NODEDC_MISSION_CORE/tests/test_xgrids_application_mqt...

226 lines
7.8 KiB
Python

from __future__ import annotations
import hashlib
from collections import deque
from types import SimpleNamespace
from typing import Any, cast
import paho.mqtt.client as mqtt
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_INFO_RESPONSE_TOPIC,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
CONTROL_SUBSCRIPTION_GROUPS,
ApplicationCommandOutcomeUnknown,
ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
)
class FakeReasonCode:
def __init__(self, *, failure: bool = False) -> None:
self.is_failure = failure
class FakeClock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
class FakeControlClient:
def __init__(self, *, emit_exchange: bool = True, clock: FakeClock | None = None) -> None:
self.on_connect: Any = None
self.on_subscribe: Any = None
self.on_publish: Any = None
self.on_message: Any = None
self.on_disconnect: Any = None
self.emit_exchange = emit_exchange
self.clock = clock
self.events: deque[tuple[str, object]] = deque()
self.connect_calls: list[tuple[str, int, int]] = []
self.subscribe_calls: list[list[tuple[str, int]]] = []
self.publish_calls: list[tuple[str, bytes, int, bool]] = []
self.unsubscribe_calls: list[list[str]] = []
self.disconnect_calls = 0
self.next_mid = 20
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
self.connect_calls.append((host, port, keepalive))
self.events.append(("connect", FakeReasonCode()))
return mqtt.MQTT_ERR_SUCCESS
def subscribe(self, topics: list[tuple[str, int]]) -> tuple[mqtt.MQTTErrorCode, int]:
self.subscribe_calls.append(topics)
mid = 6 + len(self.subscribe_calls)
self.events.append(("subscribe", mid))
return mqtt.MQTT_ERR_SUCCESS, mid
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
self.publish_calls.append((topic, payload, qos, retain))
mid = self.next_mid
self.next_mid += 1
if self.emit_exchange:
self.events.append(("publish", mid))
self.events.append(("message", (DEVICE_INFO_RESPONSE_TOPIC, b"response")))
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
del timeout
if self.clock is not None:
self.clock.now += 1.0
if not self.events:
return mqtt.MQTT_ERR_SUCCESS
kind, value = self.events.popleft()
if kind == "connect":
self.on_connect(self, None, SimpleNamespace(), value, None)
elif kind == "subscribe":
group = CONTROL_SUBSCRIPTION_GROUPS[int(value) - 7]
self.on_subscribe(
self,
None,
value,
[FakeReasonCode() for _item in group],
None,
)
elif kind == "publish":
self.on_publish(self, None, value, FakeReasonCode(), None)
elif kind == "message":
topic, payload = value
self.on_message(self, None, SimpleNamespace(topic=topic, payload=payload))
return mqtt.MQTT_ERR_SUCCESS
def unsubscribe(self, topics: list[str]) -> tuple[mqtt.MQTTErrorCode, int]:
self.unsubscribe_calls.append(topics)
return mqtt.MQTT_ERR_SUCCESS, 50
def disconnect(self) -> mqtt.MQTTErrorCode:
self.disconnect_calls += 1
return mqtt.MQTT_ERR_SUCCESS
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
payload = b"synthetic-reviewed-request"
return OneShotPublishEnvelope(
operation_key=operation_key,
topic="lixel/application/request/device_info",
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
qos=2,
retain=False,
)
def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
with pytest.raises(ValueError, match="not a direct-LAN"):
ReviewedApplicationMqttTransport("192.168.56.1")
def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None:
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)
assert ("lixel/application/response/modeling", 2) in CONTROL_SUBSCRIPTION_GROUPS[2]
assert all(
topic not in {"RealtimePointcloud", "lixel/application/report/lio_pcl"}
for group in CONTROL_SUBSCRIPTION_GROUPS
for topic, _qos in group
)
def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() -> None:
fake = FakeControlClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
opened = transport.open().as_dict()
responses = transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
transport.close()
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
assert fake.publish_calls == [
(
"lixel/application/request/device_info",
b"synthetic-reviewed-request",
2,
False,
)
]
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
assert opened["clean_session"] is False
assert opened["automatic_reconnect"] is False
assert opened["automatic_retry"] is False
snapshot = transport.snapshot().as_dict()
assert snapshot["state"] == "closed"
assert snapshot["publish_attempts"] == 1
assert snapshot["subscribe_attempts"] == 3
assert snapshot["qos2_completions"] == 1
assert snapshot["correlated_responses"] == 1
assert snapshot["operation_keys_consumed"] == 1
def test_consumed_operation_key_can_never_be_published_again() -> None:
fake = FakeControlClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
assert len(fake.publish_calls) == 1
def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
clock = FakeClock()
fake = FakeControlClient(emit_exchange=False, clock=clock)
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
exchange_timeout_seconds=2.0,
client_factory=lambda: cast(mqtt.Client, fake),
monotonic=clock,
)
transport.open()
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
assert transport.snapshot().state == "poisoned"
assert len(fake.publish_calls) == 1
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
assert len(fake.publish_calls) == 1