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