from __future__ import annotations import tempfile import time from pathlib import Path import pytest from cognitive_agent.application.pet_cards import PetCardService, normalize_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.01) return predicate() def test_pet_cards_are_durable_and_nfc_id_only_comes_from_current_reader() -> None: nfc = {"reader": {"online": True}, "card": None} with tempfile.TemporaryDirectory() as directory: database = Path(directory, "pet-cards.sqlite3") service = PetCardService( database_path=database, nfc_provider=lambda: nfc, ) first = service.create_card("团团", "摇尾巴") second = service.create_card("圆圆", "趴下") assert first["nfc_id"] is None assert service.update_card( first["id"], pet_name="团团 2", action="向前走", )["action"] == "向前走" with pytest.raises(ValueError, match="当前未识别"): service.bind_current_nfc(first["id"]) nfc["card"] = {"uid": "e1:13-b2 03"} bound = service.bind_current_nfc(first["id"]) assert bound["nfc_id"] == "E113B203" with pytest.raises(ValueError, match="先保存"): service.bind_current_nfc(second["id"]) service.update_card( second["id"], pet_name="圆圆", action="趴下", ) rebound = service.bind_current_nfc(second["id"]) assert rebound["nfc_id"] == "E113B203" cards = {card["id"]: card for card in service.snapshot()["cards"]} assert cards[first["id"]]["nfc_id"] is None assert cards[second["id"]]["nfc_id"] == "E113B203" restored = PetCardService(database_path=database) restored_cards = {card["id"]: card for card in restored.snapshot()["cards"]} assert restored_cards[first["id"]]["pet_name"] == "团团 2" assert restored_cards[second["id"]]["nfc_id"] == "E113B203" assert restored.delete_card(first["id"])["pet_name"] == "团团 2" assert restored.snapshot()["count"] == 1 def test_bound_present_nfc_publishes_one_three_frame_burst_per_detection() -> None: nfc = {"reader": {"online": True}, "card": {"uid": "E113B203"}} publications: list[tuple[float, dict[str, str]]] = [] def publish(payload: dict[str, str]) -> dict[str, object]: publications.append((time.monotonic(), dict(payload))) return {"delivery_status": "queued", "message_id": len(publications)} with tempfile.TemporaryDirectory() as directory: service = PetCardService( database_path=Path(directory, "pet-cards.sqlite3"), nfc_provider=lambda: nfc, publisher=publish, burst_window_s=0.15, ) card = service.create_card("Lucky", "向前跑") service.update_card( card["id"], pet_name="Lucky", action="向前跑", ) service.bind_current_nfc(card["id"]) service.start() try: assert wait_until(lambda: len(publications) == 3) assert all( payload == { "pet_name": "Lucky", "nfc_id": "E113B203", "action": "向前跑", } for _captured_at, payload in publications ) assert publications[-1][0] - publications[0][0] <= 0.18 status = service.snapshot()["publisher"] assert status["state"] == "burst_complete" assert status["topic"] == "n7/pet" assert status["burst_count"] == 3 assert status["burst_attempt_count"] == 3 assert status["burst_published_count"] == 3 assert status["publish_count"] == 3 time.sleep(0.18) assert len(publications) == 3 nfc["card"] = None assert wait_until( lambda: service.snapshot()["publisher"]["state"] == "waiting_for_nfc" ) stopped_at = len(publications) time.sleep(0.18) assert len(publications) == stopped_at nfc["card"] = {"uid": "E113B203"} assert wait_until(lambda: len(publications) == 6) assert service.snapshot()["publisher"]["state"] == "burst_complete" finally: service.close() def test_invalid_pet_fields_and_nfc_uids_are_rejected() -> None: with tempfile.TemporaryDirectory() as directory: service = PetCardService( database_path=Path(directory, "pet-cards.sqlite3") ) with pytest.raises(ValueError, match="宠物名"): service.create_card("", "走动") with pytest.raises(ValueError, match="宠物动作"): service.create_card("Lucky", "") assert normalize_nfc_id("04:a1-b2 c3") == "04A1B2C3" assert normalize_nfc_id("not-a-card") == "" def test_failed_pet_burst_stops_after_three_attempts_until_redetected() -> None: nfc = {"reader": {"online": True}, "card": {"uid": "E113B203"}} attempts: list[float] = [] def fail_publish(_payload: dict[str, str]) -> dict[str, object]: attempts.append(time.monotonic()) raise RuntimeError("broker unavailable") with tempfile.TemporaryDirectory() as directory: service = PetCardService( database_path=Path(directory, "pet-cards.sqlite3"), nfc_provider=lambda: nfc, publisher=fail_publish, burst_window_s=0.09, ) card = service.create_card("Lucky", "坐下") service.update_card(card["id"], pet_name="Lucky", action="坐下") service.bind_current_nfc(card["id"]) service.start() try: assert wait_until(lambda: len(attempts) == 3) status = service.snapshot()["publisher"] assert status["state"] == "burst_failed" assert status["burst_attempt_count"] == 3 assert status["burst_published_count"] == 0 assert status["publish_count"] == 0 assert status["last_error"] == "broker unavailable" time.sleep(0.15) assert len(attempts) == 3 finally: service.close()