feat(n7web): add pet NFC workflow and glass UI
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user