151 lines
5.5 KiB
Python
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"]
|