Files
Cognitive_Agent/tests/unit/test_extension_toy_publisher.py
T

305 lines
11 KiB
Python

from __future__ import annotations
import time
from collections import Counter
from typing import Any
from cognitive_agent.application.extension_toy_publisher import (
NfcExtensionToyPublisher,
build_extension_toy_messages,
normalize_extension_nfc_id,
)
def wait_until(predicate, timeout: float = 1.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.005)
return bool(predicate())
def capability(
capability_id: str,
*,
device_id: str = "toy-light-001",
device_name: str = "闪烁灯",
card_uid: str = "E113B203",
slot: int = 2,
action: str = "打开",
enabled: bool = True,
status: str = "learned",
**extra: Any,
) -> dict[str, Any]:
return {
"id": capability_id,
"status": status,
"enabled": enabled,
"device_id": device_id,
"device_name": device_name,
"card_uid": card_uid,
"slot": slot,
"action": action,
**extra,
}
def test_message_projection_groups_devices_orders_slots_and_filters_catalog() -> None:
catalog = [
capability("light-blue", slot=5, action="切换为蓝色"),
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
capability("light-on", slot=2, action="打开"),
capability("duplicate", slot=2, action="打开"),
capability("disabled", slot=1, action="关闭", enabled=False),
capability("other-card", slot=6, action="设置为白色", card_uid="04A1B2C3"),
capability("draft", slot=3, action="切换为红色", status="draft"),
capability("missing-enabled", slot=4, action="切换为绿色", enabled=None),
]
messages = build_extension_toy_messages("e1:13-b2 03", catalog)
assert [message["extension_name"] for message in messages] == ["小风扇", "闪烁灯"]
fan, light = messages
assert fan["actions"] == [
{
"type": "infrared_emit",
"at_ms": 0,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": "toy-fan-001",
"operation": "emit",
"parameters": {"action": "打开", "slot": 2},
}
]
assert [action["parameters"] for action in light["actions"]] == [
{"action": "打开", "slot": 2},
{"action": "切换为蓝色", "slot": 5},
]
assert {action["target_scope"] for action in light["actions"]} == {
"toy-light-001"
}
def test_projection_prefers_chinese_name_but_keeps_english_stable_scope() -> None:
catalog = [
capability(
"desk-light-on",
device_id="desk-light-01",
device_name="Desk Light",
object_names=["闪烁灯"],
)
]
[message] = build_extension_toy_messages("E113B203", catalog)
assert message["extension_name"] == "闪烁灯"
assert message["actions"][0]["target_scope"] == "desk-light-01"
assert normalize_extension_nfc_id("04:a1-b2 c3") == "04A1B2C3"
assert normalize_extension_nfc_id("not-a-card") == ""
def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> None:
nfc: dict[str, Any] = {
"reader": {"online": True},
"card": {"action": "present", "uid": "E113B203"},
}
catalog = [
capability("light-on"),
capability("light-blue", slot=5, action="切换为蓝色"),
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
]
publications: list[tuple[float, dict[str, Any]]] = []
def publish(payload: dict[str, Any]) -> dict[str, Any]:
publications.append((time.monotonic(), payload))
return {"delivery_status": "queued", "message_id": len(publications)}
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: catalog,
publisher=publish,
burst_window_s=0.12,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(lambda: len(publications) == 6)
assert publications[-1][0] - publications[0][0] <= 0.12
assert Counter(
publication["actions"][0]["target_scope"]
for _at, publication in publications
) == {"toy-fan-001": 3, "toy-light-001": 3}
status = service.snapshot()
assert status["state"] == "burst_complete"
assert status["burst_frame_attempt_count"] == 3
assert status["burst_publish_attempt_count"] == 6
assert status["burst_published_count"] == 6
assert status["qos"] == 1
assert status["retain"] is False
time.sleep(0.14)
assert len(publications) == 6
nfc["card"] = None
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
assert service.snapshot()["detected_nfc_ids"] == ["E113B203"]
nfc["card"] = {"action": "present", "uid": "E113B203"}
assert wait_until(lambda: len(publications) == 12)
assert service.snapshot()["state"] == "burst_complete"
finally:
service.close()
def test_detected_devices_remain_latched_until_publisher_stops() -> None:
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: [capability("light-on")],
publisher=lambda _payload: {"delivery_status": "queued"},
burst_window_s=0.03,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(
lambda: service.snapshot()["detected_nfc_ids"] == ["E113B203"]
)
nfc["card"] = None
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
assert service.snapshot()["detected_nfc_ids"] == ["E113B203"]
finally:
service.close()
assert service.snapshot()["detected_nfc_ids"] == []
def test_no_matching_ability_publishes_nothing() -> None:
nfc = {"card": {"action": "present", "uid": "E113B203"}}
publications: list[dict[str, Any]] = []
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: [
capability("other-card", card_uid="04A1B2C3"),
capability("disabled", enabled=False),
],
publisher=lambda payload: publications.append(dict(payload)) or {},
burst_window_s=0.03,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(
lambda: service.snapshot()["state"] == "no_matching_extension"
)
assert service.snapshot()["detected_nfc_ids"] == ["E113B203"]
time.sleep(0.05)
assert publications == []
finally:
service.close()
def test_binding_a_still_present_card_retries_after_catalog_revision_change() -> None:
nfc = {"card": {"action": "present", "uid": "E113B203"}}
catalog: list[dict[str, Any]] = []
catalog_revision = {"value": "catalog-a"}
publications: list[dict[str, Any]] = []
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: catalog,
catalog_revision_provider=lambda: catalog_revision["value"],
publisher=lambda payload: publications.append(dict(payload))
or {"delivery_status": "queued", "message_id": len(publications)},
burst_window_s=0.06,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(
lambda: service.snapshot()["state"] == "no_matching_extension"
)
assert publications == []
catalog.append(capability("new-device-on"))
catalog_revision["value"] = "catalog-b"
assert wait_until(lambda: len(publications) == 3)
status = service.snapshot()
assert status["state"] == "burst_complete"
assert status["topic"] == "extension/toy"
assert status["active_catalog_revision"] == "catalog-b"
assert status["burst_published_count"] == 3
catalog[:] = [
capability(
"replacement-device-on",
device_id="toy-light-003",
device_name="新灯带",
slot=21,
)
]
catalog_revision["value"] = "catalog-c"
assert wait_until(lambda: len(publications) == 6)
assert publications[-1]["actions"][0]["target_scope"] == "toy-light-003"
# An unrelated revision with identical abilities must not duplicate a burst.
catalog_revision["value"] = "catalog-d"
time.sleep(0.08)
assert len(publications) == 6
finally:
service.close()
def test_failed_burst_attempts_only_three_frames_until_redetected() -> None:
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
attempts: list[float] = []
def fail_publish(_payload: dict[str, Any]) -> dict[str, Any]:
attempts.append(time.monotonic())
raise RuntimeError("broker unavailable")
service = NfcExtensionToyPublisher(
nfc_provider=lambda: nfc,
catalog_provider=lambda: [capability("light-on")],
publisher=fail_publish,
burst_window_s=0.06,
poll_interval_s=0.005,
)
service.start()
try:
assert wait_until(lambda: len(attempts) == 3)
status = service.snapshot()
assert status["state"] == "burst_failed"
assert status["burst_publish_attempt_count"] == 3
assert status["burst_published_count"] == 0
assert status["last_error"] == "broker unavailable"
time.sleep(0.08)
assert len(attempts) == 3
finally:
service.close()
def test_server_wires_publisher_without_invoking_infrared_execution() -> None:
from pathlib import Path
root = Path(__file__).resolve().parents[2]
server = (root / "src" / "cognitive_agent" / "server.py").read_text(
encoding="utf-8"
)
service = (
root / "src" / "cognitive_agent" / "application" / "seat_service.py"
).read_text(encoding="utf-8")
assert "extension_toys = NfcExtensionToyPublisher(" in server
assert "catalog_provider=extension.list_peripheral_capabilities" in server
assert "catalog_revision_provider=extension.peripheral_catalog_revision" in server
assert "publisher=service.publish_extension_toy_message" in server
assert "SeatDemoHandler.extension_toys.start()" in server
assert "SeatDemoHandler.extension_toys.close()" in server
assert "publish_extension_toy_message" in service
method = service.split("def publish_extension_toy_message", 1)[1].split(
"\n def ", 1
)[0]
assert "_vehicle_mqtt_transport.publish_extension_toy_message" in method
assert "execute_gateway" not in method