fix: restore mode clicks and compose seat-aware assistant scenes

This commit is contained in:
vee
2026-09-12 13:33:38 +08:00
parent 76057be044
commit d09b5967f8
28 changed files with 1247 additions and 44 deletions
+178
View File
@@ -0,0 +1,178 @@
const assert = require("node:assert/strict");
const path = require("node:path");
const { chromium } = require("playwright");
const [baseURL, evidenceDir] = process.argv.slice(2);
const desktop = { width: 1600, height: 1000 };
async function main() {
const browser = await chromium.launch({
headless: true,
...(process.env.N7WEB_BROWSER_CHANNEL
? { channel: process.env.N7WEB_BROWSER_CHANNEL }
: process.platform === "win32" ? { channel: "msedge" } : {}),
});
const context = await browser.newContext({ viewport: desktop, serviceWorkers: "block" });
const page = await context.newPage();
const requests = [];
const errors = [];
const unexpectedWrites = [];
let heldResponse = null;
let nextReply = "success";
const answer = (success) => [
{ type: "action.completed", success, payload: {
status: success ? "succeeded" : "failed", execution_mode: "dry_run",
} },
{ type: "answer.completed", answer: success ? "模式执行测试完成" : "模式执行测试失败" },
].map((item) => JSON.stringify(item)).join("\n") + "\n";
const fulfill = (route, success = true) => route.fulfill({
status: 200, contentType: "application/x-ndjson", body: answer(success),
});
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ''}`);
});
async function handleApiRoute(route) {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/hardware/seat-state') {
// This legacy GET may attempt automatic firmware recovery. Never touch
// the physical gateway while verifying browser interactions.
await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}});
return;
}
// The isolated server deliberately does not launch Codex or publish a
// physical display mirror. Keep these background integration probes local.
if (pathname === '/api/health' || pathname === '/api/extension/display-sync') {
await route.fulfill({json:{status:'ok',assistant:{status:'ready',available:true}}});
return;
}
if (pathname === "/api/assistant/stream") {
requests.push(route.request().postDataJSON());
if (nextReply === "hold") { heldResponse = route; return; }
const success = nextReply !== "fail";
nextReply = "success";
await fulfill(route, success);
return;
}
if (route.request().method() !== "GET") {
unexpectedWrites.push(route.request().url());
await route.fulfill({ status: 403, body: "test forbids vehicle writes" });
return;
}
await route.continue();
}
await context.route("**/api/**", handleApiRoute);
const rail = page.locator(".mode-chips");
const office = rail.locator('button[data-mode-profile-id="office"]');
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
const waitState = (id, state) => page.waitForFunction(
({ id, state }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === state,
{ id, state },
);
async function expectOneCommand(action, command) {
const before = requests.length;
await action();
await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false');
await waitIdle();
assert.equal(requests.length, before + 1, "gesture must submit exactly once");
assert.equal(requests.at(-1).message, command);
}
try {
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3);
await page.waitForFunction(() => document.body.classList.contains('assistant-chat-enabled'));
assert.equal(await page.title(), "511工程-智能座舱");
assert.equal(new URL(page.url()).pathname, "/");
assert.ok((await page.locator('body').innerText()).includes("整车孪生界面"));
assert.ok(await rail.evaluate((element) => element.scrollWidth > element.clientWidth));
// This is a real mouse sequence: pointerdown -> pointerup -> click. The
// prior pointer capture bug cannot be detected by button.dispatchEvent().
await page.evaluate(() => window.CognitiveAgentAssistant.close());
await expectOneCommand(() => office.click(), "进入办公模式");
await waitState("office", "succeeded");
assert.equal(await page.locator('#assistantDrawerToggle').getAttribute('aria-expanded'), 'true');
assert.ok((await page.locator('#assistantMessages').innerText()).includes('进入办公模式'));
// A small physical hand movement is still a click.
const box = await office.boundingBox();
await expectOneCommand(async () => {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2);
await page.mouse.up();
}, "进入办公模式");
// Dragging from a card must scroll, without accidentally running it.
const beforeDrag = requests.length;
const startScroll = await rail.evaluate((element) => element.scrollLeft);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 90, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
assert.ok(await rail.evaluate((element) => element.scrollLeft) > startScroll);
assert.equal(requests.length, beforeDrag);
await rail.evaluate((element) => { element.scrollLeft = 0; });
await expectOneCommand(() => office.click(), "进入办公模式");
// Keyboard activation and management cards share the same submission path.
await office.focus();
await expectOneCommand(() => page.keyboard.press('Enter'), "进入办公模式");
await office.focus();
await expectOneCommand(() => page.keyboard.press('Space'), "进入办公模式");
await page.locator('#modeGalleryToggle').click();
await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "进入小憩模式");
await waitState("nap", "succeeded");
// Busy requests are not duplicated, nor does another card cancel them.
nextReply = 'hold';
const beforeBusy = requests.length;
await office.click();
await waitState('office', 'running');
await office.click();
assert.equal(requests.length, beforeBusy + 1);
assert.equal(await page.evaluate(() => window.CognitiveAgentAssistant.isPending()), true);
await fulfill(heldResponse);
nextReply = 'success';
await waitIdle();
await waitState('office', 'succeeded');
nextReply = 'fail';
await expectOneCommand(() => office.click(), "进入办公模式");
await waitState('office', 'failed');
// A catalog with few visible cards must also work without overflow.
await rail.evaluate(element => {
for (const child of element.children) {
if (child.dataset.modeProfileId !== 'office') child.style.display = 'none';
}
});
await page.waitForFunction(() => {const e=document.querySelector('.mode-chips');return e.scrollWidth <= e.clientWidth;});
await expectOneCommand(() => office.click(), "进入办公模式");
await rail.evaluate(element => {
for (const child of element.children) child.style.removeProperty('display');
});
await page.setViewportSize(desktop);
await page.screenshot({path:path.join(evidenceDir,'mode-rail-desktop.png')});
assert.deepEqual(errors, []);
assert.deepEqual(unexpectedWrites, []);
// Touch input uses native scrolling and also submits once when tapped.
const touchContext = await browser.newContext({viewport:{width:720,height:1000},hasTouch:true,serviceWorkers:'block'});
await touchContext.route('**/api/**', handleApiRoute);
const touchPage = await touchContext.newPage();
await touchPage.goto(baseURL, {waitUntil:'domcontentloaded'});
await touchPage.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3);
const beforeTouch = requests.length;
await touchPage.locator('.mode-chips button[data-mode-profile-id="office"]').tap();
await touchPage.waitForFunction(() => document.querySelector('.mode-chips [data-mode-profile-id="office"]').dataset.modeState === 'succeeded');
assert.equal(requests.length,beforeTouch+1);
assert.equal(requests.at(-1).message,'进入办公模式');
await touchPage.screenshot({path:path.join(evidenceDir,'mode-rail-touch.png')});
await touchContext.close();
console.log('PASS: click, jitter, drag, Enter, Space, gallery, busy, failure, resize, touch; no runtime errors');
} finally {
await browser.close();
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+86
View File
@@ -0,0 +1,86 @@
"""Exercise real pointer/click dispatch on the complete console in Chromium."""
from __future__ import annotations
import os
import shutil
import subprocess
import threading
from http import HTTPStatus
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
import pytest
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.auth import AuthConfig, AuthRuntime
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
def test_mode_rail_real_browser_interactions(tmp_path: Path) -> None:
node = shutil.which("node")
if not node:
pytest.skip("Node.js is required for the browser interaction regression")
env = dict(os.environ)
bundled_modules = Path(node).resolve().parent.parent / "node_modules"
if bundled_modules.is_dir():
env["NODE_PATH"] = os.pathsep.join(
item for item in (str(bundled_modules), env.get("NODE_PATH")) if item
)
available = subprocess.run(
[node, "-e", "require.resolve('playwright')"],
env=env,
capture_output=True,
encoding="utf-8",
check=False,
)
if available.returncode:
pytest.skip("Playwright is required for the real browser regression")
with patch.dict(
os.environ,
{
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
},
):
service = SeatDemoService(REPOSITORY_ROOT)
service.artifact_root = tmp_path / "artifacts"
class BrowserHandler(SeatDemoHandler):
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
def do_POST(self) -> None: # noqa: N802
# Only GET reaches this test server. Browser requests to the
# assistant are intercepted; unexpected writes fail closed.
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
def log_message(self, *_args: object) -> None:
pass
BrowserHandler.service = service
server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
completed = subprocess.run(
[
node,
str(Path(__file__).with_name("mode_rail_browser.cjs")),
f"http://127.0.0.1:{server.server_port}",
str(tmp_path),
],
env=env,
capture_output=True,
encoding="utf-8",
timeout=120,
check=False,
)
assert completed.returncode == 0, completed.stdout + completed.stderr
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
service.close()
+10 -10
View File
@@ -621,7 +621,7 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-130", timeout=3) as response:
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-131", 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
@@ -656,7 +656,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-26", timeout=3
f"{self.base_url}/assistant-chat.js?v=codex-assistant-27", timeout=3
) as response:
assistant_script = response.read().decode("utf-8")
with self.opener.open(
@@ -697,7 +697,7 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-130"', html)
self.assertIn('src="/app.js?v=dual-seat-131"', 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)
@@ -718,8 +718,8 @@ operations:
self.assertNotIn("滚动查看更多", html)
self.assertIn('aria-label="发送消息"', html)
self.assertIn('id="assistantTranscript"', html)
self.assertIn('href="/assistant-chat.css?v=codex-assistant-26"', html)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-26"', html)
self.assertIn('href="/assistant-chat.css?v=codex-assistant-27"', html)
self.assertIn('src="/assistant-chat.js?v=codex-assistant-27"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertNotIn('"admin"', console_script)
@@ -992,14 +992,14 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v277"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v278"', service_worker)
self.assertIn('/console-v2.css?v=delivery-state-66', service_worker)
self.assertIn('/console-v2.js?v=delivery-state-62', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-26', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-26', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-27', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-27', 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-130"', service_worker)
self.assertIn('"/app.js?v=dual-seat-131"', 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)
@@ -1456,7 +1456,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-130", timeout=3
f"{self.base_url}/app.js?v=dual-seat-131", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -0,0 +1,235 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.scene_composer import normalize_scene_output
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
@pytest.fixture(scope="module")
def scene_service(tmp_path_factory):
service = SeatDemoService()
service.artifact_root = tmp_path_factory.mktemp("scene-explicit-seat-scope")
service.mode_profiles = ModeProfileRegistry(
REPOSITORY_ROOT / "config" / "modes",
user_root=tmp_path_factory.mktemp("scene-explicit-seat-user-modes"),
)
try:
yield service, AssistantCapabilityCatalog(service).scene_composition_context()
finally:
service.close()
def _plan(context, scopes):
requests = []
if "driver" in scopes:
requests.append({
"capability": "seat.set_driver_massage_native",
"target_scope": "driver",
"parameters": {"action": "mode", "mode": "relax"},
})
if "passenger" in scopes:
requests.append({
"capability": "seat.set_backrest_recline",
"target_scope": "passenger",
"parameters": {"position_pct": 45},
})
requests.append({
"capability": "climate.set_temperature",
"target_scope": "vehicle",
"parameters": {"temperature_c": 24},
})
return normalize_scene_output({
"requests": requests, "ambiguities": [], "confidence": 0.95,
}, context)
def _preview(
scene_service, message, scopes=("driver", "passenger"), *, execution_mode="hardware",
):
service, context = scene_service
with patch.object(
service.hardware_execution, "reserve",
side_effect=AssertionError("scene scope validation must not execute hardware"),
):
return service.preview_native_can(
message,
provider="local",
conversation_id="conv-explicit-scene-seat-scope",
execution_mode=execution_mode,
vehicle_id="denza_n7",
assistant_plan=_plan(context, scopes),
)
@pytest.mark.parametrize("message", (
"构建一个主驾座椅舒适模式",
"创建一个仅主驾舒适模式",
"创建一个只给驾驶员坐着放松的功能模式",
"构建一个副驾座椅舒适模式",
"构建一个仅副驾舒适模式",
"构建一个右前座椅舒适模式",
"构建一个后排座椅舒适模式",
"构建一个主驾座椅与副驾空调舒适模式",
"构建一个主驾阅读灯与副驾座椅舒适模式",
))
def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_service, message):
result = _preview(scene_service, message)
assert result["status"] != "succeeded"
assert result.get("mode_profile_draft") is None
assert result["hardware_execution_allowed"] is False
assert result.get("command_plan") is None
assert result.get("command_plans") == {}
intent = result.get("intent_ir") or {}
assert (intent.get("spec") or {}).get("actions", []) == []
assert any("座位范围" in item["message"] for item in result["validation"])
@pytest.mark.parametrize(("message", "scopes"), (
("构建一个主驾座椅舒适模式", ("driver",)),
("构建一个副驾座椅舒适模式", ("passenger",)),
("创建一个仅主驾舒适模式", ("driver",)),
("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")),
("构建一个主副驾座椅舒适模式", ("driver", "passenger")),
("构建一个云端座椅舒适模式", ("driver", "passenger")),
("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")),
("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")),
("构建一个主驾座椅与副驾空调舒适模式", ("driver",)),
("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)),
))
def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_zones(
scene_service, message, scopes,
):
result = _preview(scene_service, message, scopes)
assert result["status"] == "succeeded", result.get("validation")
assert result["mode_profile_draft"]
assert result["execution_mode"] == "dry_run"
assert result["hardware_execution_allowed"] is False
actions = result["intent_ir"]["spec"]["actions"]
assert {
action["target"]["instance"]
for action in actions if action["domain"] == "seat"
} == set(scopes)
@pytest.mark.parametrize("message", (
"构建一个主驾舒适模式,副驾不要动",
"构建一个主驾舒适模式,不要动副驾",
"构建一个舒展模式,座椅靠背调整到45%",
))
def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
scene_service, message,
):
result = _preview(scene_service, message, execution_mode="dry_run")
assert result["status"] != "succeeded"
assert result.get("mode_profile_draft") is None
assert result["hardware_execution_allowed"] is False
def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service):
result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%")
assert result["status"] == "succeeded"
assert result["execution_mode"] == "dry_run"
actions = result["intent_ir"]["spec"]["actions"]
assert len(actions) == 1
assert actions[0]["capability"] == "seat.set_backrest_recline"
assert actions[0]["target"]["instance"] == "passenger"
assert actions[0]["parameters"]["position_pct"] == 45
def test_rear_window_scene_is_not_rejected_as_an_unsupported_seat(scene_service):
service, context = scene_service
plan = normalize_scene_output({
"requests": [{
"capability": "window.set_position",
"target_scope": "rear_right",
"parameters": {"position_pct": 45},
}],
"ambiguities": [], "confidence": 0.95,
}, context)
result = service.preview_native_can(
"构建一个后排换气模式", provider="local",
conversation_id="conv-rear-window-not-seat",
execution_mode="dry_run", vehicle_id="denza_n7", assistant_plan=plan,
)
assert result["status"] == "succeeded"
assert result["mode_profile_draft"]
@pytest.mark.parametrize(("text", "expected"), (
("进入主驾舒适模式", frozenset({"driver"})),
("进入仅副驾舒适模式", frozenset({"passenger"})),
("进入主副驾座椅舒适模式", frozenset({"driver", "passenger"})),
("进入主驾座椅与副驾的空调舒适模式", frozenset({"driver"})),
("进入副驾座椅与主驾的阅读灯模式", frozenset({"passenger"})),
("进入座椅舒适与右前车窗模式", None),
("进入座椅舒适与后排阅读灯模式", None),
("进入后排座椅舒适模式", frozenset({"unsupported"})),
))
def test_scene_scope_resolution_only_uses_seat_or_unqualified_scene_targets(text, expected):
assert resolve_scene_seat_scopes(text) == expected
def test_new_creation_cannot_borrow_draft_reference_to_bypass_explicit_scope(scene_service):
service, context = scene_service
built = _preview(scene_service, "构建一个主驾独享舒适模式", ("driver",))
reference = {key: built["mode_profile_draft"][key] for key in (
"name", "source_request_id", "vehicle_id",
)}
result = service.preview_native_can(
"构建一个主驾独享舒适模式", provider="local",
conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run",
vehicle_id="denza_n7", assistant_plan=_plan(context, ("driver", "passenger")),
mode_profile_draft_context=reference,
)
assert result["status"] != "succeeded"
assert not result.get("mode_profile_draft")
@pytest.mark.parametrize(("initial_scope", "name", "change", "added_capability"), (
("driver", "主驾释压舒适模式", "副驾座椅通风调整为1档", "seat.set_ventilation"),
("passenger", "副驾释压舒适模式", "主驾座椅通风调整为低档", "seat.set_driver_ventilation_native"),
))
def test_explicit_revision_can_extend_old_label_scope_then_save_and_replay(
scene_service, initial_scope, name, change, added_capability,
):
service, _context = scene_service
built = _preview(scene_service, f"构建一个{name}", (initial_scope,))
source_draft = built["mode_profile_draft"]
reference = {key: source_draft[key] for key in (
"name", "source_request_id", "vehicle_id",
)}
revised = service.preview_native_can(
f"把{name}的{change}", provider="local",
conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run",
vehicle_id="denza_n7", mode_profile_draft_context=reference,
)
assert revised["status"] == "succeeded", revised.get("validation")
draft = revised["mode_profile_draft"]
assert draft["generation_source"] == "draft_revision"
assert draft["operation_count"] == source_draft["operation_count"] + 1
actions = revised["intent_ir"]["spec"]["actions"]
assert added_capability in {action["capability"] for action in actions}
assert {
action["target"]["instance"] for action in actions if action["domain"] == "seat"
} == {"driver", "passenger"}
saved = service.create_mode_profile_card(
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
)
assert saved["status"] == "succeeded"
replayed = service.execute_mode_profile_card(
saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7",
)
assert replayed["status"] == "succeeded", replayed.get("validation")
assert {
(action["capability"], action["target"]["instance"])
for action in replayed["intent_ir"]["spec"]["actions"] if action["domain"] == "seat"
} == {
(action["capability"], action["target"]["instance"])
for action in actions if action["domain"] == "seat"
}
+113 -1
View File
@@ -12,6 +12,7 @@ import pytest
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
from cognitive_agent.application.codex_assistant import CodexAssistantService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.scene_composer import normalize_scene_output
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
@@ -94,6 +95,7 @@ def _assistant_preview(
client.available = False
assistant = CodexAssistantService(client)
snapshot = catalog.snapshot(vehicle_id="denza_n7")
fast_answer = catalog.fast_answer(message, snapshot)
events: list[dict[str, object]] = []
raw_results: list[dict[str, object]] = []
passed_plans: list[dict[str, object] | None] = []
@@ -122,6 +124,7 @@ def _assistant_preview(
conversation_id=conversation_id,
principal="scene-template-hybrid-test",
history=[],
authoritative_answer=fast_answer.answer if fast_answer else None,
emit=lambda event: events.append(event) or True,
execute_control=execute_control,
capability_snapshot=snapshot,
@@ -141,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
templates = snapshot["scene_templates"]
assert isinstance(templates, list)
assert len(templates) == 12
assert len(templates) == 13
assert all(template["assistant_callable"] is True for template in templates)
with patch.object(
@@ -339,6 +342,115 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults(
client.start_turn.assert_not_called()
@pytest.mark.parametrize(
("message", "expected_name", "template_id"),
(
("构建一个座椅舒适的功能模式", "座椅舒适模式", "seat-comfort"),
("构建一个让人坐着放松的功能模式", "坐着放松模式", "relaxation"),
("请帮我创建一个适合乘坐舒适的模式吧", "乘坐舒适模式", "seat-comfort"),
("帮我构建一个让我们坐着舒服的功能模式", "坐着舒服模式", "seat-comfort"),
("创建一个座椅放松模式并预览", "座椅放松模式", "relaxation"),
("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"),
),
)
def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
message: str,
expected_name: str,
template_id: str,
) -> None:
service, catalog, user_root = hybrid_service
with patch.object(
service.hardware_execution,
"reserve",
side_effect=AssertionError("seat comfort preview reached hardware execution"),
):
response, result, _events, client, _plans = _assistant_preview(
service, catalog, message, conversation_id="conv-seat-comfort-preview",
)
assert response["route"] == "scene_template"
assert response["success"] is True
assert result["status"] == "succeeded"
assert result["execution_mode"] == "dry_run"
assert result["hardware_execution_allowed"] is False
assert result["mode_profile_draft"]["name"] == expected_name
assert result["mode_profile_draft"]["source_template_id"] == template_id
seat_actions = {
(capability, target): parameters
for (capability, target), parameters in _action_map(result).items()
if capability.startswith("seat.")
}
assert {target for _capability, target in seat_actions} == {"driver", "passenger"}
assert all(
"driver_axis" not in capability and "driver_position" not in capability
for capability, _target in seat_actions
)
assert ("seat.set_driver_massage_native", "passenger") not in seat_actions
if template_id in {"seat-comfort", "long-trip-comfort"}:
assert seat_actions["seat.set_driver_ventilation_native", "driver"] == {
"state": "on", "level": 2,
}
assert seat_actions["seat.set_ventilation", "passenger"] == {
"state": "on", "level": 1,
}
client.start_structured_turn.assert_not_called()
client.start_turn.assert_not_called()
assert not list(user_root.rglob("*.yaml"))
@pytest.mark.parametrize("name", ("云端座椅舒展模式", "晚间乘坐释压模式"))
def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merge(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
name: str,
) -> None:
service, catalog, user_root = hybrid_service
plan = normalize_scene_output({
"requests": [
{"capability": "seat.set_driver_massage_native", "target_scope": "driver",
"parameters": {"action": "mode", "mode": "relax"}},
{"capability": "seat.set_driver_heating_native", "target_scope": "driver",
"parameters": {"state": "on", "level": 2}},
{"capability": "seat.set_backrest_recline", "target_scope": "passenger",
"parameters": {"position_pct": 45}},
{"capability": "seat.set_heating", "target_scope": "passenger",
"parameters": {"state": "on", "level": 1}},
],
"ambiguities": [], "confidence": 0.95,
}, catalog.scene_composition_context())
with patch.object(
service.hardware_execution, "reserve",
side_effect=AssertionError("scene composition reached hardware execution"),
):
result = service.preview_native_can(
f"构建一个{name}", provider="local", conversation_id="conv-seat-generated",
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
)
assert result["status"] == "succeeded"
assert result["execution_mode"] == "dry_run"
assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition"
assert set(_action_map(result)) == {
("seat.set_driver_massage_native", "driver"),
("seat.set_driver_heating_native", "driver"),
("seat.set_backrest_recline", "passenger"),
("seat.set_heating", "passenger"),
}
assert not list(user_root.rglob("*.yaml"))
def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None:
service, _catalog, _user_root = hybrid_service
result = service.preview_native_can(
"构建一个舒展模式,座椅靠背调整到45%", provider="local",
conversation_id="conv-seat-explicit-target", execution_mode="hardware",
vehicle_id="denza_n7",
)
assert result["status"] != "succeeded"
assert result.get("mode_profile_draft") is None
assert result["hardware_execution_allowed"] is False
def test_template_metadata_cannot_authorize_modified_or_stale_template_actions(
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
) -> None:
@@ -358,7 +358,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries(
templates = snapshot["scene_templates"]
assert snapshot["source_status"]["scene_templates"] == "ready"
assert len(templates) == 12
assert len(templates) == 13
assert {item["name"] for item in templates} >= {
"观影模式",
"露营模式",
@@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface():
assert 'aria-label="AI助手对话"' in html
assert 'placeholder="向 AI助手提问或交代工作"' in html
assert 'aria-label="发送消息"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-26"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-26"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-27"' in html
assert 'src="/assistant-chat.js?v=codex-assistant-27"' 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-v277"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-26"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-26"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-27"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-27"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -0,0 +1,177 @@
"""Execute the mode preview formatter against actual seat/light parameter shapes."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture(scope="module")
def render_rows():
node = shutil.which("node")
if not node:
runtime = (
Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime"
/ "dependencies" / "node" / "bin"
)
node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None)
assert node, "Node.js is required to validate rendered mode action text"
source = (ROOT / "apps" / "web-console" / "assistant-chat.js").read_text(encoding="utf-8")
start = source.index(" function modeDraftRows(")
end = source.index("\n function ", start + 1)
script = source[start:end] + "\nconst input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));\n"
script += "console.log(JSON.stringify(modeDraftRows(input.draft, input.actions)));"
def render(actions, instruction=""):
completed = subprocess.run(
[node, "-e", script],
input=json.dumps({"draft": {"instruction": instruction}, "actions": actions}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(completed.stdout)
return render
def action(capability, parameters, zone="driver"):
return {
"domain": capability.split(".")[0],
"capability": capability,
"target": {"zone": zone},
"parameters": parameters,
}
@pytest.mark.parametrize("kind", ["ventilation", "heating"])
@pytest.mark.parametrize(
("parameters", "expected"),
[
({"state": "on", "level": 1}, "开启 · 高档"),
({"state": "on", "level": 2}, "开启 · 低档"),
({"state": "on"}, "开启 · 低档"),
({"state": "off", "level": 1}, "关闭"),
],
)
def test_driver_native_comfort_uses_its_own_level_scale(render_rows, kind, parameters, expected):
rows = render_rows([action(f"seat.set_driver_{kind}_native", parameters, "front_left")])
assert rows[0]["label"] == f"主驾座椅{'通风' if kind == 'ventilation' else '加热'}"
assert rows[0]["value"] == expected
@pytest.mark.parametrize("kind", ["ventilation", "heating"])
@pytest.mark.parametrize("level", [1, 2, 3, 4, 5])
def test_passenger_levels_are_not_translated_using_driver_can_codes(render_rows, kind, level):
rows = render_rows([action(f"seat.set_{kind}", {"state": "on", "level": level}, "passenger")])
assert rows[0]["label"] == f"副驾座椅{'通风' if kind == 'ventilation' else '加热'}"
assert rows[0]["value"] == f"开启 · {level}档"
@pytest.mark.parametrize(
("parameters", "expected"),
[
({"action": "off", "mode": "relax"}, "关闭"),
({"action": "mode", "mode": "wave"}, "波浪模式"),
({"action": "mode", "mode": "pulse"}, "脉冲模式"),
({"action": "mode", "mode": "stretch"}, "舒展模式"),
({"action": "mode", "mode": "slack"}, "舒缓模式"),
({"action": "mode", "mode": "relax"}, "放松模式"),
({"action": "strength", "strength": 1}, "轻柔力度"),
({"action": "strength", "strength": 2}, "中等力度"),
({"action": "strength", "strength": 3}, "较强力度"),
({"action": "mode"}, "按摩模式待确认"),
({"action": "strength"}, "按摩力度待确认"),
],
)
def test_massage_preview_distinguishes_operation_mode_and_strength(render_rows, parameters, expected):
rows = render_rows([action("seat.set_driver_massage_native", parameters)])
assert rows[0]["label"] == "主驾座椅按摩"
assert rows[0]["value"] == expected
def test_ambient_native_color_and_brightness_remain_distinct_rows(render_rows):
rows = render_rows([
action("ambient_light.set_color_index", {"color_index": 127}, "vehicle"),
action("ambient_light.set_brightness_level", {"brightness_level": 2}, "vehicle"),
])
assert [(row["label"], row["value"]) for row in rows] == [
("氛围灯颜色", "色号 127"),
("氛围灯亮度", "2档"),
]
@pytest.mark.parametrize("brightness", [0, 20, 100])
def test_rgb_illumination_keeps_exact_color_and_brightness(render_rows, brightness):
rows = render_rows([
action("ambient_light.set_illumination", {"color_hex": "#FFB347", "brightness_pct": brightness}, "vehicle"),
])
assert rows[0]["value"] == f"颜色 #FFB347 · 亮度 {brightness}%"
def test_zero_gravity_grouping_keeps_driver_comfort_visible(render_rows):
rows = render_rows([
action("seat.set_longitudinal_position", {"position_pct": 100}, "passenger"),
action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "front_right"),
action("seat.set_driver_massage_native", {"action": "mode", "mode": "relax"}),
], "副驾零重力模式,并开启主驾放松按摩")
assert [(row["label"], row["value"]) for row in rows] == [
("副驾座椅", "零重力模式"),
("主驾座椅按摩", "放松模式"),
]
def test_position_and_power_rows_keep_existing_values(render_rows):
rows = render_rows([
action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "passenger"),
action("ambient_light.set_power", {"state": "off"}, "vehicle"),
action("seat.set_ventilation", {"state": "off", "level": 2}, "passenger"),
])
assert [(row["label"], row["value"]) for row in rows] == [
("副驾靠背角度", "81%(150°)"),
("氛围灯", "关闭"),
("副驾座椅通风", "关闭"),
]
@pytest.mark.parametrize(
("capability", "mode", "label", "value"),
[
("climate.set_control_mode", "auto", "空调控制模式", "自动控制"),
("climate.set_control_mode", "manual", "空调控制模式", "手动控制"),
("climate.set_cycle_mode", "internal", "空调循环", "内循环"),
("climate.set_cycle_mode", "external", "空调循环", "外循环"),
("climate.set_outlet_mode", "face", "空调出风模式", "吹面"),
("climate.set_outlet_mode", "face_feet", "空调出风模式", "吹面 / 吹脚"),
("climate.set_outlet_mode", "feet", "空调出风模式", "吹脚"),
("climate.set_outlet_mode", "feet_defrost", "空调出风模式", "吹脚 / 除霜"),
("climate.set_outlet_mode", "defrost", "空调出风模式", "除霜"),
("climate.set_outlet_mode", "face_feet_defrost", "空调出风模式", "吹面 / 吹脚 / 除霜"),
("climate.set_outlet_mode", "face_defrost", "空调出风模式", "吹面 / 除霜"),
],
)
def test_climate_modes_use_capability_specific_labels(render_rows, capability, mode, label, value):
rows = render_rows([action(capability, {"mode": mode}, "vehicle")])
assert (rows[0]["label"], rows[0]["value"]) == (label, value)
def test_climate_control_circulation_and_outlet_are_not_conflated(render_rows):
rows = render_rows([
action("climate.set_control_mode", {"mode": "auto"}, "vehicle"),
action("climate.set_cycle_mode", {"mode": "internal"}, "vehicle"),
action("climate.set_outlet_mode", {"mode": "face_feet"}, "vehicle"),
action("climate.set_fan_level", {"level": 2}, "vehicle"),
])
assert [(row["label"], row["value"]) for row in rows] == [
("空调控制模式", "自动控制"),
("空调循环", "内循环"),
("空调出风模式", "吹面 / 吹脚"),
("空调风量", "2档"),
]
@@ -35,10 +35,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
assert html.index("/assistant-chat.js?v=codex-assistant-26") < html.index(
assert html.index("/assistant-chat.js?v=codex-assistant-27") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+1 -1
View File
@@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
+41
View File
@@ -5,6 +5,8 @@ import shutil
import subprocess
from pathlib import Path
import pytest
from cognitive_agent.application.mode_profile import ModeProfileRegistry
ROOT = Path(__file__).resolve().parents[2]
@@ -285,6 +287,7 @@ const context = {
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); },
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
hideModeActionPopover() {},
showToast(message) { calls.push(["toast", message]); },
$(selector) {
return { "#utterance": input, "#requestForm": form, "#runButton": runButton }[selector];
@@ -355,6 +358,44 @@ console.log(JSON.stringify({ result: context.result, calls }));
}
@pytest.mark.parametrize(("phase", "input_mode"), (
("capturing", "manual"),
("capturing", "wake"),
("finalizing", "manual"),
("finalizing", "wake"),
("arming", "manual"),
))
def test_mode_click_during_dictation_does_not_create_stuck_running_state(
phase: str, input_mode: str,
) -> None:
app = (WEB / "app.js").read_text(encoding="utf-8")
start = app.index("function applyModeCommand")
end = app.index("\n\nfunction applyRuntimeTargetPolicy", start)
script = "const snippet = " + json.dumps(app[start:end]) + ";\n"
script += "const speech = " + json.dumps({"phase": phase, "inputMode": input_mode}) + ";\n"
script += r"""
const vm = require("node:vm");
const calls = [];
const context = {
window: {
CognitiveAgentAssistant: { isPending() { return false; } },
CognitiveAgentSpeech: speech,
},
showToast(message) { calls.push(message); },
};
vm.createContext(context);
vm.runInContext(snippet + '\nthis.result = applyModeCommand("进入办公模式", "office");', context);
console.log(JSON.stringify({ result: context.result, calls }));
"""
completed = subprocess.run(
[node_runtime(), "-e", script], check=True, capture_output=True, encoding="utf-8",
)
assert json.loads(completed.stdout) == {
"result": False,
"calls": ["请等待语音整理完成后再发送"],
}
def test_top_and_management_mode_cards_share_the_assistant_submission_entrypoint() -> None:
app = (WEB / "app.js").read_text(encoding="utf-8")
install_start = app.index('document.addEventListener("DOMContentLoaded"')
@@ -36,6 +36,9 @@ EXPECTED_SCENE_CAPABILITIES = {
"seat.set_legrest_height",
"seat.set_longitudinal_position",
"seat.set_ventilation",
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
"window.set_position",
}
@@ -15,6 +15,8 @@ ROOT = Path(__file__).resolve().parents[2]
(
("帮我构建一个适合看书的功能模式", ("看书模式", "")),
("构建一个适合放松的功能模式", ("放松模式", "")),
("构建一个座椅舒适的功能模式", ("座椅舒适模式", "")),
("构建一个让人坐着放松的功能模式", ("坐着放松模式", "")),
("请帮我创建适合阅读的模式吧", ("阅读模式", "")),
("我想生成一个用来读书的模式并预览", ("读书模式", "")),
("请生成用于放松的模式,先看看", ("放松模式", "")),
@@ -67,6 +69,8 @@ def test_explicit_names_are_never_rewritten(
("帮我构建一个适合看书的功能模式", "reading"),
("请创建一个用于阅读的功能模式", "reading"),
("构建一个适合放松的功能模式", "relaxation"),
("构建一个座椅舒适的功能模式", "seat-comfort"),
("构建一个让人坐着放松的功能模式", "relaxation"),
("我想创建一套用来舒缓的模式", "relaxation"),
("帮我构建一个适合看电影的功能模式", "cinema"),
),
@@ -0,0 +1,114 @@
from __future__ import annotations
from copy import deepcopy
import pytest
from jsonschema import Draft202012Validator
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
from cognitive_agent.application.mode_profile_intent import parse_mode_profile_create
from cognitive_agent.application.scene_composer import build_scene_output_schema
from cognitive_agent.application.seat_service import SeatDemoService
@pytest.fixture(scope="module")
def catalog():
service = SeatDemoService()
try:
yield AssistantCapabilityCatalog(service)
finally:
service.close()
@pytest.mark.parametrize("message", (
"构建一个座椅舒适的功能模式",
"构建一个让人坐着放松的功能模式",
"可以帮我创建一个适合座椅放松的功能模式吗?",
"构建一个主驾按摩的功能模式",
"创建一个空调舒适的功能模式",
"构建一个适合看书的功能模式,阅读灯打开",
))
def test_mode_creation_is_not_claimed_by_capability_fast_answer(catalog, message):
assert parse_mode_profile_create(message) is not None
assert catalog.fast_answer(message) is None
@pytest.mark.parametrize("message", (
"支持主驾座椅按摩吗?",
"主驾与副驾座椅有哪些功能?",
"座椅通风支持哪些档位?",
"副驾座椅支持按摩吗?",
))
def test_genuine_seat_questions_remain_read_only_capability_answers(catalog, message):
assert parse_mode_profile_create(message) is None
assert catalog.fast_answer(message) is not None
@pytest.mark.parametrize("message", (
"不要构建座椅舒适模式",
"构建一个座椅舒适模式,但不要执行",
"取消创建坐着放松模式",
"如何构建一个座椅舒适模式?",
"解释构建一个座椅舒适的功能模式这句话",
))
def test_negations_and_read_only_seat_mode_requests_never_authorize_creation(message):
assert parse_mode_profile_create(message) is None
def _response(capability, target, parameters):
return {
"requests": [{"capability": capability, "target_scope": target, "parameters": parameters}],
"ambiguities": [],
"confidence": 0.95,
}
@pytest.mark.parametrize(("capability", "target", "parameters", "valid"), (
("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 2}, True),
("seat.set_driver_heating_native", "driver", {"state": "on", "level": 1}, True),
("seat.set_driver_heating_native", "front_left", {"state": "off"}, True),
("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax"}, True),
("seat.set_driver_massage_native", "driver", {"action": "strength", "strength": 1}, True),
("seat.set_driver_massage_native", "driver", {"action": "off"}, True),
("seat.set_driver_massage_native", "passenger", {"action": "mode", "mode": "relax"}, False),
("seat.set_driver_massage_native", "driver", {"action": "mode"}, False),
("seat.set_driver_massage_native", "driver", {"action": "mode", "strength": 1}, False),
("seat.set_driver_massage_native", "driver", {"action": "off", "mode": "relax"}, False),
("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax", "strength": 1}, False),
("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "unknown"}, False),
("seat.set_driver_heating_native", "passenger", {"state": "on", "level": 2}, False),
("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 3}, False),
("seat.set_driver_axis_native", "driver", {"axis": "backrest", "direction": "backward", "duration_ms": 500}, False),
("seat.set_driver_position_native", "driver", {"slot": 1}, False),
("seat.set_backrest_recline", "driver", {"position_pct": 45}, False),
))
def test_scene_schema_respects_real_driver_and_passenger_capabilities(
catalog, capability, target, parameters, valid,
):
context = catalog.scene_composition_context()
validator = Draft202012Validator(build_scene_output_schema(context))
assert validator.is_valid(_response(capability, target, parameters)) is valid
def test_scene_context_removes_driver_capabilities_when_runtime_marks_them_unavailable(catalog):
snapshot = deepcopy(catalog.snapshot())
for capability in snapshot["capabilities"]:
if capability["id"].startswith("seat.set_driver_"):
capability["assistant_callable"] = False
context = catalog.scene_composition_context(snapshot)
assert not any(item["id"].startswith("seat.set_driver_") for item in context["capabilities"])
validator = Draft202012Validator(build_scene_output_schema(context))
assert not validator.is_valid(_response(
"seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax"},
))
def test_seat_comfort_detail_answer_uses_seat_specific_natural_language(catalog):
answer = catalog.fast_answer("座椅舒适模式会控制哪些设备?")
assert answer is not None
assert "主驾座椅通风开启低档" in answer.answer
assert "主驾座椅按摩设为放松模式" in answer.answer
assert "副驾座椅通风" in answer.answer
assert "副驾座椅靠背" in answer.answer
assert "action=" not in answer.answer
assert "主驾主驾" not in answer.answer