Files
debye_bec/tests/tests_devices/test_term_trail_controller.py
T

151 lines
5.5 KiB
Python

"""Tests for the vendored TermTrailController (term/trail wire-protocol support)."""
from unittest import mock
import pytest
from bec_server.device_server.tests.utils import DMMock
from ophyd_devices.tests.utils import SocketMock
from ophyd_devices.utils.controller import Controller, ControllerCommunicationError
from debye_bec.devices.utils.term_trail_controller import TermTrailController
# pylint: disable=protected-access
@pytest.fixture
def make_controller():
"""Factory fixture to build a fresh controller singleton with custom term/trail settings."""
def _make(controller_cls=TermTrailController, **kwargs):
Controller._reset_controller()
controller = controller_cls(
name="controller",
socket_cls=SocketMock,
socket_host="localhost",
socket_port=8080,
device_manager=DMMock(),
**kwargs,
)
controller.on()
return controller
yield _make
Controller._reset_controller()
def test_socket_put_appends_custom_term(make_controller):
controller = make_controller(term="\r")
controller.socket_put("get")
assert controller.sock.buffer_put == [b"get\r"]
@pytest.mark.parametrize(
["trail", "reply", "expected"],
[
(None, "value\r\n", "value"),
(None, "\r\n", ""),
(None, "line1\r\nline2\r\n", "line1\r\nline2"),
("\n", "value\n", "value"),
("\n", "\n", ""),
("\n", "line1\nline2\n", "line1\nline2"),
("", "value\r\n", "value\r\n"),
],
)
def test_remove_trailing_characters_strips_suffix_only(make_controller, trail, reply, expected):
"""The trail is stripped from the end of a reply only; mid-reply occurrences are preserved,
a bare terminator reduces to an empty payload, and an empty trail strips nothing."""
kwargs = {} if trail is None else {"trail": trail}
controller = make_controller(**kwargs)
assert controller._remove_trailing_characters(reply) == expected
def test_multiple_trail_options_strip_whichever_matches(make_controller):
"""With several trail alternatives (e.g. ACS GETVAR vs SETVAR replies), the one
that terminates the reply is stripped."""
controller = make_controller(term="\r", trail=["\r:\r", ":\r"])
controller.sock.buffer_recv = [b"8000.5\r:\r"]
assert controller.socket_put_and_receive("?GETVAR(1)") == "8000.5"
controller.sock.buffer_recv = [b":\r"]
assert controller.socket_put_and_receive("SETVAR(1,1)") == ""
def test_socket_get_reassembles_chunked_reply(make_controller):
controller = make_controller()
controller.sock.buffer_recv = [b"val", b"ue\r", b"\n"]
assert controller.socket_put_and_receive("get") == "value"
def test_socket_get_raises_on_runaway_reply(make_controller):
controller = make_controller(max_reply_length=8)
controller.sock.buffer_recv = [b"0123456789abcdef"]
with pytest.raises(ControllerCommunicationError):
controller.socket_put_and_receive("get")
def test_put_and_receive_reconnects_and_retries(make_controller):
"""A communication error triggers exactly one reconnect, then the retry succeeds."""
controller = make_controller()
controller.sock.buffer_recv = [b""] # remote closed the connection
def _restock():
controller.sock.buffer_recv = [b"value\r\n"]
with mock.patch.object(controller, "_reconnect", side_effect=_restock) as mock_reconnect:
assert controller.socket_put_and_receive("get") == "value"
mock_reconnect.assert_called_once()
@pytest.mark.parametrize(
"kwargs", [{"term": b"\n"}, {"trail": b"\r\n"}, {"term": 13}, {"trail": 0}]
)
def test_term_and_trail_must_be_strings(make_controller, kwargs):
with pytest.raises(TypeError):
make_controller(**kwargs)
def test_second_construction_with_conflicting_term_trail_warns(make_controller):
"""A second construction for the same host:port keeps the first term/trail and warns."""
controller = make_controller(term="\r", trail="\n")
with mock.patch("debye_bec.devices.utils.term_trail_controller.logger") as mock_logger:
second = TermTrailController(
name="controller",
socket_cls=SocketMock,
socket_host="localhost",
socket_port=8080,
device_manager=DMMock(),
term="\n",
)
assert second is controller
mock_logger.warning.assert_called_once()
assert controller._term == "\r"
assert controller._trail == "\n"
def test_second_construction_without_conflict_does_not_warn(make_controller):
controller = make_controller(term="\r")
with mock.patch("debye_bec.devices.utils.term_trail_controller.logger") as mock_logger:
for kwargs in ({"term": "\r"}, {}):
second = TermTrailController(
name="controller",
socket_cls=SocketMock,
socket_host="localhost",
socket_port=8080,
device_manager=DMMock(),
**kwargs,
)
assert second is controller
mock_logger.warning.assert_not_called()
def test_subclass_overrides_term_trail_as_class_attributes(make_controller):
"""Subclasses can set the wire protocol with class attributes, without any constructor plumbing."""
class CRTermController(TermTrailController):
_term = "\r"
_trail = "\n"
controller = make_controller(controller_cls=CRTermController)
controller.sock.buffer_recv = [b"value\n"]
assert controller.socket_put_and_receive("get") == "value"
assert controller.sock.buffer_put == [b"get\r"]