"""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"]