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") nfc["card"] = {"action": "present", "uid": "E113B203"} assert wait_until(lambda: len(publications) == 12) assert service.snapshot()["state"] == "burst_complete" finally: service.close() 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" ) time.sleep(0.05) assert publications == [] 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 "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