merge: sync feature/N7Web into feature/N7Web-Tech
# Conflicts: # apps/web-console/service-worker.js # src/cognitive_agent/application/mode_profile.py # src/cognitive_agent/application/mode_scene_catalog.py # src/cognitive_agent/application/seat_service.py # tests/e2e/test_seat_web_api.py # tests/unit/test_assistant_frontend_contract.py # tests/unit/test_vue_frontend_contract.py
This commit is contained in:
@@ -16,8 +16,13 @@ from cognitive_agent.generated.can_semantic_protocol import (
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vehicle_service() -> SeatDemoService:
|
||||
return SeatDemoService()
|
||||
def vehicle_service(tmp_path_factory: pytest.TempPathFactory) -> SeatDemoService:
|
||||
service = SeatDemoService()
|
||||
service.mode_profiles = type(service.mode_profiles)(
|
||||
service.root / "config" / "modes",
|
||||
user_root=tmp_path_factory.mktemp("assistant-capability-modes"),
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def _catalog(
|
||||
@@ -269,7 +274,16 @@ def test_all_modes_are_discoverable_and_reference_callable_capabilities(
|
||||
"zero_gravity",
|
||||
}
|
||||
assert all(item["assistant_callable"] is True for item in modes)
|
||||
assert all(set(item["operation_ids"]) <= callable_ids for item in modes)
|
||||
assert all(
|
||||
set(item["operation_ids"])
|
||||
<= callable_ids | set(item.get("n7_transfer_operation_ids", []))
|
||||
for item in modes
|
||||
)
|
||||
assert all(
|
||||
not item.get("n7_transfer_operation_ids")
|
||||
or item.get("origin") == "n7_mqtt"
|
||||
for item in modes
|
||||
)
|
||||
assert (
|
||||
next(item for item in modes if item["id"] == "baby_nap")[
|
||||
"all_operations_have_native_binding"
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
|
||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v318-tech-nfc-routing"'
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v322-tech-n7-dispatch"'
|
||||
in service_worker
|
||||
)
|
||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||
|
||||
@@ -32,16 +32,16 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
||||
|
||||
for asset in (
|
||||
"/energy-monitor.css?v=energy-monitor-4",
|
||||
"/energy-monitor.js?v=energy-monitor-4",
|
||||
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||
):
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert 'import "../energy-monitor.css";' in vite_entry
|
||||
assert "/energy-monitor.js?v=energy-monitor-4" in html
|
||||
assert "/energy-monitor.js?v=energy-monitor-5-performance" in html
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-34") < html.index(
|
||||
"/energy-monitor.js?v=energy-monitor-4"
|
||||
"/energy-monitor.js?v=energy-monitor-5-performance"
|
||||
)
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"'
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v322-tech-n7-dispatch"'
|
||||
in service_worker
|
||||
)
|
||||
|
||||
@@ -140,7 +140,7 @@ def test_energy_monitor_samples_integrates_and_bounds_its_curves() -> None:
|
||||
assert 'id="energyMonitorChartLine"' in script
|
||||
assert 'id="energyMonitorChartArea"' in script
|
||||
assert "polylinePoints(values, maximum" in script
|
||||
assert 'window.setInterval(() => sampleNow(), SAMPLE_INTERVAL_MS)' in script
|
||||
assert '() => sampleNow({ render: monitor.open })' in script
|
||||
assert 'ingestPowerTelemetry(event.detail || {}, { sample: false })' in script
|
||||
assert 'ingestPowerTelemetry(payload, { sample: false })' in script
|
||||
assert 'new CustomEvent("cognitive-agent:energy-snapshot"' in script
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WEB = ROOT / "apps" / "web-console"
|
||||
|
||||
|
||||
def source(name: str) -> str:
|
||||
return (WEB / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def section(content: str, start: str, end: str) -> str:
|
||||
start_index = content.index(start)
|
||||
end_index = content.index(end, start_index)
|
||||
return content[start_index:end_index]
|
||||
|
||||
|
||||
def test_vehicle_twin_renders_on_demand_and_throttles_hotspot_layout() -> None:
|
||||
app = source("app.js")
|
||||
animation = section(app, " function animate(timestamp = 0) {", " setTwinSelection(")
|
||||
|
||||
assert "function requestRender({ hotspots = true, settleFrames = 36 } = {})" in app
|
||||
assert "if (twinNeedsAnimation(twin)) queueTwinFrame();" in animation
|
||||
assert "window.requestAnimationFrame(animate)" not in animation
|
||||
assert "document.visibilityState !== \"visible\"" in app
|
||||
assert "new IntersectionObserver" in app
|
||||
assert "timestamp - twin.hotspotLastLayoutAt >= 100" in animation
|
||||
assert "hotspotMetrics: new Map()" in app
|
||||
assert "label?.offsetWidth" in app
|
||||
assert "renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5))" in app
|
||||
|
||||
|
||||
def test_unchanged_vehicle_state_skips_dom_and_device_panel_rebuilds() -> None:
|
||||
app = source("app.js")
|
||||
console = source("console-v2.js")
|
||||
seat_render = section(app, "function renderSeatState(actuatorState) {", "\nfunction renderDomainState")
|
||||
|
||||
assert "vehicleRenderFingerprint(mergedActuatorState)" in seat_render
|
||||
assert "renderFingerprint === state.seatRenderFingerprint" in seat_render
|
||||
assert "resetSeatState();" not in seat_render
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-state-rendered"' in app
|
||||
assert '"cognitive-agent:vehicle-state-rendered"' in console
|
||||
assert "observe(cabinBand" not in console
|
||||
assert "observe(stateBand" not in console
|
||||
assert 'if (!panelVisible("devices"))' in console
|
||||
assert "renderSyncedDevices({ force: true })" in console
|
||||
|
||||
|
||||
def test_nfc_polling_keeps_its_frequency_without_rendering_hidden_surfaces() -> None:
|
||||
extension = source("self-extension.js")
|
||||
|
||||
assert "const NFC_POLL_INTERVAL_MS = 700" in extension
|
||||
assert "currentNfcDialogFingerprint()" in extension
|
||||
assert "if (!force && !dialog.open)" in extension
|
||||
assert "renderFingerprint === nfcDialogFingerprint" in extension
|
||||
assert 'panel?.classList.contains("is-collapsed")' in extension
|
||||
assert "renderFingerprint === unifiedExtensionFingerprint" in extension
|
||||
assert '"cognitive-agent:side-panel-visibility"' in extension
|
||||
|
||||
|
||||
def test_hidden_energy_monitor_samples_without_repainting() -> None:
|
||||
monitor = source("energy-monitor.js")
|
||||
|
||||
assert "SAMPLE_INTERVAL_MS = 1000" in monitor
|
||||
assert "() => sampleNow({ render: monitor.open })" in monitor
|
||||
assert "if (next)" in monitor
|
||||
assert "renderMonitor();" in section(monitor, " function setOpen(open) {", "\n function selectSeries")
|
||||
|
||||
|
||||
def test_high_frequency_fields_and_hotspots_do_not_add_compositor_blur() -> None:
|
||||
theme = source("codepen-glass-theme.css")
|
||||
field_rule = section(
|
||||
theme,
|
||||
"body.console-v2 :is(input, textarea, select),",
|
||||
"body.console-v2 :is(input, textarea, select):focus",
|
||||
)
|
||||
|
||||
assert "backdrop-filter" not in field_rule
|
||||
assert "body.console-v2 .v2-twin-device-popover {" in theme
|
||||
shared_hotspot_rule = section(
|
||||
theme,
|
||||
"body.console-v2 .v2-twin-hotspot-label,",
|
||||
"body.console-v2 .v2-twin-device-popover {\n -webkit-backdrop-filter",
|
||||
)
|
||||
assert "backdrop-filter" not in shared_hotspot_rule
|
||||
|
||||
|
||||
def test_performance_assets_have_a_new_cache_generation() -> None:
|
||||
html = source("index.html")
|
||||
worker = source("service-worker.js")
|
||||
|
||||
for asset in (
|
||||
"/app.js?v=dual-seat-145-n7-dispatch",
|
||||
"/console-v2.js?v=local-first-77-performance",
|
||||
"/self-extension.js?v=pet-settings-53-performance",
|
||||
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v322-tech-n7-dispatch"' in worker
|
||||
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
|
||||
@@ -47,12 +47,12 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
('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"),
|
||||
('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-6-performance"),
|
||||
):
|
||||
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-v316-sidebar-accordion"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v322-tech-n7-dispatch"' in service_worker
|
||||
|
||||
|
||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
def test_all_repository_mode_cards_preserve_every_operation_in_dry_run(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Every visible preset must reach a validated executable configuration."""
|
||||
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=tmp_path / "user-modes",
|
||||
)
|
||||
service.artifact_root = tmp_path / "runs"
|
||||
vehicle_id = "denza_n7"
|
||||
failures: list[dict[str, object]] = []
|
||||
|
||||
for card in service.mode_profiles.public_modes(vehicle_id):
|
||||
profile = service.mode_profiles.get_profile(str(card["id"]), vehicle_id)
|
||||
result = service.process(
|
||||
profile.instruction or f"进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id=f"conv-mode-integrity-{profile.id.replace('_', '-')}",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id=vehicle_id,
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
compiled_operations = [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
]
|
||||
validation_errors = [
|
||||
issue
|
||||
for issue in result.get("validation", [])
|
||||
if issue.get("severity") == "error"
|
||||
]
|
||||
if (
|
||||
result.get("status") != "succeeded"
|
||||
or validation_errors
|
||||
or result.get("deferred_requests")
|
||||
or len(compiled_operations) != len(profile.operations)
|
||||
):
|
||||
failures.append(
|
||||
{
|
||||
"id": profile.id,
|
||||
"expected_operations": len(profile.operations),
|
||||
"compiled_operations": len(compiled_operations),
|
||||
"status": result.get("status"),
|
||||
"deferred_requests": result.get("deferred_requests"),
|
||||
"validation_errors": validation_errors,
|
||||
}
|
||||
)
|
||||
|
||||
assert not failures, json.dumps(failures, ensure_ascii=False, indent=2)
|
||||
@@ -323,6 +323,11 @@ const context = {
|
||||
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
||||
hideModeActionPopover() {},
|
||||
showToast(message) { calls.push(["toast", message]); },
|
||||
hasExecutableModeCommandFlow(mode) {
|
||||
const flow = mode?.command_flow;
|
||||
return Boolean(flow?.source_request_id)
|
||||
|| Boolean(flow?.plans && Object.keys(flow.plans).length);
|
||||
},
|
||||
executeSavedModeProfile(mode, options) {
|
||||
calls.push(["execute", mode.id, options.command]);
|
||||
return Promise.resolve(true);
|
||||
@@ -359,6 +364,42 @@ console.log(JSON.stringify({
|
||||
assert "executeSavedModeProfile" in snippet
|
||||
|
||||
|
||||
def test_n7_mode_command_flow_is_directly_executable_without_source_request_id() -> None:
|
||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
start = app.index("function hasExecutableModeCommandFlow")
|
||||
end = app.index("\n\nasync function executeSavedModeProfile", start)
|
||||
helper = app[start:end]
|
||||
script = helper + r"""
|
||||
console.log(JSON.stringify({
|
||||
imported: hasExecutableModeCommandFlow({
|
||||
id: "cinema",
|
||||
command_flow: {
|
||||
source_topic: "from_n7/scenes",
|
||||
plans: { from_n7_scene: { spec: { commands: [] } } },
|
||||
},
|
||||
}),
|
||||
generated: hasExecutableModeCommandFlow({
|
||||
id: "reading-user",
|
||||
command_flow: { source_request_id: "req-reading" },
|
||||
}),
|
||||
missing: hasExecutableModeCommandFlow({ id: "office" }),
|
||||
}));
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[node_runtime(), "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {
|
||||
"imported": True,
|
||||
"generated": True,
|
||||
"missing": False,
|
||||
}
|
||||
|
||||
|
||||
def test_mode_card_without_saved_flow_falls_back_to_assistant_submission() -> None:
|
||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
start = app.index("function applyModeCommand")
|
||||
@@ -391,6 +432,11 @@ const context = {
|
||||
setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); },
|
||||
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
||||
showToast(message) { calls.push(["toast", message]); },
|
||||
hasExecutableModeCommandFlow(mode) {
|
||||
const flow = mode?.command_flow;
|
||||
return Boolean(flow?.source_request_id)
|
||||
|| Boolean(flow?.plans && Object.keys(flow.plans).length);
|
||||
},
|
||||
$(selector) {
|
||||
return { "#utterance": input, "#requestForm": form, "#runButton": runButton }[selector];
|
||||
},
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import (
|
||||
AssistantCapabilityCatalog,
|
||||
)
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_scene_ingress import (
|
||||
ModeSceneIngressConsumer,
|
||||
ModeSceneIngressError,
|
||||
parse_mode_scene_ingress_payload,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
def scene_card(*, temperature: int = 24) -> dict[str, object]:
|
||||
return {
|
||||
"id": "n7-reading",
|
||||
"label": "N7 阅读模式",
|
||||
"name": "N7 阅读模式",
|
||||
"background_time": "evening",
|
||||
"description": "由车端同步的阅读场景。",
|
||||
"icon": "book-open",
|
||||
"aliases": ["N7 阅读模式", "车端阅读"],
|
||||
"origin": "user",
|
||||
"deletable": True,
|
||||
"behavior": "static",
|
||||
"domains": ["climate"],
|
||||
"operation_count": 1,
|
||||
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||
"profile_revision": f"remote-{temperature}",
|
||||
"actions": [
|
||||
{
|
||||
"type": "climate_temperature",
|
||||
"at_ms": 250,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": temperature},
|
||||
"zone": "passenger",
|
||||
"celsius": temperature,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def cinema_scene_card() -> dict[str, object]:
|
||||
"""Representative N7 card containing local and head-unit-only actions."""
|
||||
|
||||
actions: list[dict[str, object]] = [
|
||||
*[
|
||||
{
|
||||
"type": "window_position",
|
||||
"at_ms": 0,
|
||||
"domain": "window",
|
||||
"capability": "window.set_position",
|
||||
"target_scope": target,
|
||||
"operation": "set",
|
||||
"parameters": {"position_pct": 0},
|
||||
}
|
||||
for target in ("front_left", "front_right", "rear_left", "rear_right")
|
||||
],
|
||||
{
|
||||
"type": "roof_shade_position",
|
||||
"at_ms": 0,
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_position",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"position_pct": 0},
|
||||
},
|
||||
{
|
||||
"type": "seat_preset",
|
||||
"at_ms": 0,
|
||||
"domain": "local_vehicle",
|
||||
"capability": "local_scene.seat_preset",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"targets_pulse": [246, 112, 405, 342]},
|
||||
},
|
||||
{
|
||||
"type": "seat_ventilation",
|
||||
"at_ms": 0,
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_ventilation",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "off"},
|
||||
},
|
||||
{
|
||||
"type": "climate_power",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_power",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
},
|
||||
{
|
||||
"type": "climate_fan",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_fan_level",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"level": 2},
|
||||
},
|
||||
{
|
||||
"type": "climate_outlet_mode",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_outlet_mode",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"mode": "face"},
|
||||
},
|
||||
{
|
||||
"type": "climate_cycle_mode",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_cycle_mode",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"mode": "internal"},
|
||||
},
|
||||
{
|
||||
"type": "climate_control_mode",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_control_mode",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"mode": "manual"},
|
||||
},
|
||||
{
|
||||
"type": "climate_temperature",
|
||||
"at_ms": 0,
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 25},
|
||||
},
|
||||
{
|
||||
"type": "interior_light_reading_power",
|
||||
"at_ms": 0,
|
||||
"domain": "interior_light",
|
||||
"capability": "interior_light.set_reading_power",
|
||||
"target_scope": "all",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "off"},
|
||||
},
|
||||
{
|
||||
"type": "ambient_light_power",
|
||||
"at_ms": 0,
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_power",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
},
|
||||
{
|
||||
"type": "ambient_light_color",
|
||||
"at_ms": 0,
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_color_index",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"color_index": 36},
|
||||
},
|
||||
{
|
||||
"type": "ambient_light_brightness",
|
||||
"at_ms": 0,
|
||||
"domain": "ambient_light",
|
||||
"capability": "ambient_light.set_brightness_level",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"brightness_level": 4},
|
||||
},
|
||||
]
|
||||
return {
|
||||
"id": "cinema",
|
||||
"label": "Cinema mode",
|
||||
"name": "Cinema mode",
|
||||
"background_time": "night",
|
||||
"description": "Representative N7 mixed-domain scene.",
|
||||
"icon": "bookmark-plus",
|
||||
"aliases": ["Cinema mode"],
|
||||
"origin": "headunit",
|
||||
"deletable": True,
|
||||
"behavior": "static",
|
||||
"domains": list(dict.fromkeys(str(item["domain"]) for item in actions)),
|
||||
"operation_count": len(actions),
|
||||
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||
"actions": actions,
|
||||
"profile_revision": "cinema-test-revision",
|
||||
}
|
||||
|
||||
|
||||
def wait_until(predicate, timeout: float = 1.5) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return bool(predicate())
|
||||
|
||||
|
||||
def configured_service(tmp_path: Path) -> SeatDemoService:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.mode_profiles = ModeProfileRegistry(
|
||||
tmp_path / "presets",
|
||||
user_root=tmp_path / "profiles",
|
||||
)
|
||||
service.artifact_root = tmp_path / "runs"
|
||||
return service
|
||||
|
||||
|
||||
def test_parser_uses_exact_single_card_contract_and_rejects_duplicate_keys() -> None:
|
||||
scene = scene_card()
|
||||
parsed = parse_mode_scene_ingress_payload(
|
||||
json.dumps(scene, ensure_ascii=False).encode("utf-8")
|
||||
)
|
||||
assert parsed == scene
|
||||
|
||||
duplicate = (
|
||||
'{"id":"one","id":"two","label":"x","name":"x",'
|
||||
'"background_time":"noon","description":"",'
|
||||
'"icon":"x","aliases":[],"origin":"user",'
|
||||
'"deletable":true,"behavior":"static","domains":["x"],'
|
||||
'"operation_count":1,"scope":{},"profile_revision":"x",'
|
||||
'"actions":[]}'
|
||||
)
|
||||
with pytest.raises(ModeSceneIngressError, match="duplicate key"):
|
||||
parse_mode_scene_ingress_payload(duplicate)
|
||||
|
||||
|
||||
def test_three_identical_frames_are_imported_once() -> None:
|
||||
imports: list[dict[str, object]] = []
|
||||
consumer = ModeSceneIngressConsumer(
|
||||
lambda scene: imports.append(dict(scene)) or {"status": "imported"},
|
||||
deduplication_window_s=1.0,
|
||||
)
|
||||
payload = json.dumps(scene_card(), ensure_ascii=False).encode("utf-8")
|
||||
consumer.start()
|
||||
try:
|
||||
for _ in range(3):
|
||||
consumer.submit(payload)
|
||||
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 3)
|
||||
assert imports == [scene_card()]
|
||||
status = consumer.snapshot()
|
||||
assert status["imported_count"] == 1
|
||||
assert status["duplicate_count"] == 2
|
||||
assert status["rejected_count"] == 0
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
def test_failed_import_does_not_suppress_identical_retry() -> None:
|
||||
importer = MagicMock(
|
||||
side_effect=[
|
||||
RuntimeError("temporary persistence failure"),
|
||||
{"status": "imported"},
|
||||
]
|
||||
)
|
||||
consumer = ModeSceneIngressConsumer(
|
||||
importer,
|
||||
deduplication_window_s=1.0,
|
||||
)
|
||||
payload = json.dumps(scene_card(), ensure_ascii=False).encode("utf-8")
|
||||
consumer.start()
|
||||
try:
|
||||
consumer.submit(payload)
|
||||
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 1)
|
||||
first_status = consumer.snapshot()
|
||||
assert first_status["rejected_count"] == 1
|
||||
assert first_status["imported_count"] == 0
|
||||
|
||||
consumer.submit(payload)
|
||||
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 2)
|
||||
retry_status = consumer.snapshot()
|
||||
assert retry_status["rejected_count"] == 1
|
||||
assert retry_status["imported_count"] == 1
|
||||
assert retry_status["duplicate_count"] == 0
|
||||
assert importer.call_count == 2
|
||||
|
||||
consumer.submit(payload)
|
||||
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 3)
|
||||
final_status = consumer.snapshot()
|
||||
assert final_status["duplicate_count"] == 1
|
||||
assert importer.call_count == 2
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
def test_import_upserts_yaml_refreshes_catalog_and_does_not_echo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = configured_service(tmp_path)
|
||||
service.configure_mode_scene_catalog(tmp_path / "scenes.json")
|
||||
outbound = MagicMock()
|
||||
service._vehicle_mqtt_transport.publish_mode_scene = outbound
|
||||
|
||||
first = service.import_n7_mode_scene(scene_card(temperature=24))
|
||||
updated = service.import_n7_mode_scene(scene_card(temperature=26))
|
||||
|
||||
assert first["scene_id"] == "n7-reading"
|
||||
assert updated["scene_id"] == "n7-reading"
|
||||
profiles = service.mode_profiles.public_modes("denza_n7")
|
||||
assert len(profiles) == 1
|
||||
profile = profiles[0]
|
||||
assert profile["origin"] == "n7_mqtt"
|
||||
assert profile["source_topic"] == "from_n7/scenes"
|
||||
assert profile["upstream_origin"] == "user"
|
||||
assert profile["operations"][0]["parameters"] == {"temperature_c": 26}
|
||||
|
||||
catalog = service.mode_scene_catalog("denza_n7")
|
||||
assert catalog["scene_count"] == 1
|
||||
card = catalog["scenes"][0]
|
||||
assert card["origin"] == "n7_mqtt"
|
||||
assert card["source_topic"] == "from_n7/scenes"
|
||||
assert card["actions"] == scene_card(temperature=26)["actions"]
|
||||
assert card["actions"][0]["at_ms"] == 250
|
||||
assert outbound.call_count == 0
|
||||
|
||||
yaml_path = Path(service.mode_profiles.get_profile("n7-reading", "denza_n7").source)
|
||||
yaml_text = yaml_path.read_text(encoding="utf-8")
|
||||
assert "sync_origin: from_n7/scenes" in yaml_text
|
||||
assert "upstream_origin: user" in yaml_text
|
||||
|
||||
|
||||
def test_unknown_n7_action_is_forwarded_once_only_during_hardware_execution(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = configured_service(tmp_path)
|
||||
unknown = scene_card()
|
||||
unknown.update(
|
||||
domains=["media"],
|
||||
operation_count=1,
|
||||
actions=[
|
||||
{
|
||||
"type": "media_play_favorite",
|
||||
"at_ms": 0,
|
||||
"domain": "media",
|
||||
"capability": "media.play_favorite",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "play",
|
||||
"parameters": {"playlist": "focus"},
|
||||
}
|
||||
],
|
||||
)
|
||||
service.import_n7_mode_scene(unknown)
|
||||
publisher = MagicMock(
|
||||
return_value={
|
||||
"topic": "transto_n7/ctl",
|
||||
"qos": 1,
|
||||
"retain": False,
|
||||
"delivery_status": "published",
|
||||
"message_id": 7,
|
||||
}
|
||||
)
|
||||
service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
|
||||
|
||||
dry_run = service.execute_mode_profile_card(
|
||||
"n7-reading",
|
||||
execution_mode="dry_run",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
assert dry_run["status"] == "capability_unavailable"
|
||||
publisher.assert_not_called()
|
||||
|
||||
result = service.execute_mode_profile_card(
|
||||
"n7-reading",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
assert result["status"] == "capability_unavailable"
|
||||
assert result["n7_control_transfer"]["state"] == "published"
|
||||
publisher.assert_called_once()
|
||||
payload = publisher.call_args.args[0]
|
||||
assert payload == {
|
||||
"scene_id": "n7-reading",
|
||||
"scene_name": "N7 阅读模式",
|
||||
"request_id": result["request_id"],
|
||||
"operation_count": 1,
|
||||
"actions": deepcopy(unknown["actions"]),
|
||||
}
|
||||
assert result["deferred_requests"][0]["transport_submitted"] is False
|
||||
|
||||
|
||||
def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = configured_service(tmp_path)
|
||||
scene = cinema_scene_card()
|
||||
service.import_n7_mode_scene(scene)
|
||||
|
||||
result = service.execute_mode_profile_card(
|
||||
"cinema",
|
||||
execution_mode="dry_run",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
assert result["status"] == "partially_succeeded"
|
||||
assert not [
|
||||
issue
|
||||
for issue in result["validation"]
|
||||
if issue.get("severity") == "error"
|
||||
]
|
||||
compiled_operations = [
|
||||
operation
|
||||
for config in result["function_configs"].values()
|
||||
for operation in config["spec"]["operations"]
|
||||
]
|
||||
assert len(compiled_operations) == 16
|
||||
assert all(
|
||||
operation.get("parse_source") == "saved_mode_command_flow"
|
||||
for operation in compiled_operations
|
||||
)
|
||||
assert {
|
||||
operation["target"]["zone"]
|
||||
for operation in compiled_operations
|
||||
if operation["capability"] == "window.set_position"
|
||||
} == {"front_left", "front_right", "rear_left", "rear_right"}
|
||||
transferred = scene["actions"][5]
|
||||
assert result["n7_transfer_candidates"] == [transferred]
|
||||
assert result["deferred_requests"] == [
|
||||
{
|
||||
"domain": "local_vehicle",
|
||||
"capability": "local_scene.seat_preset",
|
||||
"target_scope": "vehicle",
|
||||
"desired_change": "set",
|
||||
"parameters": {"targets_pulse": [246, 112, 405, 342]},
|
||||
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
|
||||
"execution_backend": "n7_vehicle_transfer",
|
||||
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
|
||||
"transport_submitted": False,
|
||||
}
|
||||
]
|
||||
assert len(compiled_operations) + len(result["n7_transfer_candidates"]) == len(
|
||||
scene["actions"]
|
||||
)
|
||||
|
||||
text_result = service.process(
|
||||
"进入Cinema mode模式",
|
||||
provider="local",
|
||||
conversation_id="conv-n7-cinema-text",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
assert text_result["status"] == "partially_succeeded"
|
||||
assert text_result["mode_profile"] is None
|
||||
assert [item["id"] for item in text_result["matched_modes"]] == ["cinema"]
|
||||
assert len(text_result["n7_transfer_candidates"]) == 1
|
||||
assert service.mode_profiles.match_text(
|
||||
"氛围灯设置为Cinema mode模式",
|
||||
vehicle_id="denza_n7",
|
||||
) == []
|
||||
|
||||
catalog_mode = next(
|
||||
mode
|
||||
for mode in AssistantCapabilityCatalog(service).snapshot("denza_n7")["modes"]
|
||||
if mode["id"] == "cinema"
|
||||
)
|
||||
assert catalog_mode["assistant_callable"] is True
|
||||
assert catalog_mode["n7_transfer_operation_ids"] == [
|
||||
"local_scene.seat_preset"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("domain", "target_scope", "parameters"),
|
||||
[
|
||||
("media", "passenger", {"temperature_c": 24}),
|
||||
("climate", "unsupported_zone", {"temperature_c": 24}),
|
||||
("climate", "passenger", {"unsupported_parameter": 24}),
|
||||
],
|
||||
)
|
||||
def test_registered_but_invalid_action_is_not_forwarded_to_n7(
|
||||
tmp_path: Path,
|
||||
domain: str,
|
||||
target_scope: str,
|
||||
parameters: dict[str, object],
|
||||
) -> None:
|
||||
service = configured_service(tmp_path)
|
||||
invalid = scene_card()
|
||||
invalid["actions"] = [
|
||||
{
|
||||
"type": "climate_temperature",
|
||||
"at_ms": 0,
|
||||
"domain": domain,
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": target_scope,
|
||||
"operation": "set",
|
||||
"parameters": parameters,
|
||||
}
|
||||
]
|
||||
service.import_n7_mode_scene(invalid)
|
||||
profile = service.get_mode_profile_card("n7-reading", "denza_n7")
|
||||
|
||||
local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
|
||||
|
||||
assert len(local_requests) == 1
|
||||
assert transfer_actions == []
|
||||
|
||||
|
||||
def test_frontend_marks_n7_synchronized_cards() -> None:
|
||||
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
styles = (
|
||||
REPOSITORY_ROOT / "apps" / "web-console" / "styles.css"
|
||||
).read_text(encoding="utf-8")
|
||||
assert 'mode?.origin === "n7_mqtt"' in script
|
||||
assert 'sourceBadge.textContent = "N7 同步"' in script
|
||||
assert ".mode-gallery-source-badge" in styles
|
||||
|
||||
|
||||
def test_mode_profile_sync_status_exposes_catalog_and_ingress_counters(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = configured_service(tmp_path)
|
||||
service.configure_mode_scene_catalog(tmp_path / "scenes.json")
|
||||
|
||||
status = service.mode_profile_sync_status()
|
||||
|
||||
assert len(status["catalog_revision"]) == 16
|
||||
assert status["scene_count"] == 0
|
||||
assert status["catalog_state"] == "ready"
|
||||
assert status["mode_scene_ingress"]["imported_count"] == 0
|
||||
|
||||
|
||||
def test_frontend_polls_lightweight_status_and_refreshes_on_n7_import() -> None:
|
||||
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert 'apiRequest("/api/mode-profiles/status"' in script
|
||||
assert "MODE_PROFILE_SYNC_POLL_INTERVAL_MS = 1000" in script
|
||||
assert 'document.visibilityState === "hidden"' in script
|
||||
assert "if (catalogChanged || (imported && !sceneAlreadyVisible))" in script
|
||||
assert "await loadRuntime()" in script
|
||||
assert "revealModeProfile(sceneId)" in script
|
||||
assert "已从 N7 同步模式卡片" in script
|
||||
assert "N7 模式卡片导入失败" in script
|
||||
@@ -19,11 +19,14 @@ from cognitive_agent.infrastructure.can.mqtt_transport import (
|
||||
MQTT_CAN_SCHEMA,
|
||||
MQTT_EXTENSION_CONTROL_TOPIC,
|
||||
MQTT_EXTENSION_TOY_TOPIC,
|
||||
MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||
MQTT_MODE_SCENE_TOPIC,
|
||||
MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||
MQTT_PET_TOPIC,
|
||||
MqttCanTransport,
|
||||
mqtt_extension_toy_payload,
|
||||
mqtt_mode_scene_payload,
|
||||
mqtt_n7_control_transfer_payload,
|
||||
mqtt_pet_payload,
|
||||
parse_mqtt_can_payload,
|
||||
)
|
||||
@@ -739,6 +742,82 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
def test_n7_mode_scene_ingress_dispatches_raw_bytes_and_rejects_retained(
|
||||
self,
|
||||
) -> None:
|
||||
received: list[bytes] = []
|
||||
self.transport.set_mode_scene_ingress_handler(received.append)
|
||||
scene = {
|
||||
"id": "n7-reading",
|
||||
"label": "N7 阅读模式",
|
||||
"name": "N7 阅读模式",
|
||||
"background_time": "noon",
|
||||
"description": "车端同步模式。",
|
||||
"icon": "book-open",
|
||||
"aliases": ["N7 阅读模式"],
|
||||
"origin": "user",
|
||||
"deletable": True,
|
||||
"behavior": "static",
|
||||
"domains": ["interior_light"],
|
||||
"operation_count": 1,
|
||||
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||
"profile_revision": "remote-revision",
|
||||
"actions": [
|
||||
{
|
||||
"type": "interior_light_reading_power",
|
||||
"at_ms": 0,
|
||||
"domain": "interior_light",
|
||||
"capability": "interior_light.set_reading_power",
|
||||
"target_scope": "all",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.client.emit(MQTT_MODE_SCENE_INGRESS_TOPIC, scene)
|
||||
self.client.emit(MQTT_MODE_SCENE_INGRESS_TOPIC, scene, retain=True)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(json.loads(received[0]), scene)
|
||||
status = self.transport.public_info()["mode_scene_ingress"]
|
||||
self.assertTrue(status["handler_configured"])
|
||||
self.assertEqual(status["received_count"], 2)
|
||||
self.assertEqual(status["dispatched_count"], 1)
|
||||
self.assertEqual(status["rejected_count"], 1)
|
||||
self.assertEqual(status["retained_count"], 1)
|
||||
|
||||
def test_n7_control_transfer_uses_fixed_qos_one_topic_and_never_retains(
|
||||
self,
|
||||
) -> None:
|
||||
payload = {
|
||||
"scene_id": "n7-reading",
|
||||
"scene_name": "N7 阅读模式",
|
||||
"request_id": "req-20260916000000-1234567890abcdef",
|
||||
"operation_count": 1,
|
||||
"actions": [
|
||||
{
|
||||
"type": "n7_media",
|
||||
"at_ms": 0,
|
||||
"domain": "media",
|
||||
"capability": "media.play_favorite",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "play",
|
||||
"parameters": {"playlist": "focus"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
publication = self.transport.publish_n7_control_transfer(payload)
|
||||
|
||||
topic, raw_payload, qos, retain = self.client.published[-1]
|
||||
self.assertEqual(topic, MQTT_N7_CONTROL_TRANSFER_TOPIC)
|
||||
self.assertEqual(raw_payload, mqtt_n7_control_transfer_payload(payload))
|
||||
self.assertEqual(json.loads(raw_payload), payload)
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertFalse(retain)
|
||||
self.assertEqual(publication["delivery_status"], "published")
|
||||
|
||||
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
@@ -775,6 +854,7 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
[
|
||||
("n7/n7-headunit/can/state", 1),
|
||||
(MQTT_EXTENSION_CONTROL_TOPIC, 1),
|
||||
(MQTT_MODE_SCENE_INGRESS_TOPIC, 1),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
||||
assert '.v2-nfc-command-list' in styles
|
||||
assert '.v2-nfc-command-option' in styles
|
||||
assert '.v2-nfc-select-all' in styles
|
||||
assert 'self-extension.js?v=pet-settings-52' in html
|
||||
assert 'self-extension.js?v=pet-settings-53-performance' in html
|
||||
assert 'import "../self-extension.css";' in vite_entry
|
||||
|
||||
|
||||
@@ -209,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-76" in html
|
||||
assert "console-v2.js?v=local-first-77-performance" in html
|
||||
assert 'speech_semantic: "语音语义"' in console
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import start_n7web
|
||||
|
||||
|
||||
def test_tcp_port_probe_detects_an_existing_listener() -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
|
||||
assert start_n7web._tcp_port_is_listening( # noqa: SLF001
|
||||
"0.0.0.0",
|
||||
listener.getsockname()[1],
|
||||
)
|
||||
|
||||
|
||||
def test_startup_rejects_a_duplicate_service_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
args = SimpleNamespace(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
scene_bind="0.0.0.0",
|
||||
scene_port=8090,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
start_n7web,
|
||||
"_tcp_port_is_listening",
|
||||
lambda _host, port: port == 8000,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="stop the previous N7Web instance"):
|
||||
start_n7web._ensure_service_ports_available(args) # noqa: SLF001
|
||||
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
||||
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-v318-tech-nfc-routing"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v322-tech-n7-dispatch"' in service_worker
|
||||
assert "unpkg.com" not in html
|
||||
assert "cdn.jsdelivr.net" not in html
|
||||
|
||||
|
||||
Reference in New Issue
Block a user