feat(n7web): enhance control workflows and status feedback

This commit is contained in:
Miayui
2026-09-16 12:52:07 +08:00
parent 84a3cd73bb
commit 0e4328f548
45 changed files with 7317 additions and 1001 deletions
+68
View File
@@ -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
View File
@@ -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:
+58 -13
View File
@@ -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:
+363
View File
@@ -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
)
+227
View File
@@ -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
+54 -11
View File
@@ -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")
+12 -6
View File
@@ -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
+247
View File
@@ -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()
+187 -2
View File
@@ -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:
+3 -2
View File
@@ -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
+36 -5
View File
@@ -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
+37 -15
View File
@@ -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