feat(n7web): add pet NFC workflow and glass UI

This commit is contained in:
Miayui
2026-09-14 15:16:02 +08:00
parent 812e67422d
commit aa6f59d3a6
20 changed files with 2724 additions and 102 deletions
+130 -9
View File
@@ -15,6 +15,7 @@ from unittest.mock import patch
from cognitive_agent.application.codex_assistant import CodexAssistantService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.pet_cards import PetCardService
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.self_extension import (
GatewayError,
@@ -64,6 +65,18 @@ class SeatWebApiTests(unittest.TestCase):
cls.extension_temporary_directory.name, "gscu-ir-catalog.sqlite3"
),
)
cls.original_pets = SeatDemoHandler.pets
SeatDemoHandler.pets = PetCardService(
database_path=Path(
cls.extension_temporary_directory.name,
"pet-cards.sqlite3",
),
nfc_provider=lambda: SeatDemoHandler.nfc.snapshot(),
publisher=lambda _payload: {
"delivery_status": "queued",
"message_id": 1,
},
)
cls.original_auth = SeatDemoHandler.auth
SeatDemoHandler.auth = AuthRuntime(
AuthConfig(
@@ -94,6 +107,8 @@ class SeatWebApiTests(unittest.TestCase):
cls.thread.join(timeout=2)
SeatDemoHandler.service = cls.original_service
SeatDemoHandler.extension = cls.original_extension
SeatDemoHandler.pets.close()
SeatDemoHandler.pets = cls.original_pets
SeatDemoHandler.auth = cls.original_auth
cls.extension_temporary_directory.cleanup()
cls.temporary_directory.cleanup()
@@ -161,6 +176,105 @@ class SeatWebApiTests(unittest.TestCase):
unauthenticated.open(control_request, timeout=3)
self.assertEqual(control_denied.exception.code, 401)
def test_pet_card_crud_and_binding_accept_only_current_reader_uid(self) -> None:
create = urllib.request.Request(
f"{self.base_url}/api/extension/pets",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"pet_name": "团团", "action": "摇尾巴"},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(create, timeout=3) as response:
created = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 201)
pet_id = created["card"]["id"]
self.assertIsNone(created["card"]["nfc_id"])
self.assertFalse(created["card"]["saved"])
forged = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"pet_name": "团团",
"action": "摇尾巴",
"nfc_id": "DEADBEEF",
}
).encode("utf-8"),
)
with self.assertRaises(urllib.error.HTTPError) as rejected:
self.opener.open(forged, timeout=3)
self.assertEqual(rejected.exception.code, 400)
save = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"pet_name": "团团", "action": "摇尾巴"},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(save, timeout=3) as response:
saved = json.loads(response.read().decode("utf-8"))
self.assertTrue(saved["card"]["saved"])
original_nfc = SeatDemoHandler.nfc
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 8,
"reader": {"online": True, "state": "ready"},
"card": {"uid": "e1:13-b2 03", "action": "present"},
}
)
try:
bind = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}/bind-current-nfc",
method="POST",
headers={"Content-Type": "application/json"},
data=b"{}",
)
with self.opener.open(bind, timeout=3) as response:
bound = json.loads(response.read().decode("utf-8"))
self.assertEqual(bound["card"]["nfc_id"], "E113B203")
with self.opener.open(
f"{self.base_url}/api/extension/nfc/status",
timeout=3,
) as response:
nfc_status = json.loads(response.read().decode("utf-8"))
self.assertEqual(nfc_status["pet_cards"]["cards"][0]["nfc_id"], "E113B203")
self.assertEqual(nfc_status["pet_cards"]["publisher"]["topic"], "n7/pet")
finally:
SeatDemoHandler.nfc = original_nfc
update = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"pet_name": "团团 2", "action": "趴下"},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(update, timeout=3) as response:
updated = json.loads(response.read().decode("utf-8"))
self.assertEqual(updated["card"]["pet_name"], "团团 2")
self.assertEqual(updated["card"]["action"], "趴下")
self.assertEqual(updated["card"]["nfc_id"], "E113B203")
delete = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="DELETE",
)
with self.opener.open(delete, timeout=3) as response:
deleted = json.loads(response.read().decode("utf-8"))
self.assertEqual(deleted["deleted"]["id"], pet_id)
self.assertEqual(deleted["count"], 0)
def test_n7_mode_scene_catalog_is_public_and_read_only(self) -> None:
unauthenticated = urllib.request.build_opener()
@@ -693,7 +807,7 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-135", timeout=3) as response:
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-136", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
@@ -720,11 +834,11 @@ operations:
) as response:
speech_worklet = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=gscu-tcp-44", timeout=3
f"{self.base_url}/self-extension.js?v=pet-acrylic-48", timeout=3
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.css?v=gscu-tcp-43", timeout=3
f"{self.base_url}/self-extension.css?v=pet-acrylic-48", timeout=3
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
@@ -739,6 +853,10 @@ operations:
f"{self.base_url}/energy-monitor.css?v=energy-monitor-4", timeout=3
) as response:
energy_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-1", timeout=3
) as response:
glass_theme = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/service-worker.js", timeout=3
) as response:
@@ -769,7 +887,7 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-135"', html)
self.assertIn('src="/app.js?v=dual-seat-136"', html)
self.assertIn('href="/styles.css?v=dual-seat-23"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
@@ -782,8 +900,8 @@ operations:
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-44"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-43"', html)
self.assertIn('src="/self-extension.js?v=pet-acrylic-48"', html)
self.assertIn('href="/self-extension.css?v=pet-acrylic-48"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
@@ -794,6 +912,9 @@ operations:
self.assertIn('src="/assistant-chat.js?v=codex-assistant-32"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertIn('href="/codepen-glass-theme.css?v=creative-cloud-1"', html)
self.assertIn("--glass-shell:", glass_theme)
self.assertIn("backdrop-filter: blur(22px) saturate(1.25)", glass_theme)
self.assertNotIn('"admin"', console_script)
self.assertNotIn("v2RoleSelect", console_script)
self.assertIn('sidebarToggle.id = "v2SidebarToggle"', console_script)
@@ -1073,14 +1194,14 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v290"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v298"', service_worker)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
self.assertIn('/console-v2.js?v=local-first-70', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-32', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
self.assertIn('"/app.js?v=dual-seat-135"', service_worker)
self.assertIn('"/app.js?v=dual-seat-136"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -1537,7 +1658,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-135", timeout=3
f"{self.base_url}/app.js?v=dual-seat-136", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -19,7 +19,7 @@ def test_console_contains_ai_assistant_surface():
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-32"' in service_worker
@@ -38,7 +38,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
assert html.index("/assistant-chat.js?v=codex-assistant-32") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+25 -2
View File
@@ -42,12 +42,35 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
for asset in (
"/styles.css?v=dual-seat-23",
"/console-v2.css?v=delivery-state-69",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.css?v=pet-acrylic-48",
"/speech-input.css?v=sensevoice-7",
"/codepen-glass-theme.css?v=creative-cloud-1",
):
assert asset in html
assert f'"{asset}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
html = source("index.html")
theme = source("codepen-glass-theme.css")
assert '<meta name="theme-color" content="#17182b" />' in html
for contract in (
"--glass-shell:",
"--glass-panel:",
"--glass-card:",
"backdrop-filter: blur(22px) saturate(1.25)",
".main-panel > .v2-mode-rail",
"#assistantDrawer",
".joint-twin-shell > .twin-band",
"#v2ExtensionPanel .v2-extension-sync-mount",
".v2-pet-card",
".mode-gallery",
".v2-nfc-dialog",
".energy-monitor-page",
):
assert contract in theme
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
@@ -54,15 +54,17 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
'canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`',
self.app_source,
)
for blue_theme_token in (
"accent: 0x2f80ed",
"vehicleBody: 0x91c6f5",
"vehicleOutline: 0x2879d8",
"seatShell: 0x2b76c9",
"glass: 0x4b9bea",
"lamp: 0x2e7fd4",
for glass_theme_token in (
"accent: 0x7b70f5",
"sceneBackground: 0x111426",
"floor: 0x1a2038",
"vehicleBody: 0x729bc9",
"vehicleOutline: 0x73b8ff",
"seatShell: 0x426fbd",
"glass: 0x4d8ed2",
"lamp: 0x4385cf",
):
self.assertIn(blue_theme_token, self.app_source)
self.assertIn(glass_theme_token, self.app_source)
self.assertNotIn("accent: 0x0e7773", self.app_source)
self.assertNotIn("cognitive-agent:demo-role-change", self.app_source)
@@ -70,16 +72,21 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
self.assertIn("function createTwinGroundTexture(", self.app_source)
self.assertIn("new THREE.PlaneGeometry(30, 30)", self.app_source)
self.assertIn(
'surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)")',
"surfaceGradient.addColorStop(0, rgba(floorColor, 0.96))",
self.app_source,
)
self.assertIn(
'surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)")',
"surfaceGradient.addColorStop(1, rgba(floorColor, 0))",
self.app_source,
)
self.assertIn("const outerRadius = size * 0.48", self.app_source)
self.assertIn(
"const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52)",
"const gridStrong = new THREE.Color(palette.gridStrong)",
self.app_source,
)
self.assertIn("const gridSoft = new THREE.Color(palette.gridSoft)", self.app_source)
self.assertIn(
"gridContext.strokeStyle = rgba(major ? gridStrong : gridSoft, major ? 0.38 : 0.24)",
self.app_source,
)
self.assertIn("gridContext.lineWidth = major ? 0.9 : 0.45", self.app_source)
+37
View File
@@ -17,7 +17,9 @@ from cognitive_agent.infrastructure.can.hardware import (
)
from cognitive_agent.infrastructure.can.mqtt_transport import (
MQTT_CAN_SCHEMA,
MQTT_PET_TOPIC,
MqttCanTransport,
mqtt_pet_payload,
parse_mqtt_can_payload,
)
@@ -520,6 +522,41 @@ class MqttCanTransportTests(unittest.TestCase):
finally:
transport.close()
def test_pet_message_uses_fixed_topic_exact_json_and_never_retains(self) -> None:
payload = {
"pet_name": "团团",
"nfc_id": "E113B203",
"action": "摇尾巴",
}
publication = self.transport.publish_pet_message(payload)
topic, raw_payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, MQTT_PET_TOPIC)
self.assertEqual(raw_payload, mqtt_pet_payload(payload))
self.assertEqual(
json.loads(raw_payload),
{"pet_name": "团团", "nfc_id": "E113B203", "action": "摇尾巴"},
)
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(publication["delivery_status"], "published")
def test_pet_message_rejects_extra_fields_and_invalid_nfc_id(self) -> None:
with self.assertRaisesRegex(Exception, "exactly"):
self.transport.publish_pet_message(
{
"pet_name": "团团",
"nfc_id": "E113B203",
"action": "摇尾巴",
"command": "not-allowed",
}
)
with self.assertRaisesRegex(Exception, "uppercase hexadecimal"):
self.transport.publish_pet_message(
{"pet_name": "团团", "nfc_id": "not-a-uid", "action": "摇尾巴"}
)
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
+57 -2
View File
@@ -60,8 +60,8 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=gscu-tcp-44' in html
assert 'self-extension.css?v=gscu-tcp-43' in html
assert 'self-extension.js?v=pet-acrylic-48' in html
assert 'self-extension.css?v=pet-acrylic-48' in html
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
@@ -121,14 +121,69 @@ def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(encoding="utf-8")
assert 'nfc = NfcReaderService()' in server
assert 'pets = PetCardService(' in server
assert 'path == "/api/extension/nfc/status"' in server
assert 'path == "/api/extension/pets"' in server
assert '"pet_cards": self.pets.snapshot()' in server
assert 'path == "/api/extension/peripherals"' in server
assert '"nfc": self.nfc.snapshot()' in server
assert 'SeatDemoHandler.nfc.start()' in server
assert 'SeatDemoHandler.pets.start()' in server
assert 'SeatDemoHandler.pets.close()' in server
assert 'SeatDemoHandler.nfc.close()' in server
assert "frame-src 'self' http://*:3001" in server
def test_homepage_exposes_durable_editable_pet_cards_and_nfc_only_binding() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
service = (ROOT / "src" / "cognitive_agent" / "application" / "pet_cards.py").read_text(
encoding="utf-8"
)
mqtt = (
ROOT / "src" / "cognitive_agent" / "infrastructure" / "can" / "mqtt_transport.py"
).read_text(encoding="utf-8")
assert 'class="v2-pet-card-region"' in script
assert 'data-pet-field="pet_name"' in script
assert 'data-pet-field="action"' in script
assert '<output>${card.nfc_id ?' in script
assert 'data-pet-open-nfc' not in script
assert 'data-nfc-pet-select' in script
assert 'data-nfc-bind-pet' in script
assert 'bind-current-nfc' in script
assert 'api(`pets/${encodeURIComponent(cardId)}`' in script
assert 'burst_count' in script
assert 'burst_window_ms' in script
assert 'label: "已发送 3 帧"' in script
assert 'MQTT PET CARD' not in script
assert 'data-pet-publish-note' not in script
assert '宠物名 <code>' not in script
assert '宠物动作 <code>' not in script
assert '卡片 ID <code>' not in script
assert 'n7/pet' in script
assert 'if (nextRevision < petCardSnapshot.revision) return false' in script
assert '.v2-pet-card-region' in styles
assert '.v2-pet-card-list' in styles
assert '.v2-nfc-pet-binding' in styles
assert 'grid-template-columns: minmax(0, 1fr);' in styles
assert 'scrollbar-gutter: stable' in styles
assert 'backdrop-filter: blur(18px) saturate(1.35)' in styles
assert 'height: 58px' in styles
assert 'class PetCardService' in service
assert 'PET_MQTT_TOPIC = "n7/pet"' in service
assert 'def bind_current_nfc' in service
assert '"pet_name": str(card["pet_name"])' in service
assert '"nfc_id": nfc_id' in service
assert '"action": str(card["action"])' in service
assert 'PET_BURST_COUNT = 3' in service
assert 'PET_BURST_WINDOW_S = 1.0' in service
assert '"burst_complete"' in service
assert 'MQTT_PET_TOPIC = "n7/pet"' in mqtt
assert 'def publish_pet_message' in mqtt
def test_main_parsed_control_panel_renders_validated_infrared_operations() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
+172
View File
@@ -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()
+2 -2
View File
@@ -18,14 +18,14 @@ def test_vue_presentation_assets_are_local_ordered_and_cached() -> None:
presentation = "/vue-ui.js?v=vue-presentation-3"
assert html.index(runtime) < html.index(presentation) < html.index(
"/app.js?v=dual-seat-135"
"/app.js?v=dual-seat-136"
)
assert html.index(presentation) < html.index(
"/assistant-chat.js?v=codex-assistant-32"
)
assert f'"{runtime}"' in service_worker
assert f'"{presentation}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
assert "unpkg.com" not in html
assert "cdn.jsdelivr.net" not in html