181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""ADR-117 P4 — Tests for the HA-MIND semantic primitive listener.
|
||
|
||
Pure routing tests — no MQTT broker needed.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
from wifi_densepose.client import (
|
||
SemanticPrimitive,
|
||
SemanticPrimitiveEvent,
|
||
SemanticPrimitiveListener,
|
||
)
|
||
|
||
|
||
# ─── SemanticPrimitive enum ──────────────────────────────────────────
|
||
|
||
|
||
def test_enum_covers_all_10_v1_primitives() -> None:
|
||
expected = {
|
||
"someone_sleeping",
|
||
"possible_distress",
|
||
"room_active",
|
||
"elderly_inactivity",
|
||
"meeting_in_progress",
|
||
"bathroom_occupied",
|
||
"fall_risk_elevated",
|
||
"bed_exit",
|
||
"no_movement_safety",
|
||
"multi_room_transition",
|
||
}
|
||
actual = {p.value for p in SemanticPrimitive}
|
||
assert actual == expected
|
||
|
||
|
||
def test_enum_from_object_id_round_trips() -> None:
|
||
for p in SemanticPrimitive:
|
||
assert SemanticPrimitive.from_object_id(p.value) is p
|
||
|
||
|
||
def test_enum_from_object_id_returns_none_for_unknown() -> None:
|
||
assert SemanticPrimitive.from_object_id("garbage") is None
|
||
|
||
|
||
# ─── Listener routing ────────────────────────────────────────────────
|
||
|
||
|
||
def test_listener_dispatches_to_specific_handler() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
received: list[SemanticPrimitiveEvent] = []
|
||
listener.on(SemanticPrimitive.SomeoneSleeping, received.append)
|
||
|
||
evt = listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state",
|
||
json.dumps({"state": "ON", "confidence": 0.92, "explanation": ["motion<5%"]}),
|
||
)
|
||
assert evt is not None
|
||
assert evt.kind is SemanticPrimitive.SomeoneSleeping
|
||
assert evt.node_id == "aabb"
|
||
assert evt.state == "ON"
|
||
assert evt.confidence == 0.92
|
||
assert evt.explanation == ("motion<5%",)
|
||
assert len(received) == 1
|
||
assert received[0] is evt
|
||
|
||
|
||
def test_listener_on_any_fires_for_every_primitive() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
seen: list[SemanticPrimitiveEvent] = []
|
||
listener.on_any(seen.append)
|
||
|
||
listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
|
||
json.dumps({"state": "ON"}),
|
||
)
|
||
listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/bathroom_occupied/state",
|
||
json.dumps({"state": "OFF"}),
|
||
)
|
||
assert len(seen) == 2
|
||
assert seen[0].kind is SemanticPrimitive.RoomActive
|
||
assert seen[1].kind is SemanticPrimitive.BathroomOccupied
|
||
|
||
|
||
def test_listener_specific_handler_does_not_fire_for_other_primitives() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
received: list[SemanticPrimitiveEvent] = []
|
||
listener.on(SemanticPrimitive.PossibleDistress, received.append)
|
||
|
||
listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state",
|
||
json.dumps({"state": "ON"}),
|
||
)
|
||
assert received == []
|
||
|
||
|
||
def test_listener_decodes_plain_state_string() -> None:
|
||
"""HA convention: binary_sensors that don't carry attributes emit
|
||
plain strings ('ON' / 'OFF'). We must accept that too."""
|
||
listener = SemanticPrimitiveListener()
|
||
evt = listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
|
||
"ON",
|
||
)
|
||
assert evt is not None
|
||
assert evt.state == "ON"
|
||
assert evt.confidence == 0.0 # not provided in plain string
|
||
assert evt.explanation == ()
|
||
|
||
|
||
def test_listener_decodes_numeric_sensor_state() -> None:
|
||
"""fall_risk_elevated is a 0–100 sensor — verify numeric string."""
|
||
listener = SemanticPrimitiveListener()
|
||
evt = listener.handle_mqtt_message(
|
||
"homeassistant/sensor/wifi_densepose_aabb/fall_risk_elevated/state",
|
||
"73",
|
||
)
|
||
assert evt is not None
|
||
assert evt.kind is SemanticPrimitive.FallRiskElevated
|
||
assert evt.state == "73"
|
||
|
||
|
||
def test_listener_decodes_bytes_payload() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
evt = listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
|
||
b"ON",
|
||
)
|
||
assert evt is not None
|
||
assert evt.state == "ON"
|
||
|
||
|
||
def test_listener_ignores_non_state_topics() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
assert listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/config",
|
||
json.dumps({"name": "Room Active"}),
|
||
) is None
|
||
|
||
|
||
def test_listener_ignores_unknown_slug() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
assert listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/unknown_primitive/state",
|
||
"ON",
|
||
) is None
|
||
|
||
|
||
def test_listener_ignores_non_wifi_densepose_node() -> None:
|
||
listener = SemanticPrimitiveListener()
|
||
# third segment doesn't start with wifi_densepose_
|
||
assert listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/aqara_fp2/room_active/state",
|
||
"ON",
|
||
) is None
|
||
|
||
|
||
def test_listener_explanation_string_is_normalised_to_tuple() -> None:
|
||
"""Producers may send `explanation` as a single string by mistake;
|
||
accept that and wrap in a 1-tuple so downstream code can iterate
|
||
uniformly."""
|
||
listener = SemanticPrimitiveListener()
|
||
evt = listener.handle_mqtt_message(
|
||
"homeassistant/binary_sensor/wifi_densepose_aabb/possible_distress/state",
|
||
json.dumps({"state": "ON", "explanation": "HR=120 baseline=80"}),
|
||
)
|
||
assert evt is not None
|
||
assert evt.explanation == ("HR=120 baseline=80",)
|
||
|
||
|
||
def test_event_is_frozen() -> None:
|
||
evt = SemanticPrimitiveEvent(
|
||
kind=SemanticPrimitive.SomeoneSleeping,
|
||
node_id="aabb",
|
||
state="ON",
|
||
)
|
||
import pytest
|
||
with pytest.raises((AttributeError, Exception)): # FrozenInstanceError subclass
|
||
evt.state = "OFF" # type: ignore[misc]
|