feat(n7web): add pet NFC workflow and glass UI
This commit is contained in:
@@ -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