"""ADR-117 P4 — Tests for RuViewMqttClient. These tests do NOT bring up a broker — they exercise: 1. Topic-wildcard matching (`_topic_matches`) 2. Client construction + handler registration 3. The callback path by directly invoking the paho callback methods with synthesized messages End-to-end broker integration is a P4-followon item (the mosquitto patterns from memory [[feedback_mqtt_integration_test_patterns]] go there). This file keeps unit coverage tight without requiring a broker on every CI run. """ from __future__ import annotations import json from types import SimpleNamespace from typing import Any import pytest from wifi_densepose.client import RuViewMqttClient from wifi_densepose.client.mqtt import _topic_matches # ─── Topic wildcard matcher ────────────────────────────────────────── @pytest.mark.parametrize("pattern,topic,expected", [ ("ruview/+/raw/edge_vitals", "ruview/aabb/raw/edge_vitals", True), ("ruview/+/raw/edge_vitals", "ruview/aabb/cooked/edge_vitals", False), ("ruview/+/raw/+", "ruview/aabb/raw/pose", True), ("ruview/+/raw/+", "ruview/aabb/raw/pose/extra", False), # Per MQTT v5 §4.7.1.2: `+` is a whole-level wildcard only — mid- # segment `+` is a literal `+` character, not a wildcard. The # spec-correct way to wildcard the third segment of the HA # discovery topic is `homeassistant/+/+/+/config`. ("homeassistant/+/+/+/config", "homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", True), # `wifi_densepose_+` is therefore NOT a wildcard — it matches the # literal string only. Asserting that behaviour stays stable. ("homeassistant/+/wifi_densepose_+/+/config", "homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", False), ("ruview/#", "ruview/aabb/raw/edge_vitals", True), # Per MQTT v5 §4.7.1.2: `/#` ALSO matches the bare # `` itself (it represents "this topic and all sub-topics"). ("ruview/#", "ruview", True), ("ruview/+/raw/#", "ruview/aabb/raw/pose/extra", True), ("exact/topic", "exact/topic", True), ("exact/topic", "exact/topic/extra", False), ("a/b/c", "a/b", False), ]) def test_topic_matches(pattern: str, topic: str, expected: bool) -> None: assert _topic_matches(pattern, topic) is expected # ─── RuViewMqttClient construction ────────────────────────────────── def test_client_constructs_with_defaults() -> None: c = RuViewMqttClient() assert c.broker_host == "localhost" assert c.broker_port == 1883 assert c.connected is False assert c.client_id.startswith("wifi-densepose-client-") def test_client_unique_client_id_per_instance() -> None: """Per the rumqttc memory lesson — each instance needs a unique client_id so parallel tests don't kick each other off the broker.""" c1 = RuViewMqttClient() c2 = RuViewMqttClient() assert c1.client_id != c2.client_id def test_client_accepts_explicit_client_id() -> None: c = RuViewMqttClient(client_id="explicit-id") assert c.client_id == "explicit-id" # ─── Handler registration ──────────────────────────────────────────── def test_handler_registration_stores_callback() -> None: c = RuViewMqttClient() seen: list[Any] = [] c.on_message("ruview/+/raw/edge_vitals", lambda t, p: seen.append((t, p))) # Internal state — we're allowed to inspect since the handler # path needs to be unit-testable without a broker. assert "ruview/+/raw/edge_vitals" in c._handlers def test_handler_unregister_drops_callback() -> None: c = RuViewMqttClient() c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None) c.unsubscribe_handler("ruview/+/raw/edge_vitals") assert "ruview/+/raw/edge_vitals" not in c._handlers # ─── Callback dispatch (synthesized) ───────────────────────────────── def _fake_message(topic: str, body: Any) -> Any: """Synthesize a paho-mqtt MQTTMessage-ish object.""" if isinstance(body, (dict, list)): payload_bytes = json.dumps(body).encode("utf-8") elif isinstance(body, bytes): payload_bytes = body else: payload_bytes = str(body).encode("utf-8") return SimpleNamespace(topic=topic, payload=payload_bytes) def test_message_dispatch_to_matching_handler() -> None: c = RuViewMqttClient() received: list[tuple[str, Any]] = [] c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append((t, p))) msg = _fake_message( "ruview/aabbccddeeff/raw/edge_vitals", {"breathing_rate_bpm": 14.0, "heartrate_bpm": 72.0, "presence": True}, ) c._on_message(None, None, msg) assert len(received) == 1 topic, payload = received[0] assert topic == "ruview/aabbccddeeff/raw/edge_vitals" assert payload["breathing_rate_bpm"] == 14.0 def test_message_dispatch_ignores_non_matching_topic() -> None: c = RuViewMqttClient() received: list[Any] = [] c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append(p)) msg = _fake_message("ruview/aabb/raw/pose", {"persons": []}) c._on_message(None, None, msg) assert received == [] def test_message_dispatch_falls_back_to_bytes_on_non_json() -> None: c = RuViewMqttClient() received: list[Any] = [] c.on_message("custom/binary/+", lambda t, p: received.append(p)) msg = _fake_message("custom/binary/data", b"\x00\x01\x02not-json") c._on_message(None, None, msg) assert received == [b"\x00\x01\x02not-json"] def test_handler_exception_does_not_propagate() -> None: """A misbehaving user callback must not crash the paho network loop — exceptions are caught and logged.""" c = RuViewMqttClient() seen_after_crash: list[Any] = [] def crashing(_topic: str, _p: Any) -> None: raise RuntimeError("simulated callback crash") c.on_message("crashy/topic", crashing) c.on_message("safe/topic", lambda t, p: seen_after_crash.append(p)) # First, the crashing handler — must NOT raise out of _on_message. c._on_message(None, None, _fake_message("crashy/topic", "anything")) # Then the safe handler — must still fire on a subsequent message. c._on_message(None, None, _fake_message("safe/topic", {"x": 1})) assert seen_after_crash == [{"x": 1}] def test_multiple_handlers_for_overlapping_patterns_all_fire() -> None: c = RuViewMqttClient() a_received: list[Any] = [] b_received: list[Any] = [] c.on_message("ruview/+/raw/+", lambda t, p: a_received.append(p)) c.on_message("ruview/aabb/raw/edge_vitals", lambda t, p: b_received.append(p)) msg = _fake_message("ruview/aabb/raw/edge_vitals", {"presence": True}) c._on_message(None, None, msg) assert len(a_received) == 1 assert len(b_received) == 1 # ─── on_connect path ───────────────────────────────────────────────── def test_on_connect_sets_event_and_subscribes() -> None: c = RuViewMqttClient() c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None) # Stub the paho client so we can capture subscribe() calls. subscribed: list[str] = [] stub = SimpleNamespace(subscribe=lambda pattern: subscribed.append(pattern)) c._on_connect(stub, None, None, 0) assert c.connected is True assert subscribed == ["ruview/+/raw/edge_vitals"] def test_on_connect_with_nonzero_rc_does_not_set_connected() -> None: c = RuViewMqttClient() stub = SimpleNamespace(subscribe=lambda pattern: None) c._on_connect(stub, None, None, 5) # CONNACK fail assert c.connected is False