feat(n7web): enhance control workflows and status feedback
This commit is contained in:
@@ -277,6 +277,7 @@ class FakeVehicleService:
|
||||
self.calls: list[dict[str, object]] = []
|
||||
self.mode_save_calls: list[dict[str, object]] = []
|
||||
self.mode_trial_calls: list[dict[str, object]] = []
|
||||
self.mode_scene_publish_calls: list[dict[str, object]] = []
|
||||
self.fail = False
|
||||
|
||||
def preview_native_can(self, **kwargs: object) -> dict[str, object]:
|
||||
@@ -481,6 +482,44 @@ class FakeVehicleService:
|
||||
"source_request_id": source_request_id,
|
||||
}
|
||||
|
||||
def save_mode_profile_card(
|
||||
self,
|
||||
mode_id: str,
|
||||
name: str,
|
||||
instruction: str,
|
||||
source_request_id: str,
|
||||
vehicle_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"mode_profile": {
|
||||
"id": mode_id,
|
||||
"name": name,
|
||||
"origin": "user",
|
||||
"operation_count": 1,
|
||||
"instruction": instruction,
|
||||
},
|
||||
"vehicle_id": vehicle_id,
|
||||
"storage": "persistent_yaml",
|
||||
"source_request_id": source_request_id,
|
||||
}
|
||||
|
||||
def publish_saved_mode_scene(
|
||||
self,
|
||||
mode_id: str,
|
||||
vehicle_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
self.mode_scene_publish_calls.append(
|
||||
{"mode_id": mode_id, "vehicle_id": vehicle_id}
|
||||
)
|
||||
return {
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": mode_id,
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 1_000,
|
||||
}
|
||||
|
||||
def trial_mode_profile_draft(
|
||||
self,
|
||||
name: str,
|
||||
@@ -548,6 +587,7 @@ class TestCodexAssistantWebApi:
|
||||
AssistantApiHandler.service.calls.clear()
|
||||
AssistantApiHandler.service.mode_save_calls.clear()
|
||||
AssistantApiHandler.service.mode_trial_calls.clear()
|
||||
AssistantApiHandler.service.mode_scene_publish_calls.clear()
|
||||
AssistantApiHandler.service.fail = False
|
||||
FAKE_CODEX.available = True
|
||||
FAKE_CODEX.turn_calls.clear()
|
||||
@@ -1386,6 +1426,34 @@ class TestCodexAssistantWebApi:
|
||||
assert saved["status"] == "succeeded"
|
||||
assert saved["mode_profile"]["origin"] == "user"
|
||||
assert saved["mode_profile"]["operation_count"] == 9
|
||||
assert AssistantApiHandler.service.mode_scene_publish_calls == [
|
||||
{"mode_id": "mode-movie", "vehicle_id": "denza_n7"}
|
||||
]
|
||||
assert saved["mode_scene_mqtt"] == {
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": "mode-movie",
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 1_000,
|
||||
}
|
||||
|
||||
update_status, _content_type, updated = self._request_json(
|
||||
"/api/mode-profiles/mode-movie",
|
||||
payload["mode_profile_draft"],
|
||||
)
|
||||
assert update_status == 200
|
||||
assert updated["mode_profile"]["id"] == "mode-movie"
|
||||
assert updated["mode_scene_mqtt"] == {
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": "mode-movie",
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 1_000,
|
||||
}
|
||||
assert AssistantApiHandler.service.mode_scene_publish_calls == [
|
||||
{"mode_id": "mode-movie", "vehicle_id": "denza_n7"},
|
||||
{"mode_id": "mode-movie", "vehicle_id": "denza_n7"},
|
||||
]
|
||||
|
||||
def test_vehicle_failure_is_recorded_and_never_overwritten_by_codex_success(self) -> None:
|
||||
AssistantApiHandler.service.fail = True
|
||||
|
||||
+292
-143
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -18,6 +19,7 @@ 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 (
|
||||
ExtensionIntegrationError,
|
||||
GatewayError,
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
@@ -453,12 +455,55 @@ operations:
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
updated_scenes: list[dict[str, object]] = []
|
||||
updated_at: list[float] = []
|
||||
update_burst_complete = threading.Event()
|
||||
|
||||
def capture_updated_scene(
|
||||
scene: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
updated_scenes.append(json.loads(json.dumps(scene)))
|
||||
updated_at.append(time.monotonic())
|
||||
if len(updated_scenes) == 3:
|
||||
update_burst_complete.set()
|
||||
return {
|
||||
"delivery_status": "published",
|
||||
"message_id": len(updated_scenes),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
self.service._vehicle_mqtt_transport,
|
||||
"publish_mode_scene",
|
||||
side_effect=capture_updated_scene,
|
||||
):
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
self.assertTrue(update_burst_complete.wait(timeout=2))
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(payload["storage"], "persistent_yaml")
|
||||
self.assertEqual(payload["mode_profile"]["origin"], "customized")
|
||||
self.assertEqual(payload["mode_profile"]["instruction"], instruction)
|
||||
self.assertEqual(
|
||||
payload["mode_scene_mqtt"],
|
||||
{
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": "office",
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 1_000,
|
||||
},
|
||||
)
|
||||
expected_updated_scene = next(
|
||||
scene
|
||||
for scene in self.service.mode_scene_catalog("denza_n7")["scenes"]
|
||||
if scene["id"] == "office"
|
||||
)
|
||||
self.assertEqual(len(updated_scenes), 3)
|
||||
self.assertLess(updated_at[-1] - updated_at[0], 1.0)
|
||||
self.assertTrue(
|
||||
all(scene == expected_updated_scene for scene in updated_scenes)
|
||||
)
|
||||
self.assertTrue(all("scenes" not in scene for scene in updated_scenes))
|
||||
self.assertTrue(
|
||||
(user_root / "vehicles" / "denza_n7" / "user" / "office.yaml").is_file()
|
||||
)
|
||||
@@ -477,11 +522,50 @@ operations:
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(create_request, timeout=3) as response:
|
||||
created = json.loads(response.read().decode("utf-8"))
|
||||
published_scenes: list[dict[str, object]] = []
|
||||
published_at: list[float] = []
|
||||
burst_complete = threading.Event()
|
||||
|
||||
def capture_scene(scene: dict[str, object]) -> dict[str, object]:
|
||||
published_scenes.append(json.loads(json.dumps(scene)))
|
||||
published_at.append(time.monotonic())
|
||||
if len(published_scenes) == 3:
|
||||
burst_complete.set()
|
||||
return {
|
||||
"delivery_status": "published",
|
||||
"message_id": len(published_scenes),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
self.service._vehicle_mqtt_transport,
|
||||
"publish_mode_scene",
|
||||
side_effect=capture_scene,
|
||||
):
|
||||
with self.opener.open(create_request, timeout=3) as response:
|
||||
created = json.loads(response.read().decode("utf-8"))
|
||||
self.assertTrue(burst_complete.wait(timeout=2))
|
||||
self.assertEqual(response.status, 201)
|
||||
self.assertEqual(created["mode_profile"]["origin"], "user")
|
||||
self.assertEqual(created["mode_profile"]["name"], "通勤模式")
|
||||
self.assertEqual(
|
||||
created["mode_scene_mqtt"],
|
||||
{
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": created["mode_profile"]["id"],
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 1_000,
|
||||
},
|
||||
)
|
||||
expected_scene = next(
|
||||
scene
|
||||
for scene in self.service.mode_scene_catalog("denza_n7")["scenes"]
|
||||
if scene["id"] == created["mode_profile"]["id"]
|
||||
)
|
||||
self.assertEqual(len(published_scenes), 3)
|
||||
self.assertLess(published_at[-1] - published_at[0], 1.0)
|
||||
self.assertTrue(all(scene == expected_scene for scene in published_scenes))
|
||||
self.assertTrue(all("scenes" not in scene for scene in published_scenes))
|
||||
self.assertTrue(
|
||||
(
|
||||
user_root
|
||||
@@ -575,7 +659,7 @@ operations:
|
||||
snapshot=lambda: {
|
||||
"revision": 1,
|
||||
"reader": {"online": True},
|
||||
"card": {"action": "present", "uid": "2CC8B103"},
|
||||
"card": None,
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -629,7 +713,17 @@ operations:
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(save_request, timeout=3) as response:
|
||||
with patch.object(
|
||||
self.service,
|
||||
"publish_saved_mode_scene",
|
||||
return_value={
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": "office",
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 1_000,
|
||||
},
|
||||
), self.opener.open(save_request, timeout=3) as response:
|
||||
saved = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
execute_request = urllib.request.Request(
|
||||
@@ -721,14 +815,35 @@ operations:
|
||||
handler = object.__new__(SeatDemoHandler)
|
||||
handler.extension = extension
|
||||
handler.nfc = SimpleNamespace(
|
||||
snapshot=lambda: {
|
||||
"card": {"action": "present", "uid": "2CC8B103"}
|
||||
}
|
||||
snapshot=lambda: {"card": None}
|
||||
)
|
||||
profile = SimpleNamespace(
|
||||
command_flow={
|
||||
"function_configs": {
|
||||
"local_self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"capability": "infrared.emit",
|
||||
"target": {
|
||||
"device": "tony-light-001",
|
||||
"card_uid": "2CC8B103",
|
||||
},
|
||||
"parameters": {"action": "打开", "slot": 1},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
entries = handler._saved_mode_infrared_entries(profile)
|
||||
missing_binding = SimpleNamespace(
|
||||
command_flow={
|
||||
"function_configs": {
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
@@ -743,8 +858,11 @@ operations:
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
entries = handler._saved_mode_infrared_entries(profile)
|
||||
with self.assertRaisesRegex(
|
||||
ExtensionIntegrationError,
|
||||
"缺少持久 NFC 绑定",
|
||||
):
|
||||
handler._saved_mode_infrared_entries(missing_binding)
|
||||
|
||||
self.assertEqual([item["capability"]["slot"] for item in entries], [1])
|
||||
self.assertEqual(
|
||||
@@ -807,14 +925,21 @@ 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-140", timeout=3) as response:
|
||||
web_console_root = REPOSITORY_ROOT / "apps" / "web-console"
|
||||
template = (web_console_root / "src" / "App.vue").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
vite_entry = (web_console_root / "src" / "main.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-141", 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
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=local-first-70", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=local-first-76", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -842,7 +967,7 @@ operations:
|
||||
) as response:
|
||||
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-33", timeout=3
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-34", timeout=3
|
||||
) as response:
|
||||
assistant_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -854,7 +979,7 @@ operations:
|
||||
) as response:
|
||||
energy_styles = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-2", timeout=3
|
||||
f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-5", timeout=3
|
||||
) as response:
|
||||
glass_theme = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -869,50 +994,57 @@ operations:
|
||||
f"{self.base_url}/api/extension/nfc/status", timeout=3
|
||||
) as response:
|
||||
nfc_status = json.loads(response.read().decode("utf-8"))
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/extension/control/status"
|
||||
"?after_revision=-1&timeout_ms=0",
|
||||
timeout=3,
|
||||
) as response:
|
||||
extension_control_status = json.loads(response.read().decode("utf-8"))
|
||||
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
|
||||
runtime = json.loads(response.read().decode("utf-8"))
|
||||
with self.opener.open(f"{self.base_url}/api/speech/status", timeout=3) as response:
|
||||
speech_status = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
self.assertIn("511工程-智能座舱", html)
|
||||
self.assertIn('id="seatTwinCanvas"', html)
|
||||
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html)
|
||||
self.assertIn("整车孪生界面", html)
|
||||
self.assertIn("<strong>车内灯光</strong>", html)
|
||||
self.assertIn('id="interiorLightPower"', html)
|
||||
self.assertIn('id="ambientBrightness"', html)
|
||||
self.assertIn('id="ambientColorIndex"', html)
|
||||
self.assertIn('data-function="massage"', html)
|
||||
self.assertIn("CMD 0x1DE · FB 0x404 · CH1", html)
|
||||
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-140"', 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)
|
||||
self.assertIn('src="/console-v2.js?v=local-first-70"', html)
|
||||
self.assertIn('href="/console-v2.css?v=delivery-state-69"', html)
|
||||
self.assertIn('id="seatTwinCanvas"', template)
|
||||
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', template)
|
||||
self.assertIn("整车孪生界面", template)
|
||||
self.assertIn("<strong>车内灯光</strong>", template)
|
||||
self.assertIn('id="interiorLightPower"', template)
|
||||
self.assertIn('id="ambientBrightness"', template)
|
||||
self.assertIn('id="ambientColorIndex"', template)
|
||||
self.assertIn('data-function="massage"', template)
|
||||
self.assertIn("CMD 0x1DE · FB 0x404 · CH1", template)
|
||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", template)
|
||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
|
||||
self.assertNotIn("透明车体 · 实体设备", template)
|
||||
self.assertIn('src="/app.js?v=dual-seat-141"', html)
|
||||
self.assertIn('import "../styles.css";', vite_entry)
|
||||
self.assertNotIn('class="vehicle-system-hud"', template)
|
||||
self.assertIn('src="/console-v2.js?v=local-first-76"', html)
|
||||
self.assertIn('import "../console-v2.css";', vite_entry)
|
||||
self.assertIn('src="/speech-input.js?v=sensevoice-13"', html)
|
||||
self.assertIn('href="/speech-input.css?v=sensevoice-7"', html)
|
||||
self.assertIn('id="speechDictationButton"', html)
|
||||
self.assertIn('class="speech-waveform"', html)
|
||||
self.assertIn('import "../speech-input.css";', vite_entry)
|
||||
self.assertIn('id="speechDictationButton"', template)
|
||||
self.assertIn('class="speech-waveform"', template)
|
||||
self.assertIn('<body class="console-v2">', html)
|
||||
self.assertIn('id="v2BootScreen"', html)
|
||||
self.assertIn('正在加载车机控制与实时状态', html)
|
||||
self.assertIn('id="v2BootScreen"', template)
|
||||
self.assertIn('正在加载车机控制与实时状态', template)
|
||||
self.assertIn('src="/self-extension.js?v=pet-settings-52"', html)
|
||||
self.assertIn('href="/self-extension.css?v=pet-settings-52"', html)
|
||||
self.assertNotIn("self-calibration", html)
|
||||
self.assertNotIn("self-repair", html)
|
||||
self.assertNotIn("self-evolution", html)
|
||||
self.assertNotIn("滚动查看更多", html)
|
||||
self.assertIn('aria-label="发送消息"', html)
|
||||
self.assertIn('id="assistantTranscript"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-33"', html)
|
||||
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
|
||||
self.assertIn('import "../self-extension.css";', vite_entry)
|
||||
self.assertNotIn("self-calibration", template)
|
||||
self.assertNotIn("self-repair", template)
|
||||
self.assertNotIn("self-evolution", template)
|
||||
self.assertNotIn("滚动查看更多", template)
|
||||
self.assertIn('aria-label="发送消息"', template)
|
||||
self.assertIn('id="assistantTranscript"', template)
|
||||
self.assertIn('import "../assistant-chat.css";', vite_entry)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-34"', html)
|
||||
self.assertIn('import "../energy-monitor.css";', vite_entry)
|
||||
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
||||
self.assertIn('href="/codepen-glass-theme.css?v=creative-cloud-2"', html)
|
||||
self.assertIn('import "../codepen-glass-theme.css";', vite_entry)
|
||||
self.assertEqual(extension_control_status["source_label"], "车端 MQTT")
|
||||
self.assertIn("action_statuses", extension_control_status)
|
||||
self.assertIn("--glass-shell:", glass_theme)
|
||||
self.assertIn("backdrop-filter: blur(22px) saturate(1.25)", glass_theme)
|
||||
self.assertNotIn('"admin"', console_script)
|
||||
@@ -984,7 +1116,7 @@ operations:
|
||||
self.assertIn('const DEFAULT_EXECUTION_MODE = "hardware"', app_script)
|
||||
self.assertIn('preferredExecutionMode: DEFAULT_EXECUTION_MODE', app_script)
|
||||
self.assertIn('selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false })', app_script)
|
||||
self.assertIn('class="mode-option active" id="hardwareMode"', html)
|
||||
self.assertIn('class="mode-option active" id="hardwareMode"', template)
|
||||
self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles)
|
||||
self.assertNotIn('.v2-control-switch {', console_styles)
|
||||
self.assertIn('.v2-module-switcher {', console_styles)
|
||||
@@ -1036,12 +1168,12 @@ operations:
|
||||
self.assertNotIn('.v2-config-section', console_styles)
|
||||
self.assertNotIn('.console-v2 .v2-config-card {', console_styles)
|
||||
self.assertIn('.console-v2.v2-sidebar-collapsed .workspace', console_styles)
|
||||
self.assertNotIn('id="hardwareAccessField"', html)
|
||||
self.assertNotIn('id="hardwareClientLabel"', html)
|
||||
self.assertNotIn('id="requestHardwareAccess"', html)
|
||||
self.assertNotIn('id="releaseHardwareAccess"', html)
|
||||
self.assertNotIn('id="approvalField"', html)
|
||||
self.assertNotIn('id="approvalToken"', html)
|
||||
self.assertNotIn('id="hardwareAccessField"', template)
|
||||
self.assertNotIn('id="hardwareClientLabel"', template)
|
||||
self.assertNotIn('id="requestHardwareAccess"', template)
|
||||
self.assertNotIn('id="releaseHardwareAccess"', template)
|
||||
self.assertNotIn('id="approvalField"', template)
|
||||
self.assertNotIn('id="approvalToken"', template)
|
||||
self.assertNotIn("approval_token: approvalToken", app_script)
|
||||
self.assertNotIn("授权并真实执行", app_script)
|
||||
self.assertIn('const dynamicOrder = new Map([["rocking_horse", 0]', app_script)
|
||||
@@ -1052,19 +1184,19 @@ operations:
|
||||
'if (mode === "hardware" && !hardwareExecutionReady) return false',
|
||||
app_script,
|
||||
)
|
||||
self.assertIn('data-control-transport="wired_can"', html)
|
||||
self.assertIn('data-control-transport="wireless_mqtt"', html)
|
||||
self.assertIn('<div class="mode-rail" aria-label="功能模式">', html)
|
||||
self.assertIn('id="modeRailToggle"', html)
|
||||
self.assertIn('id="modeRailContent"', html)
|
||||
self.assertIn('role="region"', html)
|
||||
self.assertIn('aria-label="功能模式列表"', html)
|
||||
self.assertIn('<span>AI 新建</span>', html)
|
||||
self.assertIn('class="mode-rail-identity"', html)
|
||||
self.assertIn('aria-expanded="true"', html)
|
||||
self.assertIn('模拟下发', html)
|
||||
self.assertIn('真实下发', html)
|
||||
self.assertNotIn('id="simulationMode"', html)
|
||||
self.assertIn('data-control-transport="wired_can"', template)
|
||||
self.assertIn('data-control-transport="wireless_mqtt"', template)
|
||||
self.assertIn('<div class="mode-rail" aria-label="功能模式">', template)
|
||||
self.assertIn('id="modeRailToggle"', template)
|
||||
self.assertIn('id="modeRailContent"', template)
|
||||
self.assertIn('role="region"', template)
|
||||
self.assertIn('aria-label="功能模式列表"', template)
|
||||
self.assertIn('<span>AI 新建</span>', template)
|
||||
self.assertIn('class="mode-rail-identity"', template)
|
||||
self.assertIn('aria-expanded="true"', template)
|
||||
self.assertIn('模拟下发', template)
|
||||
self.assertIn('真实下发', template)
|
||||
self.assertNotIn('id="simulationMode"', template)
|
||||
self.assertIn('control_transport: state.controlTransport', app_script)
|
||||
self.assertIn('button.dataset.connectionState = connectionState', app_script)
|
||||
self.assertIn('信号链路${connectionState === "online" ? "正常" : "断开"}', app_script)
|
||||
@@ -1097,16 +1229,16 @@ operations:
|
||||
)
|
||||
self.assertNotIn(
|
||||
'id="hardwareMode" data-execution-mode="hardware" disabled',
|
||||
html,
|
||||
template,
|
||||
)
|
||||
self.assertIn('const seat = twin.selected === "driver" ? "driver" : "passenger"', app_script)
|
||||
self.assertNotIn('data-mode-command="seat driver vent low"', app_script)
|
||||
self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script)
|
||||
self.assertIn("CH1 主驾实车测试不启用无闭环的座椅位置手势", app_script)
|
||||
self.assertNotIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script)
|
||||
self.assertNotIn('id="hardwareNote"', html)
|
||||
self.assertNotIn('id="hardwareAliasNote"', html)
|
||||
self.assertNotIn('id="vehicleTransportNote"', html)
|
||||
self.assertNotIn('id="hardwareNote"', template)
|
||||
self.assertNotIn('id="hardwareAliasNote"', template)
|
||||
self.assertNotIn('id="vehicleTransportNote"', template)
|
||||
self.assertIn("下发控制指令", console_script)
|
||||
self.assertIn('new Set(["mode_editor_compile"])', console_script)
|
||||
self.assertIn("function extractReferenceWindowPanes", app_script)
|
||||
@@ -1198,14 +1330,17 @@ 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-v305"', service_worker)
|
||||
self.assertIn(
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"',
|
||||
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('/console-v2.js?v=local-first-76', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-33', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-34', 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-140"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-141"', 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)
|
||||
@@ -1264,39 +1399,42 @@ operations:
|
||||
self.assertIn('error.code === "hardware_busy"', app_script)
|
||||
self.assertIn("BENCH BUSY", app_script)
|
||||
self.assertIn("FBXLoader.js", html)
|
||||
self.assertIn('data-seat-layout="five-seat"', html)
|
||||
self.assertIn('data-seat-layout="five-seat"', template)
|
||||
|
||||
self.assertIn('data-active-seat="front-right"', html)
|
||||
self.assertNotIn('placeholder="请输入服务端配置令牌"', html)
|
||||
self.assertNotIn('class="twin-callout-layer"', html)
|
||||
self.assertNotIn('class="twin-callout', html)
|
||||
self.assertNotIn('data-callout=', html)
|
||||
self.assertIn('data-twin-function="ventilation"', html)
|
||||
self.assertIn('data-twin-function="heating"', html)
|
||||
self.assertIn('class="twin-level-meter"', html)
|
||||
self.assertIn('id="presetIndicator"', html)
|
||||
self.assertIn('id="vehicleSelect"', html)
|
||||
self.assertIn('id="saveModeToggle"', html)
|
||||
self.assertIn("AI 新建", html)
|
||||
self.assertIn('aria-label="快捷功能模式,可横向滚动"', html)
|
||||
self.assertIn('id="modeRailContent"', html)
|
||||
self.assertIn('aria-label="功能模式列表"', html)
|
||||
self.assertNotIn('id="modeRailPrev"', html)
|
||||
self.assertNotIn('id="modeRailNext"', html)
|
||||
self.assertGreater(html.index('id="saveModeToggle"'), html.index('id="modeRailContent"'))
|
||||
self.assertIn('id="modeGalleryToggle"', html)
|
||||
self.assertIn('id="modeGallery"', html)
|
||||
self.assertIn('id="modeGalleryGroups"', html)
|
||||
self.assertIn("管理功能模式", html)
|
||||
self.assertIn('id="modeEditorBackdrop"', html)
|
||||
self.assertIn('id="modeEditorName"', html)
|
||||
self.assertIn('id="modeEditorInstruction"', html)
|
||||
self.assertIn('id="modeEditorExecute"', html)
|
||||
self.assertIn('id="modeEditorComplete"', html)
|
||||
self.assertIn('id="modeEditorFlowPreview"', html)
|
||||
self.assertIn('class="mode-editor-flow-preview empty"', html)
|
||||
self.assertNotIn('id="saveModeSource"', html)
|
||||
self.assertNotIn("主驾 24℃", html)
|
||||
self.assertIn('data-active-seat="front-right"', template)
|
||||
self.assertNotIn('placeholder="请输入服务端配置令牌"', template)
|
||||
self.assertNotIn('class="twin-callout-layer"', template)
|
||||
self.assertNotIn('class="twin-callout', template)
|
||||
self.assertNotIn('data-callout=', template)
|
||||
self.assertIn('data-twin-function="ventilation"', template)
|
||||
self.assertIn('data-twin-function="heating"', template)
|
||||
self.assertIn('class="twin-level-meter"', template)
|
||||
self.assertIn('id="presetIndicator"', template)
|
||||
self.assertIn('id="vehicleSelect"', template)
|
||||
self.assertIn('id="saveModeToggle"', template)
|
||||
self.assertIn("AI 新建", template)
|
||||
self.assertIn('aria-label="快捷功能模式,可横向滚动"', template)
|
||||
self.assertIn('id="modeRailContent"', template)
|
||||
self.assertIn('aria-label="功能模式列表"', template)
|
||||
self.assertNotIn('id="modeRailPrev"', template)
|
||||
self.assertNotIn('id="modeRailNext"', template)
|
||||
self.assertGreater(
|
||||
template.index('id="saveModeToggle"'),
|
||||
template.index('id="modeRailContent"'),
|
||||
)
|
||||
self.assertIn('id="modeGalleryToggle"', template)
|
||||
self.assertIn('id="modeGallery"', template)
|
||||
self.assertIn('id="modeGalleryGroups"', template)
|
||||
self.assertIn("管理功能模式", template)
|
||||
self.assertIn('id="modeEditorBackdrop"', template)
|
||||
self.assertIn('id="modeEditorName"', template)
|
||||
self.assertIn('id="modeEditorInstruction"', template)
|
||||
self.assertIn('id="modeEditorExecute"', template)
|
||||
self.assertIn('id="modeEditorComplete"', template)
|
||||
self.assertIn('id="modeEditorFlowPreview"', template)
|
||||
self.assertIn('class="mode-editor-flow-preview empty"', template)
|
||||
self.assertNotIn('id="saveModeSource"', template)
|
||||
self.assertNotIn("主驾 24℃", template)
|
||||
populate_modes = app_script[
|
||||
app_script.index("function populateModeChips()")
|
||||
: app_script.index("function setModeExecutionState")
|
||||
@@ -1345,21 +1483,21 @@ operations:
|
||||
self.assertIn("message.payload", app_script)
|
||||
self.assertIn("source_request_id: editor.sourceRequestId", app_script)
|
||||
self.assertIn('method: "POST"', app_script)
|
||||
self.assertIn('data-domain-panel="climate"', html)
|
||||
self.assertIn('data-domain-panel="roof"', html)
|
||||
self.assertIn('data-domain-panel="ambient_light"', html)
|
||||
self.assertIn('data-domain-panel="window"', html)
|
||||
self.assertIn('data-domain-panel="front_light"', html)
|
||||
self.assertIn('data-domain-panel="vehicle_status"', html)
|
||||
self.assertIn('id="vehicleGear"', html)
|
||||
self.assertIn('id="vehicleSpeed"', html)
|
||||
self.assertNotIn('data-domain-panel="control_link"', html)
|
||||
self.assertIn('id="cabinStateTitle">整车设备状态</h3>', html)
|
||||
self.assertIn('data-domain-panel="climate"', template)
|
||||
self.assertIn('data-domain-panel="roof"', template)
|
||||
self.assertIn('data-domain-panel="ambient_light"', template)
|
||||
self.assertIn('data-domain-panel="window"', template)
|
||||
self.assertIn('data-domain-panel="front_light"', template)
|
||||
self.assertIn('data-domain-panel="vehicle_status"', template)
|
||||
self.assertIn('id="vehicleGear"', template)
|
||||
self.assertIn('id="vehicleSpeed"', template)
|
||||
self.assertNotIn('data-domain-panel="control_link"', template)
|
||||
self.assertIn('id="cabinStateTitle">整车设备状态</h3>', template)
|
||||
self.assertIn('id="cabinCarouselPrev"', app_script)
|
||||
self.assertIn('id="cabinCarouselNext"', app_script)
|
||||
self.assertIn('aria-label="左右浏览设备卡片"', app_script)
|
||||
self.assertNotIn('class="cabin-category-tabs"', app_script)
|
||||
self.assertNotIn('class="section-kicker"', html)
|
||||
self.assertNotIn('class="section-kicker"', template)
|
||||
self.assertIn('.joint-twin-shell > .twin-band {', console_styles)
|
||||
self.assertIn('grid-template-rows: 62px minmax(0, 1fr)', console_styles)
|
||||
self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles)
|
||||
@@ -1394,18 +1532,18 @@ operations:
|
||||
self.assertIn('name: "遮阳帘", measurable: true, components: []', app_script)
|
||||
self.assertIn("仅支持设备总功率", app_script)
|
||||
self.assertIn('.device-power-drawer {', console_styles)
|
||||
self.assertNotIn('class="roof-visual"', html)
|
||||
self.assertNotIn('class="ambient-visual"', html)
|
||||
self.assertIn('id="driverAirDirection"', html)
|
||||
self.assertIn('id="passengerAirDirection"', html)
|
||||
self.assertNotIn('id="transmittanceValue"', html)
|
||||
self.assertNotIn('id="roofMotorState"', html)
|
||||
self.assertIn('id="ambientBrightness"', html)
|
||||
self.assertNotIn('class="example-group"', html)
|
||||
self.assertNotIn('id="safetyStrip"', html)
|
||||
self.assertNotIn('class="pipeline"', html)
|
||||
self.assertNotIn('class="topbar"', html)
|
||||
self.assertNotIn('data-axis="height"', html)
|
||||
self.assertNotIn('class="roof-visual"', template)
|
||||
self.assertNotIn('class="ambient-visual"', template)
|
||||
self.assertIn('id="driverAirDirection"', template)
|
||||
self.assertIn('id="passengerAirDirection"', template)
|
||||
self.assertNotIn('id="transmittanceValue"', template)
|
||||
self.assertNotIn('id="roofMotorState"', template)
|
||||
self.assertIn('id="ambientBrightness"', template)
|
||||
self.assertNotIn('class="example-group"', template)
|
||||
self.assertNotIn('id="safetyStrip"', template)
|
||||
self.assertNotIn('class="pipeline"', template)
|
||||
self.assertNotIn('class="topbar"', template)
|
||||
self.assertNotIn('data-axis="height"', template)
|
||||
self.assertEqual(runtime["domain"], "cabin")
|
||||
self.assertEqual(
|
||||
runtime["hardware_execution_allowed"],
|
||||
@@ -1662,7 +1800,7 @@ operations:
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/app.js?v=dual-seat-140", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-141", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
@@ -1946,7 +2084,7 @@ operations:
|
||||
finally:
|
||||
SeatDemoHandler.extension = original_extension
|
||||
|
||||
def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None:
|
||||
def test_main_request_routes_durable_nfc_peripheral_after_card_removal(self) -> None:
|
||||
original_extension = SeatDemoHandler.extension
|
||||
original_nfc = SeatDemoHandler.nfc
|
||||
SeatDemoHandler.extension = SelfExtensionService(
|
||||
@@ -1959,7 +2097,7 @@ operations:
|
||||
snapshot=lambda: {
|
||||
"revision": 1,
|
||||
"reader": {"online": True},
|
||||
"card": {"action": "present", "uid": "1A53DD95"},
|
||||
"card": None,
|
||||
}
|
||||
)
|
||||
catalog_request = urllib.request.Request(
|
||||
@@ -2021,7 +2159,7 @@ operations:
|
||||
self.assertEqual(operation["domain"], "self_extension")
|
||||
self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
|
||||
|
||||
def test_mode_creation_cannot_be_claimed_by_live_nfc_peripheral(self) -> None:
|
||||
def test_mode_creation_cannot_be_claimed_by_bound_nfc_peripheral(self) -> None:
|
||||
original_extension = SeatDemoHandler.extension
|
||||
original_nfc = SeatDemoHandler.nfc
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -2050,7 +2188,7 @@ operations:
|
||||
nfc_snapshot = {
|
||||
"revision": 1,
|
||||
"reader": {"online": True},
|
||||
"card": {"action": "present", "uid": "2CC8B103"},
|
||||
"card": None,
|
||||
}
|
||||
utterance = "创建一个打开闪灯玩具模式"
|
||||
partition = partition_active_peripheral_intent(
|
||||
@@ -2352,14 +2490,20 @@ operations:
|
||||
]
|
||||
self.assertTrue(validated_payloads)
|
||||
self.assertTrue(
|
||||
all(
|
||||
any(
|
||||
{"native_climate", "self_extension"}.issubset(
|
||||
payload["function_configs"]
|
||||
)
|
||||
for payload in validated_payloads
|
||||
)
|
||||
)
|
||||
first_validated = validated_payloads[0]
|
||||
first_validated = next(
|
||||
payload
|
||||
for payload in validated_payloads
|
||||
if {"native_climate", "self_extension"}.issubset(
|
||||
payload["function_configs"]
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
{"native_climate", "self_extension"}.issubset(
|
||||
first_validated["command_plans"]
|
||||
@@ -2605,11 +2749,11 @@ operations:
|
||||
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertEqual(events[-1]["error"], "can_transport_failed")
|
||||
validated = next(
|
||||
validated = [
|
||||
item["payload"]
|
||||
for item in events
|
||||
if item["type"] == "validated_commands"
|
||||
)
|
||||
][-1]
|
||||
self.assertEqual(validated["status"], "processing")
|
||||
self.assertEqual(
|
||||
set(validated["function_configs"]),
|
||||
@@ -3620,16 +3764,21 @@ operations:
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-e2e-hardware-locked",
|
||||
"execution_mode": "hardware",
|
||||
"hardware_client_id": "hwc-e2e-locked-client",
|
||||
"approval_token": "must-not-be-persisted",
|
||||
}
|
||||
).encode("utf-8"),
|
||||
)
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
with patch.object(
|
||||
SeatDemoHandler,
|
||||
"_hardware_access_bypass_allowed",
|
||||
return_value=False,
|
||||
), self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
self.opener.open(request, timeout=3)
|
||||
|
||||
self.assertEqual(caught.exception.code, 403)
|
||||
payload = json.loads(caught.exception.read().decode("utf-8"))
|
||||
self.assertEqual(payload["error"], "hardware_safety_gate")
|
||||
self.assertEqual(payload["error"], "hardware_access_required")
|
||||
artifact_text = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in Path(self.temporary_directory.name).rglob("*")
|
||||
|
||||
@@ -778,7 +778,7 @@ def test_preview_and_process_share_mode_target_safety_gate(
|
||||
} == {"mode_target_scope_mismatch"}
|
||||
|
||||
|
||||
def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected(
|
||||
def test_durable_nfc_peripheral_can_execute_after_bound_card_is_removed(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
learned = {
|
||||
@@ -798,10 +798,10 @@ def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected(
|
||||
peripheral_provider=lambda: [learned],
|
||||
nfc_provider=lambda: {"card": {"uid": "a1b2c3d4"}},
|
||||
)
|
||||
inactive_catalog = AssistantCapabilityCatalog(
|
||||
detached_catalog = AssistantCapabilityCatalog(
|
||||
vehicle_service,
|
||||
peripheral_provider=lambda: [learned],
|
||||
nfc_provider=lambda: {"card": {"uid": "different-card"}},
|
||||
nfc_provider=lambda: {"card": None},
|
||||
)
|
||||
|
||||
active_client = _ToolCallingCodexClient()
|
||||
@@ -834,27 +834,37 @@ def test_active_nfc_peripheral_can_execute_but_inactive_peripheral_is_rejected(
|
||||
)
|
||||
assert executed == ["打开星空投影仪"]
|
||||
|
||||
inactive_client = _ToolCallingCodexClient()
|
||||
inactive_assistant = CodexAssistantService(inactive_client)
|
||||
errors: list[str] = []
|
||||
|
||||
def attempt_inactive(token: str, _turn: ActiveCodexTurn) -> None:
|
||||
try:
|
||||
inactive_assistant.execute_cockpit_request(token)
|
||||
except CodexVehicleToolError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
inactive_client.on_turn = attempt_inactive
|
||||
_stream_with_catalog(
|
||||
inactive_assistant,
|
||||
inactive_catalog,
|
||||
"打开星空投影仪",
|
||||
execute_control=lambda _instruction, _progress: pytest.fail(
|
||||
"inactive NFC peripheral must not execute"
|
||||
),
|
||||
conversation_id="conv-inactive-peripheral-01",
|
||||
detached_client = _ToolCallingCodexClient()
|
||||
detached_assistant = CodexAssistantService(detached_client)
|
||||
detached_executed: list[str] = []
|
||||
detached_client.on_turn = (
|
||||
lambda token, _turn: detached_assistant.execute_cockpit_request(token)
|
||||
)
|
||||
assert errors == ["vehicle control was not authorized by the original user request"]
|
||||
_stream_with_catalog(
|
||||
detached_assistant,
|
||||
detached_catalog,
|
||||
"打开星空投影仪",
|
||||
execute_control=lambda instruction, _progress: (
|
||||
detached_executed.append(instruction)
|
||||
or {
|
||||
"status": "succeeded",
|
||||
"execution_mode": "dry_run",
|
||||
"utterance": instruction,
|
||||
"infrared_actions": [
|
||||
{
|
||||
"capability_id": "star-projector-on",
|
||||
"device_id": "star-projector",
|
||||
"device_name": "星空投影仪",
|
||||
"action": "开启",
|
||||
"slot": 7,
|
||||
"transport": "infrared",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
conversation_id="conv-detached-peripheral-01",
|
||||
)
|
||||
assert detached_executed == ["打开星空投影仪"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -451,7 +451,7 @@ def test_mode_provider_failure_degrades_to_unknown_instead_of_empty(
|
||||
assert "0 个" not in answer.answer
|
||||
|
||||
|
||||
def test_learned_peripheral_requires_enabled_capability_and_matching_nfc_card(
|
||||
def test_learned_peripheral_requires_enabled_capability_and_durable_nfc_binding(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
capability = {
|
||||
@@ -470,8 +470,8 @@ def test_learned_peripheral_requires_enabled_capability_and_matching_nfc_card(
|
||||
absent_item = absent["learned_peripherals"][0]
|
||||
assert absent_item["enabled"] is True
|
||||
assert absent_item["active_for_current_nfc_card"] is False
|
||||
assert absent_item["assistant_callable"] is False
|
||||
assert "闪灯玩具" not in _catalog(
|
||||
assert absent_item["assistant_callable"] is True
|
||||
assert "闪灯玩具" in _catalog(
|
||||
vehicle_service, peripherals=lambda: [capability]
|
||||
).control_terms(absent)
|
||||
|
||||
@@ -910,6 +910,39 @@ def test_provider_failure_is_not_misreported_as_an_empty_catalog(
|
||||
assert "没有" not in answer.answer
|
||||
|
||||
|
||||
def test_nfc_reader_failure_does_not_hide_durable_peripheral_bindings(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
capability = {
|
||||
"id": "lamp-on",
|
||||
"status": "learned",
|
||||
"enabled": True,
|
||||
"device_id": "lamp-1",
|
||||
"device_name": "闪灯玩具",
|
||||
"object_names": ["闪灯玩具"],
|
||||
"action": "开启",
|
||||
"action_names": ["打开"],
|
||||
"card_uid": "A1B2",
|
||||
"slot": 1,
|
||||
}
|
||||
|
||||
def nfc_unavailable() -> dict[str, object]:
|
||||
raise RuntimeError("reader offline")
|
||||
|
||||
catalog = _catalog(
|
||||
vehicle_service,
|
||||
peripherals=lambda: [capability],
|
||||
nfc=nfc_unavailable,
|
||||
)
|
||||
snapshot = catalog.snapshot()
|
||||
item = snapshot["learned_peripherals"][0]
|
||||
|
||||
assert snapshot["source_status"]["learned_peripherals"] == "ready"
|
||||
assert item["active_for_current_nfc_card"] is False
|
||||
assert item["assistant_callable"] is True
|
||||
assert "闪灯玩具" in catalog.control_terms(snapshot)
|
||||
|
||||
|
||||
def test_catalog_revision_is_stable_and_changes_with_live_capabilities(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
|
||||
@@ -3,25 +3,35 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WEB = ROOT / "apps" / "web-console"
|
||||
|
||||
|
||||
def vue_shell() -> str:
|
||||
return (WEB / "src" / "App.vue").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_console_contains_ai_assistant_surface():
|
||||
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
|
||||
assert 'id="assistantTranscript"' in html
|
||||
assert 'id="assistantMessages"' in html
|
||||
assert "你好,我是 AI助手。你可以向我提问" in html
|
||||
assert 'aria-label="AI助手对话"' in html
|
||||
assert 'placeholder="向 AI助手提问或交代工作"' in html
|
||||
assert 'aria-label="发送消息"' in html
|
||||
assert 'href="/assistant-chat.css?v=codex-assistant-29"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-33"' in html
|
||||
html = (WEB / "index.html").read_text(encoding="utf-8")
|
||||
template = vue_shell()
|
||||
vite_entry = (WEB / "src" / "main.js").read_text(encoding="utf-8")
|
||||
assert 'id="assistantTranscript"' in template
|
||||
assert 'id="assistantMessages"' in template
|
||||
assert "你好,我是 AI助手。你可以向我提问" in template
|
||||
assert 'aria-label="AI助手对话"' in template
|
||||
assert 'placeholder="向 AI助手提问或交代工作"' in template
|
||||
assert 'aria-label="发送消息"' in template
|
||||
assert 'import "../assistant-chat.css";' in vite_entry
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-34"' in html
|
||||
|
||||
|
||||
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-v305"' in service_worker
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"'
|
||||
in service_worker
|
||||
)
|
||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-33"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-34"' in service_worker
|
||||
|
||||
|
||||
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
||||
@@ -90,6 +100,18 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
|
||||
assert "appendDispatchedControls(message, event.payload)" in script
|
||||
assert "function announceModeExecution(" in script
|
||||
assert "announceModeExecution," in script
|
||||
assert "function beginModeExecution(" in script
|
||||
assert "function completeModeExecution(" in script
|
||||
assert "appendDispatchedControls(message, modeExecutionPreviewResult(profile))" in script
|
||||
assert "beginModeExecution," in script
|
||||
assert "completeModeExecution," in script
|
||||
assert "const operationByRef = new Map()" in script
|
||||
assert 'deliveryState: "submitting"' in script
|
||||
assert 'deliveryLabel: "正在下发"' in script
|
||||
assert '"seat.set_cushion_tilt": "坐盆"' in script
|
||||
assert '"seat.set_backrest_recline": "靠背"' in script
|
||||
assert '"seat.set_legrest_height": "腿托"' in script
|
||||
assert '"seat.set_legrest_extension": "腿托"' in script
|
||||
assert '"ambient_light.set_illumination": "氛围灯颜色与亮度"' in script
|
||||
assert '.replaceAll("_", "-")' in script
|
||||
assert ".assistant-dispatched-controls > summary" in css
|
||||
@@ -98,6 +120,29 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
|
||||
assert ".assistant-dispatched-controls[open]" in css
|
||||
|
||||
|
||||
def test_saved_mode_execution_opens_with_pending_flow_before_http_completion():
|
||||
app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
|
||||
flow = app.split(
|
||||
"async function executeSavedModeProfile", 1
|
||||
)[1].split("function applyModeCommand", 1)[0]
|
||||
|
||||
begin_index = flow.index("assistant?.beginModeExecution?.(mode")
|
||||
request_index = flow.index("await postControlPhase(")
|
||||
assert begin_index < request_index
|
||||
assert "assistant.completeModeExecution(assistantModeHandle, mode, payload" in flow
|
||||
assert "assistant.completeModeExecution(assistantModeHandle, mode, null" in flow
|
||||
|
||||
|
||||
def test_seat_control_cards_name_the_controlled_seat_component():
|
||||
console = (ROOT / "apps/web-console/console-v2.js").read_text(encoding="utf-8")
|
||||
|
||||
assert '"seat.set_cushion_tilt": ["副驾座椅坐盆调节"' in console
|
||||
assert 'backrest: "主驾座椅靠背调节"' in console
|
||||
assert 'basin: "主驾座椅坐盆调节"' in console
|
||||
assert 'legrest: "主驾座椅腿托调节"' in console
|
||||
assert 'horizontal: "主驾座椅前后调节"' in console
|
||||
|
||||
|
||||
def test_codex_request_keeps_trusted_vehicle_context_without_legacy_model_routing():
|
||||
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
||||
request_body = script.split("function requestBody(message)", 1)[1].split(
|
||||
@@ -332,7 +377,7 @@ def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content():
|
||||
|
||||
|
||||
def test_assistant_composer_keeps_the_microphone_visible_and_aligned():
|
||||
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
|
||||
html = vue_shell()
|
||||
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
||||
assert 'id="speechDictationButton"' in html
|
||||
assert 'data-lucide="mic"' in html
|
||||
@@ -345,7 +390,7 @@ def test_assistant_composer_keeps_the_microphone_visible_and_aligned():
|
||||
|
||||
|
||||
def test_voice_input_supports_direct_manual_capture_and_background_wake_standby():
|
||||
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
|
||||
html = vue_shell()
|
||||
script = (ROOT / "apps/web-console/speech-input.js").read_text(encoding="utf-8")
|
||||
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -27,18 +27,23 @@ def node_runtime() -> str:
|
||||
|
||||
def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
||||
html = source("index.html")
|
||||
vite_entry = source("src/main.js")
|
||||
service_worker = source("service-worker.js")
|
||||
|
||||
for asset in (
|
||||
"/energy-monitor.css?v=energy-monitor-4",
|
||||
"/energy-monitor.js?v=energy-monitor-4",
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-33") < html.index(
|
||||
assert 'import "../energy-monitor.css";' in vite_entry
|
||||
assert "/energy-monitor.js?v=energy-monitor-4" in html
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-34") < html.index(
|
||||
"/energy-monitor.js?v=energy-monitor-4"
|
||||
)
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"'
|
||||
in service_worker
|
||||
)
|
||||
|
||||
|
||||
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.extension_control import (
|
||||
ExtensionControlAuthorizationError,
|
||||
ExtensionControlConsumer,
|
||||
ExtensionControlError,
|
||||
authorize_extension_control_actions,
|
||||
parse_extension_control_payload,
|
||||
)
|
||||
from cognitive_agent.application.self_extension import (
|
||||
ExtensionIntegrationError,
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
)
|
||||
|
||||
|
||||
def wait_until(predicate, timeout: float = 1.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return bool(predicate())
|
||||
|
||||
|
||||
def action(
|
||||
slot: int,
|
||||
action_name: str,
|
||||
*,
|
||||
target_scope: str = "toy-light-001",
|
||||
at_ms: int = 0,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "infrared_emit",
|
||||
"at_ms": at_ms,
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": target_scope,
|
||||
"operation": "emit",
|
||||
"parameters": {"action": action_name, "slot": slot},
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def request(*actions: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"domain": "self_extension",
|
||||
"operation_count": len(actions),
|
||||
"actions": list(actions),
|
||||
}
|
||||
|
||||
|
||||
def capability(
|
||||
capability_id: str,
|
||||
slot: int,
|
||||
action_name: str,
|
||||
*,
|
||||
device_id: str = "toy-light-001",
|
||||
enabled: bool = True,
|
||||
card_uid: str = "E113B203",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": capability_id,
|
||||
"status": "learned",
|
||||
"enabled": enabled,
|
||||
"device_id": device_id,
|
||||
"device_name": "闪烁灯",
|
||||
"card_uid": card_uid,
|
||||
"action": action_name,
|
||||
"slot": slot,
|
||||
"risk": "low",
|
||||
}
|
||||
|
||||
|
||||
def encoded(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def test_parser_accepts_scene_action_shape_and_strips_compatibility_fields() -> None:
|
||||
payload = request(
|
||||
action(
|
||||
2,
|
||||
"打开",
|
||||
target={"device": "toy-light-001", "card_uid": "E113B203"},
|
||||
operation_ref="toy-light-on",
|
||||
)
|
||||
)
|
||||
|
||||
parsed = parse_extension_control_payload(encoded(payload))
|
||||
|
||||
assert parsed == request(action(2, "打开"))
|
||||
assert "target" not in parsed["actions"][0]
|
||||
assert "operation_ref" not in parsed["actions"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "message"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"domain": "self_extension",
|
||||
"operation_count": 2,
|
||||
"actions": [action(2, "打开")],
|
||||
},
|
||||
"must equal",
|
||||
),
|
||||
(request({**action(2, "打开"), "domain": "seat"}), "self_extension"),
|
||||
(request({**action(2, "打开"), "operation": "set"}), "must be emit"),
|
||||
(request(action(2, "打开", target_scope="闪烁灯")), "English device id"),
|
||||
],
|
||||
)
|
||||
def test_parser_rejects_invalid_control_contract(
|
||||
payload: dict[str, Any],
|
||||
message: str,
|
||||
) -> None:
|
||||
with pytest.raises(ExtensionControlError, match=message):
|
||||
parse_extension_control_payload(encoded(payload))
|
||||
|
||||
|
||||
def test_parser_rejects_duplicate_json_keys() -> None:
|
||||
raw = (
|
||||
b'{"domain":"self_extension","domain":"self_extension",'
|
||||
b'"operation_count":1,"actions":[]}'
|
||||
)
|
||||
|
||||
with pytest.raises(ExtensionControlError, match="duplicate key"):
|
||||
parse_extension_control_payload(raw)
|
||||
|
||||
|
||||
def test_authorization_requires_exact_enabled_learned_bound_ability() -> None:
|
||||
parsed = parse_extension_control_payload(encoded(request(action(2, "打开"))))
|
||||
valid = capability("light-on", 2, "打开")
|
||||
|
||||
[authorized] = authorize_extension_control_actions(parsed, [valid])
|
||||
|
||||
assert authorized["capability_id"] == "light-on"
|
||||
assert authorized["action"] == action(2, "打开")
|
||||
for disallowed in (
|
||||
[{**valid, "enabled": False}],
|
||||
[{**valid, "status": "draft"}],
|
||||
[{**valid, "card_uid": ""}],
|
||||
[{**valid, "device_id": "another-device"}],
|
||||
[{**valid, "action": "关闭"}],
|
||||
[{**valid, "slot": 3}],
|
||||
):
|
||||
with pytest.raises(ExtensionControlAuthorizationError, match="not authorized"):
|
||||
authorize_extension_control_actions(parsed, disallowed)
|
||||
|
||||
|
||||
def test_consumer_validates_entire_request_then_executes_by_at_ms() -> None:
|
||||
catalog = [
|
||||
capability("light-on", 2, "打开"),
|
||||
capability("light-red", 3, "切换为红色"),
|
||||
]
|
||||
executions: list[tuple[float, dict[str, Any]]] = []
|
||||
|
||||
def execute(value: dict[str, Any]) -> dict[str, Any]:
|
||||
executions.append((time.monotonic(), value))
|
||||
return {"success": True, "transport": "tcp", "gateway_acknowledged": True}
|
||||
|
||||
consumer = ExtensionControlConsumer(
|
||||
catalog_provider=lambda: catalog,
|
||||
executor=execute,
|
||||
adjacent_emit_interval_s=0,
|
||||
)
|
||||
consumer.start()
|
||||
try:
|
||||
consumer.submit(
|
||||
encoded(
|
||||
request(
|
||||
action(2, "打开", at_ms=30),
|
||||
action(3, "切换为红色", at_ms=0),
|
||||
)
|
||||
)
|
||||
)
|
||||
assert wait_until(lambda: consumer.snapshot()["state"] == "completed")
|
||||
assert [item[1]["parameters"]["slot"] for item in executions] == [3, 2]
|
||||
status = consumer.snapshot()
|
||||
assert status["processed_count"] == 1
|
||||
assert status["executed_action_count"] == 2
|
||||
assert [item["slot"] for item in status["last_results"]] == [3, 2]
|
||||
assert [item["slot"] for item in status["action_statuses"]] == [3, 2]
|
||||
assert [item["state"] for item in status["action_statuses"]] == [
|
||||
"submitted_unverified",
|
||||
"submitted_unverified",
|
||||
]
|
||||
assert all(
|
||||
item["state_label"] == "已下发 · 物理效果未确认"
|
||||
for item in status["action_statuses"]
|
||||
)
|
||||
assert status["source_label"] == "车端 MQTT"
|
||||
assert status["active_action_index"] is None
|
||||
assert status["last_error"] is None
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
def test_consumer_rejects_whole_request_before_first_tcp_command() -> None:
|
||||
executions: list[dict[str, Any]] = []
|
||||
consumer = ExtensionControlConsumer(
|
||||
catalog_provider=lambda: [capability("light-on", 2, "打开")],
|
||||
executor=lambda value: executions.append(value) or {"success": True},
|
||||
adjacent_emit_interval_s=0,
|
||||
)
|
||||
consumer.start()
|
||||
try:
|
||||
consumer.submit(
|
||||
encoded(
|
||||
request(
|
||||
action(2, "打开"),
|
||||
action(3, "切换为红色"),
|
||||
)
|
||||
)
|
||||
)
|
||||
assert wait_until(lambda: consumer.snapshot()["state"] == "rejected")
|
||||
assert executions == []
|
||||
assert consumer.snapshot()["rejected_count"] == 1
|
||||
assert "not authorized" in consumer.snapshot()["last_error"]
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
def test_consumer_aborts_remaining_actions_after_tcp_failure() -> None:
|
||||
catalog = [
|
||||
capability("light-on", 2, "打开"),
|
||||
capability("light-red", 3, "切换为红色"),
|
||||
capability("light-blue", 5, "切换为蓝色"),
|
||||
]
|
||||
attempted_slots: list[int] = []
|
||||
|
||||
def execute(value: dict[str, Any]) -> dict[str, Any]:
|
||||
slot = int(value["parameters"]["slot"])
|
||||
attempted_slots.append(slot)
|
||||
if slot == 3:
|
||||
raise RuntimeError("TCP disconnected")
|
||||
return {"success": True, "transport": "tcp"}
|
||||
|
||||
consumer = ExtensionControlConsumer(
|
||||
catalog_provider=lambda: catalog,
|
||||
executor=execute,
|
||||
adjacent_emit_interval_s=0,
|
||||
)
|
||||
consumer.start()
|
||||
try:
|
||||
consumer.submit(
|
||||
encoded(
|
||||
request(
|
||||
action(2, "打开"),
|
||||
action(3, "切换为红色"),
|
||||
action(5, "切换为蓝色"),
|
||||
)
|
||||
)
|
||||
)
|
||||
assert wait_until(lambda: consumer.snapshot()["state"] == "failed")
|
||||
assert attempted_slots == [2, 3]
|
||||
status = consumer.snapshot()
|
||||
assert status["failed_count"] == 1
|
||||
assert status["executed_action_count"] == 1
|
||||
assert [item["state"] for item in status["action_statuses"]] == [
|
||||
"submitted_unverified",
|
||||
"failed",
|
||||
"aborted",
|
||||
]
|
||||
assert status["last_error"] == "TCP disconnected"
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
def test_consumer_projects_mqtt_transport_rejection_without_execution() -> None:
|
||||
executions: list[dict[str, Any]] = []
|
||||
consumer = ExtensionControlConsumer(
|
||||
catalog_provider=list,
|
||||
executor=lambda value: executions.append(value) or {"success": True},
|
||||
)
|
||||
consumer.start()
|
||||
try:
|
||||
consumer.reject_transport_message(
|
||||
"retained extension/ctl messages are not executable",
|
||||
"retained",
|
||||
)
|
||||
|
||||
status = consumer.snapshot()
|
||||
assert status["state"] == "rejected"
|
||||
assert status["request_count"] == 1
|
||||
assert status["processed_count"] == 1
|
||||
assert status["rejected_count"] == 1
|
||||
assert status["last_rejection_kind"] == "retained"
|
||||
assert status["last_latency_ms"] == 0
|
||||
assert status["action_statuses"] == []
|
||||
assert executions == []
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
def test_self_extension_reauthorizes_immediately_before_gateway_emit() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
service = SelfExtensionService(
|
||||
SimulationGateway(),
|
||||
catalog_db_path=Path(directory, "infrared.sqlite3"),
|
||||
)
|
||||
service.sync_peripheral_catalog([capability("light-on", 2, "打开")])
|
||||
with patch.object(
|
||||
service,
|
||||
"execute_gateway",
|
||||
return_value={
|
||||
"success": True,
|
||||
"transport": "tcp",
|
||||
"gateway_acknowledged": True,
|
||||
},
|
||||
) as execute_gateway:
|
||||
result = service.execute_bound_infrared_action(action(2, "打开"))
|
||||
execute_gateway.assert_called_once_with("emit", {"slot": 2})
|
||||
assert result["authorization"]["capability_id"] == "light-on"
|
||||
|
||||
with pytest.raises(ExtensionIntegrationError, match="未通过"):
|
||||
service.execute_bound_infrared_action(action(3, "切换为红色"))
|
||||
assert execute_gateway.call_count == 1
|
||||
service.close()
|
||||
|
||||
|
||||
def test_server_wires_extension_control_subscription_and_health() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
server = (root / "src" / "cognitive_agent" / "server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
seat_service = (
|
||||
root / "src" / "cognitive_agent" / "application" / "seat_service.py"
|
||||
).read_text(encoding="utf-8")
|
||||
console = (root / "apps" / "web-console" / "console-v2.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "extension_control = ExtensionControlConsumer(" in server
|
||||
assert "executor=extension.execute_bound_infrared_action" in server
|
||||
assert "service.set_extension_control_handler(extension_control.submit)" in server
|
||||
assert "extension_control.reject_transport_message" in server
|
||||
assert '"extension_control": self.extension_control.snapshot()' in server
|
||||
assert 'path == "/api/extension/control/status"' in server
|
||||
assert "self.extension_control.wait_for_snapshot(" in server
|
||||
assert "SeatDemoHandler.extension_control.start()" in server
|
||||
assert "SeatDemoHandler.extension_control.close()" in server
|
||||
assert "def set_extension_control_handler" in seat_service
|
||||
assert "def set_extension_control_rejection_handler" in seat_service
|
||||
assert 'fetch(`/api/extension/control/status?${query}`' in console
|
||||
assert "function applyExtensionControlSnapshot" in console
|
||||
assert "if (revision < extensionControlSync.revision)" in console
|
||||
assert 'vehicle_mqtt_extension: "车端 MQTT"' in console
|
||||
assert 'delivery_transport: "车端 MQTT → 红外 TCP"' in console
|
||||
assert 'semantic_instruction: `${extensionName} · ${actionName}`' in console
|
||||
assert (
|
||||
'category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`'
|
||||
in console
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from cognitive_agent.application.extension_toy_publisher import (
|
||||
NfcExtensionToyPublisher,
|
||||
build_extension_toy_messages,
|
||||
normalize_extension_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.005)
|
||||
return bool(predicate())
|
||||
|
||||
|
||||
def capability(
|
||||
capability_id: str,
|
||||
*,
|
||||
device_id: str = "toy-light-001",
|
||||
device_name: str = "闪烁灯",
|
||||
card_uid: str = "E113B203",
|
||||
slot: int = 2,
|
||||
action: str = "打开",
|
||||
enabled: bool = True,
|
||||
status: str = "learned",
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": capability_id,
|
||||
"status": status,
|
||||
"enabled": enabled,
|
||||
"device_id": device_id,
|
||||
"device_name": device_name,
|
||||
"card_uid": card_uid,
|
||||
"slot": slot,
|
||||
"action": action,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def test_message_projection_groups_devices_orders_slots_and_filters_catalog() -> None:
|
||||
catalog = [
|
||||
capability("light-blue", slot=5, action="切换为蓝色"),
|
||||
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
|
||||
capability("light-on", slot=2, action="打开"),
|
||||
capability("duplicate", slot=2, action="打开"),
|
||||
capability("disabled", slot=1, action="关闭", enabled=False),
|
||||
capability("other-card", slot=6, action="设置为白色", card_uid="04A1B2C3"),
|
||||
capability("draft", slot=3, action="切换为红色", status="draft"),
|
||||
capability("missing-enabled", slot=4, action="切换为绿色", enabled=None),
|
||||
]
|
||||
|
||||
messages = build_extension_toy_messages("e1:13-b2 03", catalog)
|
||||
|
||||
assert [message["extension_name"] for message in messages] == ["小风扇", "闪烁灯"]
|
||||
fan, light = messages
|
||||
assert fan["actions"] == [
|
||||
{
|
||||
"type": "infrared_emit",
|
||||
"at_ms": 0,
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": "toy-fan-001",
|
||||
"operation": "emit",
|
||||
"parameters": {"action": "打开", "slot": 2},
|
||||
}
|
||||
]
|
||||
assert [action["parameters"] for action in light["actions"]] == [
|
||||
{"action": "打开", "slot": 2},
|
||||
{"action": "切换为蓝色", "slot": 5},
|
||||
]
|
||||
assert {action["target_scope"] for action in light["actions"]} == {
|
||||
"toy-light-001"
|
||||
}
|
||||
|
||||
|
||||
def test_projection_prefers_chinese_name_but_keeps_english_stable_scope() -> None:
|
||||
catalog = [
|
||||
capability(
|
||||
"desk-light-on",
|
||||
device_id="desk-light-01",
|
||||
device_name="Desk Light",
|
||||
object_names=["闪烁灯"],
|
||||
)
|
||||
]
|
||||
|
||||
[message] = build_extension_toy_messages("E113B203", catalog)
|
||||
|
||||
assert message["extension_name"] == "闪烁灯"
|
||||
assert message["actions"][0]["target_scope"] == "desk-light-01"
|
||||
assert normalize_extension_nfc_id("04:a1-b2 c3") == "04A1B2C3"
|
||||
assert normalize_extension_nfc_id("not-a-card") == ""
|
||||
|
||||
|
||||
def test_each_detection_publishes_three_frames_per_device_and_then_stops() -> None:
|
||||
nfc: dict[str, Any] = {
|
||||
"reader": {"online": True},
|
||||
"card": {"action": "present", "uid": "E113B203"},
|
||||
}
|
||||
catalog = [
|
||||
capability("light-on"),
|
||||
capability("light-blue", slot=5, action="切换为蓝色"),
|
||||
capability("fan-on", device_id="toy-fan-001", device_name="小风扇"),
|
||||
]
|
||||
publications: list[tuple[float, dict[str, Any]]] = []
|
||||
|
||||
def publish(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
publications.append((time.monotonic(), payload))
|
||||
return {"delivery_status": "queued", "message_id": len(publications)}
|
||||
|
||||
service = NfcExtensionToyPublisher(
|
||||
nfc_provider=lambda: nfc,
|
||||
catalog_provider=lambda: catalog,
|
||||
publisher=publish,
|
||||
burst_window_s=0.12,
|
||||
poll_interval_s=0.005,
|
||||
)
|
||||
service.start()
|
||||
try:
|
||||
assert wait_until(lambda: len(publications) == 6)
|
||||
assert publications[-1][0] - publications[0][0] <= 0.12
|
||||
assert Counter(
|
||||
publication["actions"][0]["target_scope"]
|
||||
for _at, publication in publications
|
||||
) == {"toy-fan-001": 3, "toy-light-001": 3}
|
||||
status = service.snapshot()
|
||||
assert status["state"] == "burst_complete"
|
||||
assert status["burst_frame_attempt_count"] == 3
|
||||
assert status["burst_publish_attempt_count"] == 6
|
||||
assert status["burst_published_count"] == 6
|
||||
assert status["qos"] == 1
|
||||
assert status["retain"] is False
|
||||
|
||||
time.sleep(0.14)
|
||||
assert len(publications) == 6
|
||||
|
||||
nfc["card"] = None
|
||||
assert wait_until(lambda: service.snapshot()["state"] == "waiting_for_nfc")
|
||||
nfc["card"] = {"action": "present", "uid": "E113B203"}
|
||||
assert wait_until(lambda: len(publications) == 12)
|
||||
assert service.snapshot()["state"] == "burst_complete"
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_no_matching_ability_publishes_nothing() -> None:
|
||||
nfc = {"card": {"action": "present", "uid": "E113B203"}}
|
||||
publications: list[dict[str, Any]] = []
|
||||
service = NfcExtensionToyPublisher(
|
||||
nfc_provider=lambda: nfc,
|
||||
catalog_provider=lambda: [
|
||||
capability("other-card", card_uid="04A1B2C3"),
|
||||
capability("disabled", enabled=False),
|
||||
],
|
||||
publisher=lambda payload: publications.append(dict(payload)) or {},
|
||||
burst_window_s=0.03,
|
||||
poll_interval_s=0.005,
|
||||
)
|
||||
service.start()
|
||||
try:
|
||||
assert wait_until(
|
||||
lambda: service.snapshot()["state"] == "no_matching_extension"
|
||||
)
|
||||
time.sleep(0.05)
|
||||
assert publications == []
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_failed_burst_attempts_only_three_frames_until_redetected() -> None:
|
||||
nfc: dict[str, Any] = {"card": {"action": "present", "uid": "E113B203"}}
|
||||
attempts: list[float] = []
|
||||
|
||||
def fail_publish(_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
attempts.append(time.monotonic())
|
||||
raise RuntimeError("broker unavailable")
|
||||
|
||||
service = NfcExtensionToyPublisher(
|
||||
nfc_provider=lambda: nfc,
|
||||
catalog_provider=lambda: [capability("light-on")],
|
||||
publisher=fail_publish,
|
||||
burst_window_s=0.06,
|
||||
poll_interval_s=0.005,
|
||||
)
|
||||
service.start()
|
||||
try:
|
||||
assert wait_until(lambda: len(attempts) == 3)
|
||||
status = service.snapshot()
|
||||
assert status["state"] == "burst_failed"
|
||||
assert status["burst_publish_attempt_count"] == 3
|
||||
assert status["burst_published_count"] == 0
|
||||
assert status["last_error"] == "broker unavailable"
|
||||
time.sleep(0.08)
|
||||
assert len(attempts) == 3
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_server_wires_publisher_without_invoking_infrared_execution() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
server = (root / "src" / "cognitive_agent" / "server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
service = (
|
||||
root / "src" / "cognitive_agent" / "application" / "seat_service.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "extension_toys = NfcExtensionToyPublisher(" in server
|
||||
assert "catalog_provider=extension.list_peripheral_capabilities" in server
|
||||
assert "publisher=service.publish_extension_toy_message" in server
|
||||
assert "SeatDemoHandler.extension_toys.start()" in server
|
||||
assert "SeatDemoHandler.extension_toys.close()" in server
|
||||
assert "publish_extension_toy_message" in service
|
||||
method = service.split("def publish_extension_toy_message", 1)[1].split(
|
||||
"\n def ", 1
|
||||
)[0]
|
||||
assert "_vehicle_mqtt_transport.publish_extension_toy_message" in method
|
||||
assert "execute_gateway" not in method
|
||||
@@ -6,7 +6,10 @@ WEB = ROOT / "apps" / "web-console"
|
||||
|
||||
|
||||
def source(name: str) -> str:
|
||||
return (WEB / name).read_text(encoding="utf-8")
|
||||
content = (WEB / name).read_text(encoding="utf-8")
|
||||
if name == "index.html":
|
||||
content += "\n" + (WEB / "src" / "App.vue").read_text(encoding="utf-8")
|
||||
return content
|
||||
|
||||
|
||||
def test_shadcn_semantic_theme_tokens_drive_the_console() -> None:
|
||||
@@ -36,19 +39,20 @@ def test_shadcn_semantic_theme_tokens_drive_the_console() -> None:
|
||||
|
||||
|
||||
def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
html = source("index.html")
|
||||
main = source("src/main.js")
|
||||
service_worker = source("service-worker.js")
|
||||
|
||||
for asset in (
|
||||
"/styles.css?v=dual-seat-23",
|
||||
"/console-v2.css?v=delivery-state-69",
|
||||
"/self-extension.css?v=pet-settings-52",
|
||||
"/speech-input.css?v=sensevoice-7",
|
||||
"/codepen-glass-theme.css?v=creative-cloud-2",
|
||||
for source_asset, cached_asset in (
|
||||
('import "../styles.css"', "/styles.css?v=dual-seat-23"),
|
||||
('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"),
|
||||
('import "../self-extension.css"', "/self-extension.css?v=pet-settings-52"),
|
||||
('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"),
|
||||
('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-5"),
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
|
||||
assert source_asset in main
|
||||
assert f'"{cached_asset}"' in service_worker
|
||||
assert 'import "./styles/design-system.css"' in main
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker
|
||||
|
||||
|
||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||
@@ -91,6 +95,45 @@ def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||
assert "border-radius: 0 !important" in theme
|
||||
|
||||
|
||||
def test_reference_theme_uses_one_transform_only_floating_card_contract() -> None:
|
||||
theme = source("codepen-glass-theme.css")
|
||||
|
||||
for contract in (
|
||||
"--glass-card-radius:",
|
||||
"--glass-card-lift:",
|
||||
"--glass-card-rest-shadow:",
|
||||
"--glass-card-float-shadow:",
|
||||
".assistant-dispatched-controls",
|
||||
".assistant-mode-draft",
|
||||
".mode-gallery-card",
|
||||
".mode-editor-command-card",
|
||||
".v2-device-card",
|
||||
".v2-pet-card",
|
||||
".energy-kpi",
|
||||
"transform: translateY(var(--glass-card-lift))",
|
||||
"@media (prefers-reduced-motion: reduce)",
|
||||
"transition: none !important",
|
||||
):
|
||||
assert contract in theme
|
||||
|
||||
|
||||
def test_dark_theme_uses_one_glass_scrim_for_every_modal_backdrop() -> None:
|
||||
theme = source("codepen-glass-theme.css")
|
||||
|
||||
for contract in (
|
||||
"--glass-dialog-scrim:",
|
||||
"--glass-dialog-scrim-filter:",
|
||||
".mode-gallery-backdrop",
|
||||
".mode-editor-backdrop",
|
||||
".energy-monitor-layer",
|
||||
".device-power-backdrop",
|
||||
".v2-extension-dialog::backdrop",
|
||||
"background: var(--glass-dialog-scrim) !important",
|
||||
"backdrop-filter: var(--glass-dialog-scrim-filter) !important",
|
||||
):
|
||||
assert contract in theme
|
||||
|
||||
|
||||
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
|
||||
styles = source("styles.css")
|
||||
console = source("console-v2.css")
|
||||
|
||||
@@ -146,7 +146,7 @@ console.log(JSON.stringify(context.result));
|
||||
|
||||
def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None:
|
||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
html = (WEB / "index.html").read_text(encoding="utf-8")
|
||||
template = (WEB / "src" / "App.vue").read_text(encoding="utf-8")
|
||||
styles = (WEB / "styles.css").read_text(encoding="utf-8")
|
||||
definitions = app[
|
||||
app.index("const MODE_GALLERY_GROUPS")
|
||||
@@ -161,8 +161,8 @@ def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None
|
||||
assert 'if (group.id === "custom") grid.append(createModeGalleryAddCard())' in app
|
||||
assert "function createModeGalleryEmpty(group)" in app
|
||||
assert 'section.setAttribute("aria-labelledby", headingId)' in app
|
||||
assert "自定义模式 · 静态模式 · 动态模式" in html
|
||||
assert "按来源与运行方式整理,快速定位并管理模式" in html
|
||||
assert "自定义模式 · 静态模式 · 动态模式" in template
|
||||
assert "按来源与运行方式整理,快速定位并管理模式" in template
|
||||
assert "grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));" in styles
|
||||
|
||||
|
||||
@@ -173,11 +173,17 @@ def test_top_mode_cards_render_real_actions_in_a_body_level_hover_and_focus_prev
|
||||
assert "function modeExecutionActionViews(mode)" in app
|
||||
assert "Array.isArray(mode?.operations)" in app
|
||||
assert "function renderModeActionPopover(mode)" in app
|
||||
assert "modeEditorReadableCommands(mode?.command_flow)" in app
|
||||
assert 'eyebrow.dataset.modeActionEyebrow = "true"' in app
|
||||
assert '"最新控制指令流"' in app
|
||||
assert 'popover.setAttribute("role", "tooltip")' in app
|
||||
assert "document.body.append(popover)" in app
|
||||
assert 'device.innerHTML = "<b>设备</b>"' in app
|
||||
assert 'target.innerHTML = "<b>目标</b>"' in app
|
||||
assert 'parameters.innerHTML = "<b>参数</b>"' in app
|
||||
assert 'appendMetadata("语义", action.semantic || action.operation_ref)' in app
|
||||
assert 'appendMetadata("实际", action.command)' in app
|
||||
assert 'appendMetadata("通道", action.channel)' in app
|
||||
assert 'appendMetadata("设备", action.device)' in app
|
||||
assert 'appendMetadata("目标", action.target)' in app
|
||||
assert 'appendMetadata("参数", action.parameters)' in app
|
||||
assert 'button.setAttribute("aria-describedby", popover.id)' in app
|
||||
assert 'group.addEventListener("pointerover"' in app
|
||||
assert 'group.addEventListener("pointerout"' in app
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_scene_catalog import (
|
||||
ModeSceneCatalogPublisher,
|
||||
ModeSceneMqttBurstPublisher,
|
||||
build_mode_scene_catalog,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
|
||||
@@ -156,3 +159,247 @@ def test_repository_presets_are_exported_without_internal_command_flows() -> Non
|
||||
encoded = json.dumps(catalog, ensure_ascii=False)
|
||||
assert "command_flow" not in encoded
|
||||
assert "source_request_id" not in encoded
|
||||
|
||||
|
||||
def test_saved_editor_flow_projects_all_native_and_extension_operations(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
registry = ModeProfileRegistry(
|
||||
tmp_path / "presets",
|
||||
user_root=tmp_path / "profiles",
|
||||
)
|
||||
operations = [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "driver",
|
||||
"parameters": {"temperature_c": 24},
|
||||
},
|
||||
{
|
||||
"domain": "interior_light",
|
||||
"capability": "interior_light.set_reading_power",
|
||||
"target_scope": "all",
|
||||
"parameters": {"state": "on"},
|
||||
},
|
||||
{
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_motion",
|
||||
"target_scope": "vehicle",
|
||||
"parameters": {"motion": "open"},
|
||||
},
|
||||
{
|
||||
"domain": "window",
|
||||
"capability": "window.set_position",
|
||||
"target_scope": "front_right",
|
||||
"parameters": {"position_pct": 50},
|
||||
},
|
||||
]
|
||||
|
||||
def configured_operation(
|
||||
operation_id: str,
|
||||
capability: str,
|
||||
zone: str,
|
||||
parameters: dict[str, object],
|
||||
*,
|
||||
device: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": operation_id,
|
||||
"capability": capability,
|
||||
"parameters": parameters,
|
||||
"target": {"device": device, "zone": zone},
|
||||
}
|
||||
|
||||
function_configs = {
|
||||
"native_climate": {
|
||||
"spec": {
|
||||
"domain": "climate",
|
||||
"operations": [
|
||||
configured_operation(
|
||||
"driver_set_temperature_1",
|
||||
"climate.set_temperature",
|
||||
"driver",
|
||||
{"temperature_c": 24},
|
||||
device="hvac",
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_interior_light": {
|
||||
"spec": {
|
||||
"domain": "interior_light",
|
||||
"operations": [
|
||||
configured_operation(
|
||||
"all_set_reading_power_1",
|
||||
"interior_light.set_reading_power",
|
||||
"all",
|
||||
{"state": "on"},
|
||||
device="reading_light",
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_roof": {
|
||||
"spec": {
|
||||
"domain": "roof",
|
||||
"operations": [
|
||||
configured_operation(
|
||||
"vehicle_set_shade_motion_1",
|
||||
"roof.set_shade_motion",
|
||||
"vehicle",
|
||||
{"motion": "open"},
|
||||
device="roof_shade",
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
"native_window": {
|
||||
"spec": {
|
||||
"domain": "window",
|
||||
"operations": [
|
||||
configured_operation(
|
||||
"front_right_set_position_1",
|
||||
"window.set_position",
|
||||
"front_right",
|
||||
{"position_pct": 50},
|
||||
device="window",
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
"self_extension": {
|
||||
"spec": {
|
||||
"domain": "self_extension",
|
||||
"operations": [
|
||||
{
|
||||
"id": "ir-slot:toy-light-001:2-1",
|
||||
"capability": "infrared.emit",
|
||||
"parameters": {"action": "打开", "slot": 2},
|
||||
"target": {
|
||||
"card_uid": "2CC8B103",
|
||||
"device": "toy-light-001",
|
||||
"name": "闪烁灯",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "ir-slot:toy-light-001:6-2",
|
||||
"capability": "infrared.emit",
|
||||
"parameters": {"action": "设置为白色", "slot": 6},
|
||||
"target": {
|
||||
"card_uid": "2CC8B103",
|
||||
"device": "toy-light-001",
|
||||
"name": "闪烁灯",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
registry.save_mode(
|
||||
"阅读模式",
|
||||
operations,
|
||||
mode_id="mode-reading",
|
||||
vehicle_id="denza_n7",
|
||||
command_flow={"function_configs": function_configs},
|
||||
)
|
||||
|
||||
catalog = build_mode_scene_catalog(
|
||||
registry,
|
||||
"denza_n7",
|
||||
generated_at="2026-09-15T00:00:00Z",
|
||||
)
|
||||
reading = next(
|
||||
scene for scene in catalog["scenes"] if scene["id"] == "mode-reading"
|
||||
)
|
||||
|
||||
assert reading["operation_count"] == 6
|
||||
assert len(reading["actions"]) == 6
|
||||
assert [action["capability"] for action in reading["actions"]] == [
|
||||
"climate.set_temperature",
|
||||
"interior_light.set_reading_power",
|
||||
"roof.set_shade_motion",
|
||||
"window.set_position",
|
||||
"infrared.emit",
|
||||
"infrared.emit",
|
||||
]
|
||||
assert reading["domains"] == [
|
||||
"climate",
|
||||
"interior_light",
|
||||
"roof",
|
||||
"self_extension",
|
||||
"window",
|
||||
]
|
||||
first_infrared, second_infrared = reading["actions"][-2:]
|
||||
assert first_infrared["parameters"] == {"action": "打开", "slot": 2}
|
||||
assert second_infrared["parameters"] == {"action": "设置为白色", "slot": 6}
|
||||
assert first_infrared["target"] == {
|
||||
"card_uid": "2CC8B103",
|
||||
"device": "toy-light-001",
|
||||
"name": "闪烁灯",
|
||||
}
|
||||
assert second_infrared["target"] == first_infrared["target"]
|
||||
assert first_infrared["operation"] == "emit"
|
||||
|
||||
|
||||
def test_mode_scene_mqtt_burst_publishes_only_one_exact_card_three_times() -> None:
|
||||
publications: list[tuple[float, dict[str, object]]] = []
|
||||
complete = threading.Event()
|
||||
scene: dict[str, object] = {
|
||||
"id": "mode-reading",
|
||||
"label": "阅读模式",
|
||||
"name": "阅读模式",
|
||||
"background_time": "noon",
|
||||
"description": "由自然语言生成的自定义模式。",
|
||||
"icon": "bookmark-plus",
|
||||
"aliases": ["阅读模式", "阅读"],
|
||||
"origin": "user",
|
||||
"deletable": True,
|
||||
"behavior": "static",
|
||||
"domains": ["climate"],
|
||||
"operation_count": 1,
|
||||
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||
"profile_revision": "1234567890abcdef",
|
||||
"actions": [
|
||||
{
|
||||
"type": "climate_temperature",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 24},
|
||||
"zone": "passenger",
|
||||
"celsius": 24,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def publish(payload: dict[str, object]) -> dict[str, object]:
|
||||
publications.append((time.monotonic(), payload))
|
||||
if len(publications) == 3:
|
||||
complete.set()
|
||||
return {"delivery_status": "published", "message_id": len(publications)}
|
||||
|
||||
publisher = ModeSceneMqttBurstPublisher(
|
||||
publish,
|
||||
topic="from_pc/scenes",
|
||||
burst_window_s=0.12,
|
||||
)
|
||||
queued_at = time.monotonic()
|
||||
try:
|
||||
queued = publisher.queue(scene)
|
||||
assert complete.wait(timeout=1.0)
|
||||
assert queued == {
|
||||
"state": "queued",
|
||||
"topic": "from_pc/scenes",
|
||||
"scene_id": "mode-reading",
|
||||
"burst_count": 3,
|
||||
"burst_window_ms": 120,
|
||||
}
|
||||
assert len(publications) == 3
|
||||
assert publications[-1][0] - queued_at < 0.2
|
||||
assert all(payload == scene for _, payload in publications)
|
||||
assert all("scenes" not in payload for _, payload in publications)
|
||||
assert publisher.snapshot()["state"] == "burst_complete"
|
||||
finally:
|
||||
publisher.close()
|
||||
|
||||
@@ -17,8 +17,13 @@ from cognitive_agent.infrastructure.can.hardware import (
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.mqtt_transport import (
|
||||
MQTT_CAN_SCHEMA,
|
||||
MQTT_EXTENSION_CONTROL_TOPIC,
|
||||
MQTT_EXTENSION_TOY_TOPIC,
|
||||
MQTT_MODE_SCENE_TOPIC,
|
||||
MQTT_PET_TOPIC,
|
||||
MqttCanTransport,
|
||||
mqtt_extension_toy_payload,
|
||||
mqtt_mode_scene_payload,
|
||||
mqtt_pet_payload,
|
||||
parse_mqtt_can_payload,
|
||||
)
|
||||
@@ -80,10 +85,19 @@ class _FakeMqttClient:
|
||||
self.published.append((topic, payload, qos, retain))
|
||||
return _PublishResult()
|
||||
|
||||
def emit(self, topic: str, payload: object) -> None:
|
||||
def emit(
|
||||
self,
|
||||
topic: str,
|
||||
payload: object,
|
||||
*,
|
||||
retain: bool = False,
|
||||
dup: bool = False,
|
||||
) -> None:
|
||||
message = SimpleNamespace(
|
||||
topic=topic,
|
||||
payload=json.dumps(payload).encode("utf-8"),
|
||||
retain=retain,
|
||||
dup=dup,
|
||||
)
|
||||
self.on_message(self, None, message)
|
||||
|
||||
@@ -557,6 +571,174 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
{"pet_name": "团团", "nfc_id": "not-a-uid", "action": "摇尾巴"}
|
||||
)
|
||||
|
||||
def test_extension_toy_uses_fixed_topic_qos_one_and_never_retains(self) -> None:
|
||||
# This application-level topic is contractually QoS 1 even if the
|
||||
# vehicle CAN relay itself is configured with a different QoS.
|
||||
self.transport.settings.qos = 0
|
||||
payload = {
|
||||
"extension_name": "闪烁灯",
|
||||
"actions": [
|
||||
{
|
||||
"type": "infrared_emit",
|
||||
"at_ms": 0,
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": "toy-light-001",
|
||||
"operation": "emit",
|
||||
"parameters": {"action": "打开", "slot": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
publication = self.transport.publish_extension_toy_message(payload)
|
||||
|
||||
topic, raw_payload, qos, retain = self.client.published[-1]
|
||||
self.assertEqual(topic, MQTT_EXTENSION_TOY_TOPIC)
|
||||
self.assertEqual(raw_payload, mqtt_extension_toy_payload(payload))
|
||||
self.assertEqual(json.loads(raw_payload), payload)
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertFalse(retain)
|
||||
self.assertEqual(publication["qos"], 1)
|
||||
self.assertEqual(publication["delivery_status"], "published")
|
||||
|
||||
def test_extension_toy_rejects_non_english_scope_and_extra_fields(self) -> None:
|
||||
action = {
|
||||
"type": "infrared_emit",
|
||||
"at_ms": 0,
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": "闪烁灯",
|
||||
"operation": "emit",
|
||||
"parameters": {"action": "打开", "slot": 2},
|
||||
}
|
||||
with self.assertRaisesRegex(Exception, "English stable device id"):
|
||||
self.transport.publish_extension_toy_message(
|
||||
{"extension_name": "闪烁灯", "actions": [action]}
|
||||
)
|
||||
with self.assertRaisesRegex(Exception, "requires exactly"):
|
||||
self.transport.publish_extension_toy_message(
|
||||
{
|
||||
"extension_name": "闪烁灯",
|
||||
"actions": [{**action, "target_scope": "toy-light-001"}],
|
||||
"execute": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_extension_control_is_dispatched_as_raw_bytes_without_blocking(self) -> None:
|
||||
received: list[bytes] = []
|
||||
self.transport.set_extension_control_handler(received.append)
|
||||
payload = {
|
||||
"domain": "self_extension",
|
||||
"operation_count": 1,
|
||||
"actions": [
|
||||
{
|
||||
"type": "infrared_emit",
|
||||
"at_ms": 0,
|
||||
"domain": "self_extension",
|
||||
"capability": "infrared.emit",
|
||||
"target_scope": "toy-light-001",
|
||||
"operation": "emit",
|
||||
"parameters": {"action": "打开", "slot": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(json.loads(received[0]), payload)
|
||||
status = self.transport.public_info()["extension_control"]
|
||||
self.assertTrue(status["handler_configured"])
|
||||
self.assertEqual(status["received_count"], 1)
|
||||
self.assertEqual(status["dispatched_count"], 1)
|
||||
self.assertEqual(status["rejected_count"], 0)
|
||||
|
||||
def test_extension_control_rejects_retained_and_duplicate_deliveries(self) -> None:
|
||||
received: list[bytes] = []
|
||||
rejections: list[tuple[str, str]] = []
|
||||
self.transport.set_extension_control_handler(received.append)
|
||||
self.transport.set_extension_control_rejection_handler(
|
||||
lambda error, kind: rejections.append((error, kind))
|
||||
)
|
||||
payload = {
|
||||
"domain": "self_extension",
|
||||
"operation_count": 1,
|
||||
"actions": [],
|
||||
}
|
||||
|
||||
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, retain=True)
|
||||
self.client.emit(MQTT_EXTENSION_CONTROL_TOPIC, payload, dup=True)
|
||||
|
||||
self.assertEqual(received, [])
|
||||
status = self.transport.public_info()["extension_control"]
|
||||
self.assertEqual(status["received_count"], 2)
|
||||
self.assertEqual(status["dispatched_count"], 0)
|
||||
self.assertEqual(status["rejected_count"], 2)
|
||||
self.assertEqual(status["retained_count"], 1)
|
||||
self.assertEqual(status["duplicate_count"], 1)
|
||||
self.assertTrue(status["rejection_handler_configured"])
|
||||
self.assertEqual(
|
||||
rejections,
|
||||
[
|
||||
("retained extension/ctl messages are not executable", "retained"),
|
||||
("duplicate extension/ctl delivery was ignored", "duplicate"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_mode_scene_uses_fixed_topic_exact_catalog_card_and_never_retains(
|
||||
self,
|
||||
) -> None:
|
||||
scene = {
|
||||
"id": "mode-reading",
|
||||
"label": "阅读模式",
|
||||
"name": "阅读模式",
|
||||
"background_time": "noon",
|
||||
"description": "由自然语言生成的自定义模式。",
|
||||
"icon": "bookmark-plus",
|
||||
"aliases": ["阅读模式", "阅读"],
|
||||
"origin": "user",
|
||||
"deletable": True,
|
||||
"behavior": "static",
|
||||
"domains": ["climate"],
|
||||
"operation_count": 1,
|
||||
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||
"profile_revision": "1234567890abcdef",
|
||||
"actions": [
|
||||
{
|
||||
"type": "climate_temperature",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 24},
|
||||
"zone": "passenger",
|
||||
"celsius": 24,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
publication = self.transport.publish_mode_scene(scene)
|
||||
|
||||
topic, raw_payload, qos, retain = self.client.published[-1]
|
||||
self.assertEqual(topic, MQTT_MODE_SCENE_TOPIC)
|
||||
self.assertEqual(raw_payload, mqtt_mode_scene_payload(scene))
|
||||
self.assertEqual(json.loads(raw_payload), scene)
|
||||
self.assertNotIn("scenes", json.loads(raw_payload))
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertFalse(retain)
|
||||
self.assertEqual(publication["delivery_status"], "published")
|
||||
|
||||
def test_mode_scene_rejects_catalog_wrapper(self) -> None:
|
||||
with self.assertRaisesRegex(Exception, "one scene only"):
|
||||
self.transport.publish_mode_scene(
|
||||
{
|
||||
"service": "n7-scenes",
|
||||
"scene_count": 1,
|
||||
"scenes": [{"id": "mode-reading"}],
|
||||
}
|
||||
)
|
||||
|
||||
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
@@ -590,7 +772,10 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.subscriptions,
|
||||
[("n7/n7-headunit/can/state", 1)],
|
||||
[
|
||||
("n7/n7-headunit/can/state", 1),
|
||||
(MQTT_EXTENSION_CONTROL_TOPIC, 1),
|
||||
],
|
||||
)
|
||||
|
||||
def test_first_partial_live_vehicle_snapshot_does_not_wait_for_missing_ids(self) -> None:
|
||||
|
||||
@@ -14,6 +14,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
script = source("self-extension.js")
|
||||
styles = source("self-extension.css")
|
||||
html = source("index.html")
|
||||
vite_entry = source("src/main.js")
|
||||
|
||||
assert 'data-open-nfc-binding' in script
|
||||
assert '靠近读卡器后' in script
|
||||
@@ -61,7 +62,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
assert '.v2-nfc-command-option' in styles
|
||||
assert '.v2-nfc-select-all' in styles
|
||||
assert 'self-extension.js?v=pet-settings-52' in html
|
||||
assert 'self-extension.css?v=pet-settings-52' in html
|
||||
assert 'import "../self-extension.css";' in vite_entry
|
||||
|
||||
|
||||
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
|
||||
@@ -208,5 +209,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
|
||||
assert "下发控制指令" in console
|
||||
assert "已解析控制指令" not in console
|
||||
assert 'li[data-can-channel="IR"]' in styles
|
||||
assert "console-v2.js?v=local-first-70" in html
|
||||
assert "console-v2.js?v=local-first-76" in html
|
||||
assert 'speech_semantic: "语音语义"' in console
|
||||
|
||||
@@ -10,7 +10,11 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def source(name: str) -> str:
|
||||
return (ROOT / "apps" / "web-console" / name).read_text(encoding="utf-8")
|
||||
web_root = ROOT / "apps" / "web-console"
|
||||
content = (web_root / name).read_text(encoding="utf-8")
|
||||
if name == "index.html":
|
||||
content += "\n" + (web_root / "src" / "App.vue").read_text(encoding="utf-8")
|
||||
return content
|
||||
|
||||
|
||||
def test_frontend_only_exposes_user_orchestration_and_extension() -> None:
|
||||
@@ -827,8 +831,8 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
|
||||
assert [commands[0]["deliveryLabel"] for commands in parsed] == [
|
||||
"未下发",
|
||||
"未下发",
|
||||
"下发",
|
||||
"下发",
|
||||
"已下发 · 待状态确认",
|
||||
"已下发 · 待状态确认",
|
||||
"安全阻止",
|
||||
]
|
||||
assert [commands[0]["deliveryState"] for commands in parsed] == [
|
||||
@@ -848,6 +852,137 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
|
||||
]
|
||||
|
||||
|
||||
def test_delivery_status_distinguishes_blocked_failed_aborted_and_verified() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
return
|
||||
console = source("console-v2.js")
|
||||
assert 'class="v2-can-command-delivery-detail"' in console
|
||||
assert "<b>状态说明</b>" in console
|
||||
start = console.index(" const normalizedCanTarget")
|
||||
end = console.index("\n\n function renderParsedCanCommands", start)
|
||||
helpers = console[start:end]
|
||||
|
||||
def sample(
|
||||
operation: dict,
|
||||
*,
|
||||
status: str,
|
||||
message: str = "",
|
||||
telemetry: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"execution_mode": "hardware",
|
||||
"control_transport": "wireless_mqtt",
|
||||
"function_configs": {
|
||||
"sample": {"spec": {"operations": [operation]}}
|
||||
},
|
||||
"execution_report": {
|
||||
"spec": {
|
||||
"results": [
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"status": status,
|
||||
"message": message,
|
||||
"telemetry": telemetry or {},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
samples = [
|
||||
sample(
|
||||
{
|
||||
"id": "window-blocked",
|
||||
"capability": "window.set_position",
|
||||
"target": {"zone": "front-right"},
|
||||
"parameters": {"position_pct": 20},
|
||||
},
|
||||
status="failed",
|
||||
message="未收到有效且新鲜的 0x388 车窗位置,拒绝启动机构",
|
||||
telemetry={"verification_status": "failed_or_blocked"},
|
||||
),
|
||||
sample(
|
||||
{
|
||||
"id": "infrared-dispatch-failed",
|
||||
"capability": "infrared.emit",
|
||||
"target": {"device": "reading-light", "name": "阅读灯"},
|
||||
"parameters": {"action": "开启", "slot": 2},
|
||||
},
|
||||
status="failed",
|
||||
message="GSCU1B 尚未连接到本地 TCP 桥接",
|
||||
telemetry={"delivery_status": "failed"},
|
||||
),
|
||||
sample(
|
||||
{
|
||||
"id": "infrared-aborted",
|
||||
"capability": "infrared.emit",
|
||||
"target": {"device": "reading-light", "name": "阅读灯"},
|
||||
"parameters": {"action": "切换为暖光", "slot": 5},
|
||||
},
|
||||
status="aborted",
|
||||
message="前序红外指令失败,未执行",
|
||||
telemetry={"delivery_status": "not_submitted"},
|
||||
),
|
||||
sample(
|
||||
{
|
||||
"id": "climate-verified",
|
||||
"capability": "climate.set_power",
|
||||
"target": {"zone": "vehicle"},
|
||||
"parameters": {"state": "on"},
|
||||
},
|
||||
status="succeeded",
|
||||
telemetry={
|
||||
"delivery_status": "delivered",
|
||||
"physical_effect_confirmed": True,
|
||||
},
|
||||
),
|
||||
sample(
|
||||
{
|
||||
"id": "climate-post-submit-failed",
|
||||
"capability": "climate.set_power",
|
||||
"target": {"zone": "vehicle"},
|
||||
"parameters": {"state": "on"},
|
||||
},
|
||||
status="failed",
|
||||
message="网关提交后执行失败",
|
||||
telemetry={"delivery_status": "submitted"},
|
||||
),
|
||||
]
|
||||
script = (
|
||||
f"{helpers}\n"
|
||||
f"const samples = {json.dumps(samples, ensure_ascii=False)};\n"
|
||||
"process.stdout.write(JSON.stringify(samples.map((sample) => "
|
||||
"parsedCanCommands(sample)[0])));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-"],
|
||||
input=script,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
commands = json.loads(completed.stdout)
|
||||
assert [item["deliveryLabel"] for item in commands] == [
|
||||
"安全阻止",
|
||||
"下发失败",
|
||||
"已中止",
|
||||
"执行已确认",
|
||||
"执行失败",
|
||||
]
|
||||
assert [item["deliveryState"] for item in commands] == [
|
||||
"blocked",
|
||||
"failed",
|
||||
"aborted",
|
||||
"submitted",
|
||||
"failed",
|
||||
]
|
||||
assert "0x388 实时位置" in commands[0]["deliveryDetail"]
|
||||
assert commands[1]["deliveryDetail"] == "GSCU1B 尚未连接到本地 TCP 桥接"
|
||||
|
||||
|
||||
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
|
||||
app = source("app.js")
|
||||
console = source("console-v2.js")
|
||||
@@ -870,6 +1005,12 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
|
||||
assert 'executionMode: "dry_run"' in app
|
||||
assert 'clientType: "mode_editor_compile"' in app
|
||||
assert "previewOnly: true" in app
|
||||
compile_flow = app.split(
|
||||
"async function executeModeEditorInstruction", 1
|
||||
)[1].split("async function completeModeEditor", 1)[0]
|
||||
assert "renderResult(" not in compile_flow
|
||||
assert "cognitive-agent:validated-can-commands" not in compile_flow
|
||||
assert "cognitive-agent:result" not in compile_flow
|
||||
assert 'const nonDispatchTaskSources = new Set(["mode_editor_compile"])' in console
|
||||
assert "if (isDispatchTask(task))" in console
|
||||
assert "下发控制指令" in console
|
||||
|
||||
@@ -943,7 +943,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
self.assertFalse(result["physical_state_confirmed"])
|
||||
self.assertEqual(len(result["device_response"]), 2)
|
||||
|
||||
def test_main_orchestration_resolves_only_live_bound_nfc_peripheral(self) -> None:
|
||||
def test_main_orchestration_uses_durable_binding_after_card_is_removed(self) -> None:
|
||||
catalog = [
|
||||
{
|
||||
"id": "cap-third-party-light-on",
|
||||
@@ -978,16 +978,25 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
self.assertIsNone(
|
||||
resolve_active_peripheral_command("打开氛围灯", catalog, present)
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.assertEqual(
|
||||
resolve_active_peripheral_command(
|
||||
"打开第三方氛围灯", catalog, {"card": None}
|
||||
)
|
||||
)["id"],
|
||||
"cap-third-party-light-on",
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.assertEqual(
|
||||
resolve_active_peripheral_command(
|
||||
"打开第三方氛围灯",
|
||||
catalog,
|
||||
{"card": {"uid": "FFFFFFFF"}},
|
||||
)["id"],
|
||||
"cap-third-party-light-on",
|
||||
)
|
||||
self.assertIsNone(
|
||||
resolve_active_peripheral_command(
|
||||
"打开第三方氛围灯",
|
||||
[{**catalog[0], "card_uid": ""}],
|
||||
{"card": None},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1059,6 +1068,17 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual([item["id"] for item in selected], ["on", "red"])
|
||||
self.assertEqual(
|
||||
[
|
||||
item["id"]
|
||||
for item in active_peripheral_plan_catalog(
|
||||
"打开第三方氛围灯,红灯闪烁5s后关闭",
|
||||
catalog,
|
||||
{"card": None},
|
||||
)
|
||||
],
|
||||
["on", "red"],
|
||||
)
|
||||
self.assertEqual(
|
||||
active_peripheral_plan_catalog(
|
||||
"打开第三方氛围灯",
|
||||
@@ -1351,7 +1371,18 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["status"] for item in result["operation_results"]],
|
||||
["failed"],
|
||||
["failed", "aborted"],
|
||||
)
|
||||
self.assertEqual(
|
||||
result["operation_results"][1]["message"],
|
||||
"前序红外指令失败,未执行",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["operation_results"][1]["telemetry"]["delivery_status"],
|
||||
"not_submitted",
|
||||
)
|
||||
self.assertFalse(
|
||||
result["operation_results"][1]["telemetry"]["transport_submitted"]
|
||||
)
|
||||
|
||||
def test_power_on_then_white_waits_800ms_and_continues_without_ack(self) -> None:
|
||||
|
||||
@@ -10,13 +10,18 @@ def source(name: str) -> str:
|
||||
|
||||
|
||||
def test_spatial_ui_contract_is_loaded_last_and_cached() -> None:
|
||||
html = source("index.html")
|
||||
main = source("src/main.js")
|
||||
service_worker = source("service-worker.js")
|
||||
asset = "/ui-consistency.css?v=spatial-ui-5"
|
||||
asset = "/ui-consistency.css?v=spatial-ui-8"
|
||||
|
||||
assert asset in html
|
||||
assert 'import "../ui-consistency.css"' in main
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert html.index("/energy-monitor.css") < html.index(asset)
|
||||
assert main.index('import "../energy-monitor.css"') < main.index(
|
||||
'import "../ui-consistency.css"'
|
||||
)
|
||||
assert main.index('import "../ui-consistency.css"') < main.index(
|
||||
'import "./styles/design-system.css"'
|
||||
)
|
||||
|
||||
|
||||
def test_same_kind_surfaces_share_geometry_tokens() -> None:
|
||||
@@ -116,12 +121,16 @@ def test_pet_and_tcp_dialog_controls_keep_accessible_aligned_layout() -> None:
|
||||
|
||||
def test_compact_sidebar_overrides_cannot_break_shared_geometry() -> None:
|
||||
styles = source("ui-consistency.css")
|
||||
design_system = source("src/styles/design-system.css")
|
||||
|
||||
assert ".v2-sync-execution-field," in styles
|
||||
assert ".v2-can-command-panel" in styles
|
||||
assert "border-radius: var(--ui-radius-item) !important" in styles
|
||||
assert ".v2-sync-log {" in styles
|
||||
assert "border-radius: var(--ui-radius-section) !important" in styles
|
||||
assert "body.console-v2 .v2-sync-log h3" in design_system
|
||||
assert "font-size: var(--n7-font-card-title) !important" in design_system
|
||||
assert "white-space: nowrap" in design_system
|
||||
|
||||
|
||||
def test_assistant_composer_hint_stays_inside_narrow_input() -> None:
|
||||
@@ -133,9 +142,13 @@ def test_assistant_composer_hint_stays_inside_narrow_input() -> None:
|
||||
assert "max-width: calc(100% - 132px)" in styles
|
||||
|
||||
|
||||
def test_failed_delivery_uses_one_semantic_status_treatment() -> None:
|
||||
def test_delivery_failures_use_distinct_semantic_status_treatments() -> None:
|
||||
styles = source("ui-consistency.css")
|
||||
|
||||
assert '[data-delivery-state="blocked"]' in styles
|
||||
assert '[data-delivery-state="failed"]' in styles
|
||||
assert '[data-delivery-state="aborted"]' in styles
|
||||
assert ".v2-can-command-delivery-detail" in styles
|
||||
assert "color: var(--destructive)" in styles
|
||||
assert "color: var(--status-warning-foreground)" in styles
|
||||
assert "color: var(--muted-foreground)" in styles
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -11,25 +12,44 @@ def source(name: str) -> str:
|
||||
return (WEB / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_vue_presentation_assets_are_local_ordered_and_cached() -> None:
|
||||
def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
||||
html = source("index.html")
|
||||
main = source("src/main.js")
|
||||
app = source("src/App.vue")
|
||||
package = json.loads(source("package.json"))
|
||||
vite = source("vite.config.js")
|
||||
service_worker = source("service-worker.js")
|
||||
runtime = "/vendor/vue/3.5.21/vue.runtime.global.prod.js"
|
||||
presentation = "/vue-ui.js?v=vue-presentation-3"
|
||||
|
||||
assert html.index(runtime) < html.index(presentation) < html.index(
|
||||
"/app.js?v=dual-seat-140"
|
||||
)
|
||||
assert html.index(presentation) < html.index(
|
||||
"/assistant-chat.js?v=codex-assistant-33"
|
||||
)
|
||||
assert f'"{runtime}"' in service_worker
|
||||
assert f'"{presentation}"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v305"' in service_worker
|
||||
assert '<div id="app" aria-busy="true"></div>' in html
|
||||
assert '<script type="module" src="/src/main.js"></script>' in html
|
||||
assert 'import { createApp, nextTick } from "vue"' in main
|
||||
assert 'import App from "./App.vue"' in main
|
||||
assert 'application.mount("#app")' in main
|
||||
assert 'defineOptions({ name: "N7ConsoleApp" })' in app
|
||||
assert 'class="workspace"' in app
|
||||
assert package["dependencies"]["vue"] == "3.5.21"
|
||||
assert package["scripts"]["build"] == "vite build"
|
||||
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
||||
assert 'outDir: outputRoot' in vite
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker
|
||||
assert "unpkg.com" not in html
|
||||
assert "cdn.jsdelivr.net" not in html
|
||||
|
||||
|
||||
def test_python_and_one_click_startup_use_the_vite_distribution() -> None:
|
||||
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
launcher = (ROOT / "tools" / "start_n7web.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist"' in server
|
||||
assert 'if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file()' in server
|
||||
assert 'parser.add_argument(\n "--skip-web-build"' in launcher
|
||||
assert 'commands.append([str(npm), "ci"])' in launcher
|
||||
assert 'commands.append([str(npm), "run", "build"])' in launcher
|
||||
assert "_build_web_console(npm)" in launcher
|
||||
|
||||
|
||||
def test_vendored_vue_runtime_and_license_are_present() -> None:
|
||||
runtime = WEB / "vendor" / "vue" / "3.5.21" / "vue.runtime.global.prod.js"
|
||||
license_file = WEB / "vendor" / "vue" / "3.5.21" / "LICENSE"
|
||||
@@ -66,7 +86,7 @@ def test_vue_islands_render_modes_and_dispatched_controls() -> None:
|
||||
assert ".assistant-dispatched-controls-vue-host" in styles
|
||||
|
||||
|
||||
def test_existing_dom_renderers_remain_as_no_vue_fallbacks() -> None:
|
||||
def test_existing_dom_renderers_remain_as_transition_fallbacks() -> None:
|
||||
app = source("app.js")
|
||||
assistant = source("assistant-chat.js")
|
||||
|
||||
@@ -81,5 +101,7 @@ def test_existing_dom_renderers_remain_as_no_vue_fallbacks() -> None:
|
||||
'document.createElement("details")', command_vue_call
|
||||
)
|
||||
assert command_fallback > command_vue_call
|
||||
assert "renderDispatchedControls: () => false" in source("vue-ui.js")
|
||||
assert "renderModeChips: () => false" in source("vue-ui.js")
|
||||
vue_ui = source("vue-ui.js")
|
||||
assert 'from "vue"' in vue_ui
|
||||
assert "renderDispatchedControls: () => false" not in vue_ui
|
||||
assert "renderModeChips: () => false" not in vue_ui
|
||||
|
||||
Reference in New Issue
Block a user