diff --git a/src/compas_xr/mqtt/messages.py b/src/compas_xr/mqtt/messages.py index f43e6a4a..549d6209 100644 --- a/src/compas_xr/mqtt/messages.py +++ b/src/compas_xr/mqtt/messages.py @@ -114,17 +114,40 @@ def __init__( time_stamp: str = None, ): super(Header, self).__init__() - self["sequence_id"] = sequence_id or self._ensure_sequence_id() - self["response_id"] = response_id or self._ensure_response_id(increment_response_ID) - self["device_id"] = device_id or self._get_device_id() - self["time_stamp"] = time_stamp or self._get_time_stamp() + # ``is None`` rather than falsiness: sequence_id and response_id are counters that + # legitimately start at 0, and ``0 or default`` would silently discard them. + self["sequence_id"] = sequence_id if sequence_id is not None else self._ensure_sequence_id() + self["response_id"] = ( + response_id if response_id is not None else self._ensure_response_id(increment_response_ID) + ) + self["device_id"] = device_id if device_id is not None else self._get_device_id() + self["time_stamp"] = time_stamp if time_stamp is not None else self._get_time_stamp() @classmethod def parse(cls, value): """Parse the header information from the input value """ - instance = cls(value["sequence_id"], value["response_id"], value["device_id"], value["time_stamp"]) + # ``parse`` is called with the decoded JSON dict off the wire, but also with a + # Header instance when a message is re-parsed locally. Unwrap to the plain mapping. + value = getattr(value, "data", value) + + missing = [ + key + for key in ("sequence_id", "response_id", "device_id", "time_stamp") + if value.get(key) is None + ] + if missing: + raise ValueError("Information for Header parsing missing: {}.".format(", ".join(missing))) + + # Keyword arguments on purpose: the first positional parameter is + # ``increment_response_ID``, so passing these positionally shifts every field. + instance = cls( + sequence_id=value["sequence_id"], + response_id=value["response_id"], + device_id=value["device_id"], + time_stamp=value["time_stamp"], + ) instance._update_sequence_counter_from_message(value["sequence_id"]) instance._update_response_id_from_message(value["response_id"]) return instance diff --git a/tests/compas_xr/mqtt/test_messages.py b/tests/compas_xr/mqtt/test_messages.py new file mode 100644 index 00000000..96614e91 --- /dev/null +++ b/tests/compas_xr/mqtt/test_messages.py @@ -0,0 +1,63 @@ +import pytest + +from compas_xr.mqtt.messages import GetTrajectoryRequest +from compas_xr.mqtt.messages import Header + + +def test_header_parse_preserves_every_field(): + """Header.parse used to pass values positionally into a signature whose first + parameter is ``increment_response_ID``, shifting every field by one.""" + wire = { + "sequence_id": 7, + "response_id": 3, + "device_id": "device-abc", + "time_stamp": "2026-09-08T10:00:00", + } + + header = Header.parse(wire) + + assert header["sequence_id"] == 7 + assert header["response_id"] == 3 + assert header["device_id"] == "device-abc" + assert header["time_stamp"] == "2026-09-08T10:00:00" + + +def test_header_parse_keeps_zero_valued_counters(): + """sequence_id and response_id are counters that legitimately start at 0, which a + falsiness check would discard and regenerate.""" + wire = { + "sequence_id": 0, + "response_id": 0, + "device_id": "device-abc", + "time_stamp": "2026-09-08T10:00:00", + } + + header = Header.parse(wire) + + assert header["sequence_id"] == 0 + assert header["response_id"] == 0 + + +@pytest.mark.parametrize("missing", ["sequence_id", "response_id", "device_id", "time_stamp"]) +def test_header_parse_rejects_incomplete_headers(missing): + wire = { + "sequence_id": 1, + "response_id": 2, + "device_id": "device-abc", + "time_stamp": "2026-09-08T10:00:00", + } + del wire[missing] + + with pytest.raises(ValueError, match=missing): + Header.parse(wire) + + +def test_message_roundtrip_preserves_header(): + request = GetTrajectoryRequest(element_id="elem-7", robot_name="ur10e") + + parsed = GetTrajectoryRequest.parse(request.data) + + assert parsed["element_id"] == "elem-7" + assert parsed["robot_name"] == "ur10e" + for key in ("sequence_id", "response_id", "device_id", "time_stamp"): + assert parsed["header"][key] == request["header"][key]