feat: add seat-scoped static and dynamic assistant modes
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const [baseURL, evidenceDir] = process.argv.slice(2);
|
||||
|
||||
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: { width: 1600, height: 1000 }, serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const posted = [];
|
||||
const responses = [];
|
||||
let mockedExecution = null;
|
||||
let statusReads = 0;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`);
|
||||
});
|
||||
await context.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) {
|
||||
await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } });
|
||||
return;
|
||||
}
|
||||
if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() });
|
||||
if (mockedExecution && pathname === "/api/assistant/stream") {
|
||||
await route.fulfill({ contentType: "application/x-ndjson", body: [
|
||||
{ type: "action.completed", success: true, payload: mockedExecution },
|
||||
{ type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" },
|
||||
].map((event) => JSON.stringify(event)).join("\n") + "\n" });
|
||||
return;
|
||||
}
|
||||
if (mockedExecution && pathname === "/api/modes/schedule/status") {
|
||||
statusReads += 1;
|
||||
assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7");
|
||||
await route.fulfill({ json: {
|
||||
...mockedExecution.schedule_execution, vehicle_id: "denza_n7",
|
||||
phase_index: 1, repeat_index: 0, completed: false,
|
||||
status: statusReads === 1 ? "running" : "failed",
|
||||
error: statusReads === 1 ? null : "QA阶段模拟失败",
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) {
|
||||
// Chromium can discard completed streaming response bodies from CDP.
|
||||
// Fetch the real isolated endpoint and retain its exact response bytes.
|
||||
const response = await route.fetch();
|
||||
const text = await response.text();
|
||||
responses.push({ pathname, text, status: response.status() });
|
||||
await route.fulfill({ response, body: text });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
|
||||
const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname);
|
||||
try {
|
||||
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled")
|
||||
&& document.querySelectorAll(".mode-chips button").length > 3);
|
||||
assert.equal(await page.title(), "511工程-智能座舱");
|
||||
assert.equal(new URL(page.url()).origin, baseURL);
|
||||
assert.match(await page.locator("body").innerText(), /整车孪生界面/);
|
||||
if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) {
|
||||
await page.locator('#v2SidebarToggle').click();
|
||||
}
|
||||
await page.locator('[data-execution-mode="dry_run"]').click();
|
||||
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
|
||||
|
||||
// Exercise the same real assistant endpoint and rendered cards for both
|
||||
// implicit passenger selection and an explicit driver request. These
|
||||
// previews stay unsaved, so the timeline save/trial tests remain isolated.
|
||||
for (const [index, testCase] of [
|
||||
{ text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" },
|
||||
{ text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" },
|
||||
{ text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" },
|
||||
].entries()) {
|
||||
const responsePromise = responseFor("/api/assistant/stream");
|
||||
await page.locator("#utterance").fill(testCase.text);
|
||||
await page.locator("#utterance").press("Enter");
|
||||
const response = await responsePromise;
|
||||
assert.equal(response.status(), 200);
|
||||
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1)
|
||||
.text.trim().split("\n").map((line) => JSON.parse(line));
|
||||
await waitIdle();
|
||||
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
|
||||
assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`);
|
||||
const draft = payload.mode_profile_draft;
|
||||
const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions;
|
||||
const seats = actions.filter((action) => action.capability?.startsWith("seat."))
|
||||
.map((action) => action.target_scope || action.target?.instance || action.target?.zone);
|
||||
assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`);
|
||||
assert.deepEqual([...new Set(seats)], [testCase.seat]);
|
||||
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
|
||||
await card.waitFor({ state: "visible" });
|
||||
const actionText = await card.locator(".assistant-mode-draft-row").allTextContents();
|
||||
assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n"));
|
||||
assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n"));
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) });
|
||||
}
|
||||
|
||||
const savedModes = [];
|
||||
for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) {
|
||||
const responsePromise = responseFor("/api/assistant/stream");
|
||||
await page.locator("#utterance").fill(text);
|
||||
await page.locator("#utterance").press("Enter");
|
||||
const response = await responsePromise;
|
||||
assert.equal(response.status(), 200);
|
||||
const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line));
|
||||
await waitIdle();
|
||||
const draftEvent = events.find((event) => event.payload?.mode_profile_draft);
|
||||
assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`);
|
||||
const draft = draftEvent.payload.mode_profile_draft;
|
||||
assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1");
|
||||
assert.ok(draft.schedule.phases.length >= 2);
|
||||
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
|
||||
assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length);
|
||||
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
|
||||
assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/);
|
||||
assert.match(await card.innerText(), /停止动态模式/);
|
||||
if (index === 0) {
|
||||
assert.match(await card.innerText(), /副驾/);
|
||||
assert.doesNotMatch(await card.innerText(), /主驾/);
|
||||
} else {
|
||||
const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations)
|
||||
.filter((operation) => operation.capability === "climate.set_temperature")
|
||||
.map((operation) => operation.parameters.temperature_c);
|
||||
assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time");
|
||||
for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`));
|
||||
}
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) });
|
||||
|
||||
const trialPromise = responseFor("/api/mode-profile-drafts/trial");
|
||||
await card.locator(".assistant-mode-draft-trial").click();
|
||||
const trialResponse = await trialPromise;
|
||||
const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text);
|
||||
assert.equal(trialResponse.status(), 200, JSON.stringify(trial));
|
||||
assert.equal(trial.status, "succeeded", JSON.stringify(trial));
|
||||
assert.equal(trial.schedule_execution?.status, "completed");
|
||||
await page.waitForFunction((requestId) => {
|
||||
const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`);
|
||||
return status?.classList.contains("success") || status?.classList.contains("failed");
|
||||
}, draft.source_request_id);
|
||||
assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/);
|
||||
|
||||
const savePromise = responseFor("/api/mode-profiles");
|
||||
await card.locator(".assistant-mode-draft-save").click();
|
||||
const saveResponse = await savePromise;
|
||||
const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text);
|
||||
assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved));
|
||||
assert.ok(saved.mode_profile?.id);
|
||||
savedModes.push(saved.mode_profile);
|
||||
await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" });
|
||||
const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`);
|
||||
await button.waitFor({ state: "visible" });
|
||||
// Saving reveals the card with smooth scrolling. A tooltip correctly
|
||||
// closes while its rail is still moving, so wait for that motion to end.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
const rail = document.querySelector('.mode-chips');
|
||||
let previous = rail.scrollLeft;
|
||||
let stable = 0;
|
||||
const sample = () => {
|
||||
const current = rail.scrollLeft;
|
||||
stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0;
|
||||
previous = current;
|
||||
if (stable >= 12) resolve();
|
||||
else requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}));
|
||||
await page.mouse.move(1, 1);
|
||||
const buttonBox = await button.boundingBox();
|
||||
const railBox = await page.locator('.mode-chips').boundingBox();
|
||||
const visibleLeft = Math.max(buttonBox.x, railBox.x);
|
||||
const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width);
|
||||
assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail');
|
||||
// A native mouse move does not request another smooth scroll, unlike
|
||||
// locator.hover() which scrolls the target to the center first.
|
||||
await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2);
|
||||
const tooltip = page.locator("#modeChipActionPopover");
|
||||
await tooltip.waitFor({ state: "visible" });
|
||||
assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/);
|
||||
assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count);
|
||||
for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name));
|
||||
await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 });
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
|
||||
await page.locator("#modeGalleryToggle").click();
|
||||
for (const mode of savedModes) {
|
||||
const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) });
|
||||
const details = shell.locator(".mode-gallery-schedule-details");
|
||||
await details.locator("summary").click();
|
||||
assert.match(await details.innerText(), /开始 · 持续/);
|
||||
assert.match(await details.innerText(), /不自动回滚/);
|
||||
}
|
||||
await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; });
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") });
|
||||
await page.locator("#modeGalleryClose").click();
|
||||
await page.setViewportSize({ width: 720, height: 1000 });
|
||||
if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') {
|
||||
await page.locator('#v2SidebarToggle').click();
|
||||
}
|
||||
await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") });
|
||||
assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false);
|
||||
|
||||
// The backend sequence above is real and strictly dry-run. Separately
|
||||
// verify the asynchronous UI contract with a synthetic running/failed
|
||||
// response; it must never launch an actual timed hardware execution.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const mode = savedModes[1];
|
||||
mockedExecution = {
|
||||
request_id: "browser-running-qa", status: "running", execution_mode: "dry_run",
|
||||
orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode,
|
||||
schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" },
|
||||
};
|
||||
const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count();
|
||||
await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click();
|
||||
await waitIdle();
|
||||
await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id);
|
||||
const actionStatus = page.locator('.assistant-action-status').last();
|
||||
assert.match(await actionStatus.innerText(), /已启动动态时间轴/);
|
||||
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2'));
|
||||
await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败'));
|
||||
assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed');
|
||||
assert.equal(statusReads, 2);
|
||||
assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2);
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run"));
|
||||
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2);
|
||||
assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2);
|
||||
console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`);
|
||||
console.log(`Evidence: ${evidenceDir}`);
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") });
|
||||
console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Full browser -> deterministic assistant -> schedule trial/save, without hardware."""
|
||||
|
||||
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 MagicMock, patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||
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_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None:
|
||||
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)
|
||||
if not node:
|
||||
pytest.skip("Node.js is required for the schedule browser regression")
|
||||
env = dict(os.environ)
|
||||
modules = Path(node).resolve().parent.parent / "node_modules"
|
||||
if modules.is_dir():
|
||||
env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value)
|
||||
probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False)
|
||||
if probe.returncode:
|
||||
pytest.skip("Playwright is required for the schedule 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",
|
||||
"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
|
||||
"COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"),
|
||||
}):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
# A failed deterministic route must fail visibly, never launch a model or
|
||||
# make network calls to an external assistant during the browser test.
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
client.status = "test-offline"
|
||||
assistant = CodexAssistantService(client, fast_implicit_controls=True)
|
||||
forbidden = []
|
||||
|
||||
class BrowserHandler(SeatDemoHandler):
|
||||
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
|
||||
# Both legacy GET paths can touch physical adapters or firmware.
|
||||
self._json({"status": "unavailable", "hardware_execution_allowed": False})
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._json({"status": "ok", "assistant": {"status": "ready", "available": True}})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/extension/display-sync":
|
||||
self._json({"status": "ok"})
|
||||
return
|
||||
if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}:
|
||||
forbidden.append(path)
|
||||
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
super().do_POST()
|
||||
|
||||
def _read_json_body(self, **kwargs):
|
||||
payload = super()._read_json_body(**kwargs)
|
||||
if payload.get("execution_mode", "dry_run") != "dry_run":
|
||||
forbidden.append(f"execution_mode:{payload.get('execution_mode')}")
|
||||
raise ValueError("Browser regression permits dry_run only")
|
||||
return payload
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
BrowserHandler.service = service
|
||||
BrowserHandler.assistant = assistant
|
||||
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_schedule_browser.cjs")),
|
||||
f"http://127.0.0.1:{server.server_port}", str(tmp_path),
|
||||
], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False)
|
||||
assert completed.returncode == 0, completed.stdout + completed.stderr
|
||||
assert not forbidden, forbidden
|
||||
assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
assistant.close()
|
||||
service.close()
|
||||
@@ -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-131", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-132", 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-27", timeout=3
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-28", 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-131"', html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-132"', 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-27"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-27"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-28"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-28"', 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-v278"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v279"', 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-27', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-27', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-28', 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-131"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-132"', 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-131", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-132", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
|
||||
@@ -715,9 +715,16 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
|
||||
|
||||
for mode_index, mode in enumerate(modes):
|
||||
assert isinstance(mode, Mapping)
|
||||
profile = vehicle_service.mode_profiles.get_profile(str(mode["id"]), "denza_n7")
|
||||
has_driver = any(
|
||||
operation.domain == "seat" and operation.target_scope in {"driver", "front_left"}
|
||||
for operation in profile.operations
|
||||
)
|
||||
for scope_index, scope in enumerate(
|
||||
("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅")
|
||||
):
|
||||
if has_driver and scope in {"主驾", "驾驶员"}:
|
||||
continue # Valid dual-seat subsets have a separate exact-action matrix.
|
||||
message = f"请让{scope}进入{mode['name']}"
|
||||
result = vehicle_service.preview_native_can(
|
||||
message,
|
||||
@@ -754,7 +761,7 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes(
|
||||
def test_preview_and_process_share_mode_target_safety_gate(
|
||||
vehicle_service: SeatDemoService,
|
||||
) -> None:
|
||||
message = "主驾开启小憩模式"
|
||||
message = "主驾开启零重力模式"
|
||||
direct = vehicle_service.process(
|
||||
message,
|
||||
provider="local",
|
||||
|
||||
@@ -754,7 +754,11 @@ def test_incomplete_ambiguous_or_unsupported_scene_never_creates_partial_draft(
|
||||
|
||||
assert result["status"] != "succeeded"
|
||||
assert result.get("mode_profile_draft") is None
|
||||
assert result.get("orchestration_route") != "mode_profile_create_preview"
|
||||
# Seat policy can reject on the creation route before compiling any action.
|
||||
assert not (result.get("intent_ir") or {}).get("spec", {}).get("actions")
|
||||
assert not result.get("actions")
|
||||
assert result.get("command_plan") is None
|
||||
assert not result.get("command_plans")
|
||||
assert result.get("validation")
|
||||
failure = result.get("mode_creation_failure")
|
||||
assert isinstance(failure, dict)
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Creation selects one seat by default without remapping incompatible actuators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, 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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy_service(tmp_path, monkeypatch):
|
||||
for variable in (
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||||
):
|
||||
monkeypatch.setenv(variable, "false")
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
user_root = tmp_path / "user-modes"
|
||||
service.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes", user_root=user_root,
|
||||
)
|
||||
try:
|
||||
with (
|
||||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical CAN forbidden")) as reserve,
|
||||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT forbidden")) as mqtt,
|
||||
):
|
||||
yield service, AssistantCapabilityCatalog(service), user_root
|
||||
reserve.assert_not_called()
|
||||
mqtt.assert_not_called()
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def _actions(result):
|
||||
return (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
|
||||
|
||||
|
||||
def _seat_scopes(result):
|
||||
return {
|
||||
action["target"]["instance"] for action in _actions(result)
|
||||
if action["domain"] == "seat"
|
||||
}
|
||||
|
||||
|
||||
def _operation_key(operation):
|
||||
target = operation.get("target_scope")
|
||||
if target is None:
|
||||
raw_target = operation.get("target") or {}
|
||||
target = raw_target.get("instance") or raw_target.get("zone")
|
||||
return (
|
||||
operation["capability"], target,
|
||||
json.dumps(operation["parameters"], ensure_ascii=False, sort_keys=True),
|
||||
)
|
||||
|
||||
|
||||
def _preview(service, message, *, plan=None, draft=None):
|
||||
return service.preview_native_can(
|
||||
message, provider="local", conversation_id="conv-seat-policy",
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
assistant_plan=plan, mode_profile_draft_context=draft,
|
||||
)
|
||||
|
||||
|
||||
def _assert_created(result, scopes):
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result.get("mode_profile_draft"), result
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert _seat_scopes(result) == set(scopes)
|
||||
schedule = result.get("schedule")
|
||||
if schedule:
|
||||
for phase in schedule["phases"]:
|
||||
assert {
|
||||
op["target_scope"] for op in phase["operations"] if op["domain"] == "seat"
|
||||
} <= set(scopes)
|
||||
assert result["mode_profile_draft"]["schedule"] == schedule
|
||||
|
||||
|
||||
def _assistant_preview(service, catalog, message, *, client=None):
|
||||
if client is None:
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast = catalog.fast_answer(message, snapshot)
|
||||
results, events = [], []
|
||||
|
||||
def execute(instruction, progress, assistant_plan=None):
|
||||
result = _preview(service, instruction, plan=assistant_plan)
|
||||
results.append(result)
|
||||
return result
|
||||
|
||||
response = assistant.stream(
|
||||
message, conversation_id="conv-seat-policy", principal="seat-policy-test", history=[],
|
||||
authoritative_answer=fast.answer if fast else None,
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
assert len(results) == 1, (response, results)
|
||||
return response, results[0], client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"阅读模式", "观影模式", "办公模式", "座椅舒适模式", "放松模式", "座椅动态模式", "上车预热模式",
|
||||
])
|
||||
@pytest.mark.parametrize(("prefix", "scopes"), [
|
||||
("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
])
|
||||
def test_registered_modes_use_selected_seat_through_assistant_fast_path(policy_service, name, prefix, scopes):
|
||||
service, catalog, user_root = policy_service
|
||||
response, preview, client = _assistant_preview(service, catalog, f"构建一个{prefix}{name}")
|
||||
assert response["success"] is True, (response, preview)
|
||||
_assert_created(preview, scopes)
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), [
|
||||
("构建一个两个座椅的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个两张座椅的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个两座椅的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个前排两座的阅读模式", {"driver", "passenger"}),
|
||||
("构建一个副驾驶席阅读模式", {"passenger"}),
|
||||
("构建一个副驾驶位阅读模式", {"passenger"}),
|
||||
("构建一个副驾驶座椅阅读模式", {"passenger"}),
|
||||
("构建一个两个座椅的动态模式", {"driver", "passenger"}),
|
||||
("构建一个两张座椅的动态模式", {"driver", "passenger"}),
|
||||
("构建一个两座椅的动态模式", {"driver", "passenger"}),
|
||||
("构建一个前排两座的动态模式", {"driver", "passenger"}),
|
||||
("构建一个副驾驶座椅动态模式", {"passenger"}),
|
||||
])
|
||||
def test_registered_seat_aliases_select_correct_targets_without_model(policy_service, message, scopes):
|
||||
service, catalog, _ = policy_service
|
||||
response, preview, client = _assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (
|
||||
response["answer"], preview.get("validation"), preview.get("mode_profile_draft"),
|
||||
)
|
||||
_assert_created(preview, scopes)
|
||||
assert response["route"] == "scene_template"
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", [
|
||||
"reading", "cinema", "camping", "quiet-night", "reception", "romantic",
|
||||
"winter-warmth", "summer-cooling", "long-trip-comfort", "relaxation", "seat-comfort",
|
||||
"seat-dynamic", "boarding-warmup",
|
||||
])
|
||||
@pytest.mark.parametrize(("prefix", "scope"), [("", "passenger"), ("主驾", "driver")])
|
||||
def test_trusted_template_selection_keeps_nonseat_devices_and_native_parameters(policy_service, template_id, prefix, scope):
|
||||
service, _, _ = policy_service
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
original = deepcopy(template.plan())
|
||||
result = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
|
||||
_assert_created(result, {scope})
|
||||
raw_operations = (
|
||||
[op for phase in original["schedule"]["phases"] for op in phase["operations"]]
|
||||
if original.get("schedule") else [op.to_yaml() for op in template.operations]
|
||||
)
|
||||
expected = [
|
||||
_operation_key(op) for op in raw_operations
|
||||
if op["domain"] != "seat" or op["target_scope"] == scope
|
||||
]
|
||||
assert sorted(map(_operation_key, _actions(result))) == sorted(expected)
|
||||
assert template.plan() == original, "projecting a request must not mutate the registered template"
|
||||
|
||||
|
||||
def _model_output(scopes, *, dynamic=False):
|
||||
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},
|
||||
})
|
||||
if not dynamic:
|
||||
return {"requests": requests, "ambiguities": [], "confidence": 0.95}
|
||||
return {
|
||||
"requests": [], "ambiguities": [], "confidence": 0.95,
|
||||
"schedule": {
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "relax", "name": "舒缓", "duration_ms": 30000, "requests": deepcopy(requests)},
|
||||
{"id": "support", "name": "支撑", "duration_ms": 60000, "requests": deepcopy(requests)},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
@pytest.mark.parametrize(("prefix", "scopes"), [
|
||||
("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
])
|
||||
def test_unregistered_model_plan_preserves_correct_explicit_or_default_targets(policy_service, dynamic, prefix, scopes):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output(scopes, dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
|
||||
)
|
||||
dynamic_label = "动态" if dynamic else ""
|
||||
result = _preview(service, f"构建一个{prefix}星河释压{dynamic_label}模式", plan=plan)
|
||||
_assert_created(result, scopes)
|
||||
assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
@pytest.mark.parametrize("prefix", ["", "主驾", "副驾"])
|
||||
def test_unregistered_model_cannot_invent_cross_seat_actions(policy_service, dynamic, prefix):
|
||||
service, catalog, user_root = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output({"driver", "passenger"}, dynamic=dynamic),
|
||||
catalog.scene_composition_context(), dynamic=dynamic,
|
||||
)
|
||||
try:
|
||||
result = _preview(service, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", plan=plan)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("prefix", "requested_scope", "unexpected_scope"), [
|
||||
("", "passenger", "driver"), ("主驾", "driver", "passenger"),
|
||||
])
|
||||
def test_unexpected_seat_only_in_later_dynamic_phase_rejects_entire_draft(
|
||||
policy_service, prefix, requested_scope, unexpected_scope,
|
||||
):
|
||||
service, catalog, user_root = policy_service
|
||||
output = _model_output({requested_scope}, dynamic=True)
|
||||
output["schedule"]["phases"][1]["requests"] = _model_output({unexpected_scope})["requests"]
|
||||
plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=True)
|
||||
try:
|
||||
result = _preview(service, f"构建一个{prefix}星河释压动态模式", plan=plan)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert not result.get("command_plans")
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个办公模式,主驾座椅分两个阶段动态舒缓",
|
||||
])
|
||||
def test_existing_mode_binding_cannot_authorize_cross_seat_model_schedule(policy_service, message):
|
||||
service, catalog, user_root = policy_service
|
||||
original = service.mode_profiles.get_profile("office", "denza_n7")
|
||||
revision = service.mode_profiles.profile_revision(original)
|
||||
plan = normalize_scene_output(
|
||||
_model_output({"driver", "passenger"}, dynamic=True),
|
||||
catalog.scene_composition_context(), dynamic=True,
|
||||
)
|
||||
try:
|
||||
result = _preview(service, message, plan=plan)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded", "binding an existing mode is not authorization for model-added seats"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert not result.get("command_plans")
|
||||
assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile("office", "denza_n7")) == revision
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scope"), [
|
||||
("构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", "passenger"),
|
||||
("构建一个办公模式,主驾座椅分两个阶段动态舒缓", "driver"),
|
||||
])
|
||||
def test_existing_mode_binding_keeps_valid_selected_seat_schedule_saveable(policy_service, message, scope):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output({scope}, dynamic=True), catalog.scene_composition_context(), dynamic=True,
|
||||
)
|
||||
result = _preview(service, message, plan=plan)
|
||||
_assert_created(result, {scope})
|
||||
draft = result["mode_profile_draft"]
|
||||
assert draft["existing_mode_id"] == "office"
|
||||
assert draft["existing_mode_revision"]
|
||||
saved = service.save_mode_profile_card(
|
||||
draft["existing_mode_id"], draft["name"], draft["instruction"],
|
||||
draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
assert saved["mode_profile"]["schedule"] == result["schedule"]
|
||||
executed = service.execute_mode_profile_card(
|
||||
saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded"
|
||||
assert _seat_scopes(executed) == {scope}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
def test_default_seat_preference_does_not_force_seat_actions_into_nonseat_scene(policy_service, dynamic):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(
|
||||
_model_output(set(), dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic,
|
||||
)
|
||||
result = _preview(service, f"构建一个星河恒温{'动态' if dynamic else ''}模式", plan=plan)
|
||||
_assert_created(result, set())
|
||||
assert all(action["domain"] == "climate" for action in _actions(result))
|
||||
|
||||
|
||||
def test_explicit_atomic_mode_requirements_default_unqualified_seat_to_passenger(policy_service):
|
||||
service, _, _ = policy_service
|
||||
result = _preview(service, "构建一个自定义支撑模式,座椅靠背调整到45%,空调24度")
|
||||
_assert_created(result, {"passenger"})
|
||||
assert {_operation_key(action) for action in _actions(result)} == {
|
||||
("seat.set_backrest_recline", "passenger", '{"position_pct": 45}'),
|
||||
# Only the seat default changes; the existing local HVAC zone stays driver.
|
||||
("climate.set_temperature", "driver", '{"temperature_c": 24}'),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
def test_passenger_position_capability_is_not_retargeted_to_driver(policy_service, dynamic):
|
||||
service, catalog, _ = policy_service
|
||||
output = _model_output({"passenger"}, dynamic=dynamic)
|
||||
requests = output["schedule"]["phases"][0]["requests"] if dynamic else output["requests"]
|
||||
requests[0]["target_scope"] = "driver"
|
||||
try:
|
||||
plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=dynamic)
|
||||
result = _preview(service, f"构建一个主驾星河释压{'动态' if dynamic else ''}模式", plan=plan)
|
||||
except ValueError:
|
||||
return
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), [
|
||||
("构建一个主驾空调与座椅舒适模式", {"passenger"}),
|
||||
("构建一个主驾阅读灯与座椅舒适模式", {"passenger"}),
|
||||
("构建一个副驾空调与主驾座椅舒适模式", {"driver"}),
|
||||
("构建一个主驾阅读灯与副驾座椅舒适模式", {"passenger"}),
|
||||
])
|
||||
def test_other_device_zones_do_not_select_or_broaden_seat_scope(policy_service, message, scopes):
|
||||
service, catalog, _ = policy_service
|
||||
plan = normalize_scene_output(_model_output(scopes), catalog.scene_composition_context())
|
||||
result = _preview(service, message, plan=plan)
|
||||
_assert_created(result, scopes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("template_id", "prefix", "scope"), [
|
||||
("reading", "", "passenger"), ("reading", "主驾", "driver"),
|
||||
("seat-dynamic", "", "passenger"), ("seat-dynamic", "主驾", "driver"),
|
||||
("boarding-warmup", "", "passenger"), ("boarding-warmup", "主驾", "driver"),
|
||||
])
|
||||
def test_save_reload_and_simulated_execution_preserve_selected_seat(policy_service, template_id, prefix, scope):
|
||||
service, _, user_root = policy_service
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
preview = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan())
|
||||
_assert_created(preview, {scope})
|
||||
draft = preview["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded", saved
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
executed = service.execute_mode_profile_card(
|
||||
saved["mode_profile"]["id"], conversation_id="conv-seat-policy-replay",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded", executed.get("validation")
|
||||
assert executed["hardware_execution_allowed"] is False
|
||||
assert _seat_scopes(executed) == {scope}
|
||||
assert sorted(map(_operation_key, _actions(executed))) == sorted(map(_operation_key, _actions(preview)))
|
||||
if preview.get("schedule"):
|
||||
assert executed["schedule"] == preview["schedule"]
|
||||
assert executed["schedule_execution"]["completed"] is True
|
||||
assert all(
|
||||
_seat_scopes(phase["result"]) <= {scope}
|
||||
for phase in executed["schedule_execution"]["phase_results"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("initial_scope", "prefix"), [("driver", "主驾"), ("passenger", "")])
|
||||
def test_unrelated_draft_edit_retains_original_seat_and_explicit_edit_may_extend_it(policy_service, initial_scope, prefix):
|
||||
service, catalog, _ = policy_service
|
||||
name = f"{prefix}星河释压模式"
|
||||
built = _preview(
|
||||
service, f"构建一个{name}",
|
||||
plan=normalize_scene_output(_model_output({initial_scope}), catalog.scene_composition_context()),
|
||||
)
|
||||
_assert_created(built, {initial_scope})
|
||||
draft = built["mode_profile_draft"]
|
||||
revised = _preview(
|
||||
service, f"把{name}的空调温度调整到26度",
|
||||
draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
|
||||
)
|
||||
_assert_created(revised, {initial_scope})
|
||||
original_seat_ops = {_operation_key(a) for a in _actions(built) if a["domain"] == "seat"}
|
||||
assert {_operation_key(a) for a in _actions(revised) if a["domain"] == "seat"} == original_seat_ops
|
||||
added = "副驾座椅通风调整到1档" if initial_scope == "driver" else "主驾座椅通风调整到低档"
|
||||
draft = revised["mode_profile_draft"]
|
||||
extended = _preview(
|
||||
service, f"把{name}的{added}",
|
||||
draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")},
|
||||
)
|
||||
_assert_created(extended, {"driver", "passenger"})
|
||||
assert {_operation_key(a) for a in _actions(extended) if a["domain"] == "seat"} >= original_seat_ops
|
||||
|
||||
|
||||
def _structured_client(output):
|
||||
client = MagicMock()
|
||||
client.available = True
|
||||
client.status = "ready"
|
||||
client.start_thread.return_value = "thread-seat-policy"
|
||||
client.start_turn.side_effect = AssertionError("scene fell back to unconstrained agent")
|
||||
|
||||
def start(thread_id, text, *, application_context, output_schema, timeout=None):
|
||||
Draft202012Validator(output_schema).validate(output)
|
||||
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-seat-policy", events=queue.Queue())
|
||||
turn.events.put({"method": "item/completed", "params": {"item": {
|
||||
"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False),
|
||||
}}})
|
||||
turn.events.put({"method": "turn/completed", "params": {"turn": {
|
||||
"id": turn.turn_id, "status": "completed", "items": [], "error": None,
|
||||
}}})
|
||||
return turn
|
||||
|
||||
client.start_structured_turn.side_effect = start
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("prefix", "scopes"), [("", {"passenger"}), ("主驾", {"driver"})])
|
||||
@pytest.mark.parametrize("dynamic", [False, True])
|
||||
def test_unregistered_assistant_structured_path_uses_seat_policy(policy_service, prefix, scopes, dynamic):
|
||||
service, catalog, _ = policy_service
|
||||
client = _structured_client(_model_output(scopes, dynamic=dynamic))
|
||||
response, preview, _ = _assistant_preview(
|
||||
service, catalog, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", client=client,
|
||||
)
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert response["route"] == "scene_composer"
|
||||
_assert_created(preview, scopes)
|
||||
client.start_structured_turn.assert_called_once()
|
||||
client.start_turn.assert_not_called()
|
||||
@@ -1237,6 +1237,10 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
|
||||
) -> None:
|
||||
service, user_root = mode_service
|
||||
nap = service.mode_profiles.get_profile("nap", "denza_n7")
|
||||
selected_operations = [
|
||||
operation for operation in nap.operations
|
||||
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
|
||||
]
|
||||
result = _preview_without_hardware(
|
||||
service,
|
||||
"构建一个小憩模式",
|
||||
@@ -1249,15 +1253,16 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied(
|
||||
draft = _assert_successful_preview(
|
||||
result,
|
||||
name=nap.name,
|
||||
operation_count=len(nap.operations),
|
||||
operation_count=len(selected_operations),
|
||||
)
|
||||
assert draft["generation_source"] == "registered_mode_profile"
|
||||
assert draft["existing_mode_id"] == "nap"
|
||||
expected = {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in nap.operations
|
||||
for operation in selected_operations
|
||||
}
|
||||
actual = _action_map(result)
|
||||
assert {target for capability, target in actual if capability.startswith("seat.")} == {"passenger"}
|
||||
for (capability, target), parameters in expected.items():
|
||||
rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target
|
||||
for name, value in parameters.items():
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from copy import deepcopy
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.mode_schedule_service import public_schedule_status
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
SceneCompositionError,
|
||||
build_scene_output_schema,
|
||||
normalize_scene_output,
|
||||
)
|
||||
from cognitive_agent.application.schedule_intent import needs_mode_schedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schedule_service(tmp_path, monkeypatch):
|
||||
for variable in (
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||||
):
|
||||
monkeypatch.setenv(variable, "false")
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
user_root = tmp_path / "user-modes"
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
with (
|
||||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve,
|
||||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")),
|
||||
):
|
||||
try:
|
||||
yield service, AssistantCapabilityCatalog(service), user_root
|
||||
finally:
|
||||
service.close()
|
||||
reserve.assert_not_called()
|
||||
|
||||
|
||||
def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None):
|
||||
if client is None:
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast = catalog.fast_answer(message, snapshot)
|
||||
results, events, passed_plans = [], [], []
|
||||
|
||||
def execute(instruction, progress, assistant_plan=None):
|
||||
passed_plans.append(deepcopy(assistant_plan))
|
||||
result = service.preview_native_can(
|
||||
instruction, provider="local", conversation_id=conversation,
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
on_commands_validated=progress, assistant_plan=assistant_plan,
|
||||
)
|
||||
results.append(result)
|
||||
return result
|
||||
|
||||
response = assistant.stream(
|
||||
message, conversation_id=conversation, principal="schedule-test", history=[],
|
||||
authoritative_answer=fast.answer if fast else None,
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
assert len(results) == 1, (response, results)
|
||||
return response, results[0], events, passed_plans, client
|
||||
|
||||
|
||||
def create_saved(service, catalog, message="构建一个上车预热的功能模式"):
|
||||
response, preview, _, _, _ = assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (response, preview)
|
||||
draft = preview["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
return preview, saved["mode_profile"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [
|
||||
("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000),
|
||||
("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000),
|
||||
("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000),
|
||||
("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000),
|
||||
("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000),
|
||||
("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000),
|
||||
])
|
||||
def test_user_temporal_requests_create_complete_multistage_template_without_model(
|
||||
schedule_service, message, template_id, count, repeats, elapsed,
|
||||
):
|
||||
service, catalog, user_root = schedule_service
|
||||
response, preview, events, plans, client = assistant_preview(service, catalog, message)
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert response["route"] == "scene_template"
|
||||
assert preview["status"] == "succeeded"
|
||||
assert preview["execution_mode"] == "dry_run"
|
||||
assert preview["hardware_execution_allowed"] is False
|
||||
assert preview["orchestration_route"] == "mode_profile_create_preview"
|
||||
draft = preview["mode_profile_draft"]
|
||||
assert draft["source_template_id"] == template_id
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["operation_count"] == count
|
||||
expected_schedule = deepcopy(plans[0]["schedule"])
|
||||
for phase in expected_schedule["phases"]:
|
||||
phase["operations"] = [
|
||||
op for op in phase["operations"]
|
||||
if op["domain"] != "seat" or op["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["schedule"] == preview["schedule"] == expected_schedule
|
||||
schedule = ModeSchedule.from_dict(preview["schedule"])
|
||||
assert schedule.repeat_count == repeats
|
||||
assert schedule.total_duration_ms == elapsed
|
||||
actions = preview["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == count
|
||||
assert len({action["id"] for action in actions}) == count
|
||||
assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases}
|
||||
assert len(preview["schedule_phase_previews"]) == 2
|
||||
assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"])
|
||||
assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases)
|
||||
assert {
|
||||
op["target_scope"] for phase in schedule.phases for op in phase.operations
|
||||
if op["domain"] == "seat"
|
||||
} == {"passenger"}
|
||||
assert events
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
|
||||
def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message):
|
||||
service, catalog, _ = schedule_service
|
||||
_, preview, _, _, _ = assistant_preview(service, catalog, message)
|
||||
draft = preview["mode_profile_draft"]
|
||||
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
|
||||
trial = service.trial_mode_profile_draft(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert trial["status"] == "succeeded"
|
||||
assert trial["hardware_execution_allowed"] is False
|
||||
assert trial["schedule"] == preview["schedule"]
|
||||
execution = trial["schedule_execution"]
|
||||
schedule = ModeSchedule.from_dict(preview["schedule"])
|
||||
assert execution["completed"] is True
|
||||
assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms
|
||||
assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count
|
||||
assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count
|
||||
assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"])
|
||||
def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message):
|
||||
service, catalog, user_root = schedule_service
|
||||
preview, public_profile = create_saved(service, catalog, message)
|
||||
assert public_profile["schedule"] == preview["schedule"]
|
||||
assert public_profile["behavior"] == "cyclic"
|
||||
mode_id = public_profile["id"]
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
assert reloaded.schedule.to_dict() == preview["schedule"]
|
||||
with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")):
|
||||
executed = service.execute_mode_profile_card(
|
||||
mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert executed["status"] == "succeeded", executed
|
||||
assert executed["orchestration_route"] == "mode_schedule_execute"
|
||||
assert executed["execution_mode"] == "dry_run"
|
||||
assert executed["hardware_execution_allowed"] is False
|
||||
assert "mode_profile_draft" not in executed
|
||||
execution = executed["schedule_execution"]
|
||||
assert execution["completed"] is True
|
||||
assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms
|
||||
assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count
|
||||
expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count
|
||||
for expected, actual in zip(expected_phases, execution["phase_results"], strict=True):
|
||||
phase_result = actual["result"]
|
||||
assert phase_result["status"] == "succeeded"
|
||||
assert phase_result["execution_mode"] == "dry_run"
|
||||
actual_actions = phase_result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actual_actions) == len(expected.operations)
|
||||
expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations)
|
||||
actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions)
|
||||
assert actual_params == expected_params
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
preview, saved = create_saved(service, catalog)
|
||||
result = service.process(
|
||||
f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded", result
|
||||
assert result["orchestration_route"] == "mode_schedule_execute"
|
||||
assert result["schedule"] == preview["schedule"]
|
||||
temperatures = []
|
||||
for phase in result["schedule_execution"]["phase_results"]:
|
||||
temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature")
|
||||
assert temperatures == [28, 24]
|
||||
|
||||
|
||||
def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog)
|
||||
with (
|
||||
patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")),
|
||||
pytest.raises(HardwareAccessRequiredError),
|
||||
):
|
||||
service.execute_mode_profile_card(
|
||||
saved["id"], execution_mode="hardware", vehicle_id="denza_n7",
|
||||
conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False,
|
||||
hardware_client_id="hwc-schedule-unauthorized",
|
||||
)
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "value"), [
|
||||
("name", "其他模式"), ("instruction", "直接执行危险指令"),
|
||||
("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"),
|
||||
])
|
||||
def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value):
|
||||
service, catalog, user_root = schedule_service
|
||||
_, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式")
|
||||
draft = preview["mode_profile_draft"]
|
||||
arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")}
|
||||
arguments[field] = value
|
||||
with pytest.raises(ValueError):
|
||||
service.trial_mode_profile_draft(**arguments)
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"])
|
||||
def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation):
|
||||
service, catalog, user_root = schedule_service
|
||||
template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup")
|
||||
plan = deepcopy(template["plan"])
|
||||
phase = plan["schedule"]["phases"][0]
|
||||
if mutation == "capability":
|
||||
phase["operations"][0]["capability"] = "door.set_vehicle_lock"
|
||||
elif mutation == "duration":
|
||||
phase["duration_ms"] = 1
|
||||
elif mutation == "temperature":
|
||||
phase["operations"][1]["parameters"]["temperature_c"] = 99
|
||||
else:
|
||||
phase["operations"][3]["target_scope"] = "passenger"
|
||||
try:
|
||||
result = service.preview_native_can(
|
||||
"构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
except ValueError:
|
||||
result = None
|
||||
if result is not None:
|
||||
assert result["status"] != "succeeded"
|
||||
assert "mode_profile_draft" not in result
|
||||
assert result.get("hardware_execution_allowed") is not True
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog)
|
||||
profile = service.mode_profiles.get_profile(saved["id"], "denza_n7")
|
||||
# Represents a stale or externally edited user profile, not a trusted runtime
|
||||
# capability change. Its structure remains valid, but the target is unsafe.
|
||||
profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99
|
||||
with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")):
|
||||
result = service.execute_mode_profile_card(
|
||||
saved["id"], conversation_id="conv-schedule-stale-capability",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "validation_failed", result
|
||||
assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID"
|
||||
assert "mode_profile_draft" not in result
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
|
||||
preview_method = service._preview_mode_schedule
|
||||
virtual_method = service.native_can_runtime.execute_virtual
|
||||
execution_started = False
|
||||
executed_massage_modes = []
|
||||
|
||||
def validate_then_arm(*args, **kwargs):
|
||||
nonlocal execution_started
|
||||
result = preview_method(*args, **kwargs)
|
||||
execution_started = True
|
||||
return result
|
||||
|
||||
def fault_on_second_phase(command_plan, function_config):
|
||||
if execution_started:
|
||||
for op in function_config["spec"]["operations"]:
|
||||
if op["capability"] == "seat.set_driver_massage_native":
|
||||
executed_massage_modes.append(op["parameters"]["mode"])
|
||||
if op["parameters"]["mode"] == "slack":
|
||||
raise RuntimeError("injected lower-level actuator failure")
|
||||
return virtual_method(command_plan, function_config)
|
||||
|
||||
with (
|
||||
patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm),
|
||||
patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase),
|
||||
):
|
||||
result = service.execute_mode_profile_card(
|
||||
saved["id"], conversation_id="conv-schedule-injected-failure",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
execution = result["schedule_execution"]
|
||||
assert result["status"] == "failed", result
|
||||
assert execution["completed"] is False
|
||||
assert execution["error"]
|
||||
assert execution["executed_phases"] == 1
|
||||
assert execution["virtual_elapsed_ms"] == 30000
|
||||
assert execution["repeat_index"] == 0
|
||||
assert len(execution["phase_results"]) == 2
|
||||
assert executed_massage_modes == ["relax", "slack"]
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def structured_schedule():
|
||||
return {
|
||||
"requests": [], "ambiguities": [], "confidence": 0.96,
|
||||
"schedule": {
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "warm", "name": "暖舱", "duration_ms": 30000,
|
||||
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]},
|
||||
{"id": "settle", "name": "恒温", "duration_ms": 60000,
|
||||
"requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def structured_client(output):
|
||||
client = MagicMock()
|
||||
client.available = True
|
||||
client.status = "ready"
|
||||
client.start_thread.return_value = "thread-schedule-test"
|
||||
client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent")
|
||||
|
||||
def start(thread_id, text, *, application_context, output_schema, timeout=None):
|
||||
Draft202012Validator(output_schema).validate(output)
|
||||
turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue())
|
||||
turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}})
|
||||
turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}})
|
||||
return turn
|
||||
|
||||
client.start_structured_turn.side_effect = start
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
"构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次",
|
||||
])
|
||||
def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message):
|
||||
service, catalog, user_root = schedule_service
|
||||
output = structured_schedule()
|
||||
client = structured_client(output)
|
||||
response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client)
|
||||
assert response["route"] == "scene_composer", response
|
||||
assert response["success"] is True, (response, preview)
|
||||
assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy"
|
||||
client.start_structured_turn.assert_called_once()
|
||||
client.start_turn.assert_not_called()
|
||||
assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"]
|
||||
assert preview["schedule"] == plans[0]["schedule"]
|
||||
assert preview["schedule"]["repeat_count"] == 2
|
||||
assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000]
|
||||
assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24]
|
||||
assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
schema = build_scene_output_schema(context, dynamic=True)
|
||||
Draft202012Validator(schema).validate(output)
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert plan["ambiguities"] == []
|
||||
schedule = ModeSchedule.from_dict(plan["schedule"])
|
||||
assert schedule.total_duration_ms == 180000
|
||||
assert schedule.operation_count == 2
|
||||
assert schedule.phases[0].operations[0]["domain"] == "climate"
|
||||
assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
"static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability",
|
||||
"duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration",
|
||||
"bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability",
|
||||
])
|
||||
def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
schedule = output["schedule"]
|
||||
phase = schedule["phases"][0]
|
||||
if mutation == "static-root":
|
||||
output["requests"] = deepcopy(phase["requests"])
|
||||
elif mutation == "unknown-root":
|
||||
output["execute_now"] = True
|
||||
elif mutation == "unknown-schedule":
|
||||
schedule["unbounded"] = True
|
||||
elif mutation == "unknown-phase":
|
||||
phase["skip_safety"] = True
|
||||
elif mutation == "unknown-capability":
|
||||
phase["requests"][0]["capability"] = "unknown.actuate"
|
||||
elif mutation == "motion-capability":
|
||||
phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}}
|
||||
elif mutation == "duplicate-phase":
|
||||
schedule["phases"][1]["id"] = phase["id"]
|
||||
elif mutation == "duplicate-operation":
|
||||
phase["requests"].append(deepcopy(phase["requests"][0]))
|
||||
elif mutation == "no-phases":
|
||||
schedule["phases"] = []
|
||||
elif mutation == "empty-phase":
|
||||
phase["requests"] = []
|
||||
elif mutation == "bool-duration":
|
||||
phase["duration_ms"] = True
|
||||
elif mutation == "bool-repeat":
|
||||
schedule["repeat_count"] = True
|
||||
elif mutation == "long-duration":
|
||||
phase["duration_ms"] = 900001
|
||||
elif mutation == "long-total":
|
||||
schedule["repeat_count"] = 20
|
||||
phase["duration_ms"] = 900000
|
||||
elif mutation == "nan-parameter":
|
||||
phase["requests"][0]["parameters"]["temperature_c"] = float("nan")
|
||||
with pytest.raises(SceneCompositionError):
|
||||
normalize_scene_output(output, context, dynamic=True)
|
||||
|
||||
|
||||
def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service):
|
||||
_, catalog, _ = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None}
|
||||
Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output)
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert not plan["requests"]
|
||||
assert "schedule" not in plan
|
||||
assert plan["ambiguities"]
|
||||
|
||||
|
||||
def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service):
|
||||
service, catalog, user_root = schedule_service
|
||||
context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7"))
|
||||
output = structured_schedule()
|
||||
output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"]
|
||||
plan = normalize_scene_output(output, context, dynamic=True)
|
||||
assert "metadata" not in plan
|
||||
with pytest.raises(ValueError, match="未解决"):
|
||||
service.preview_native_can(
|
||||
"构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("seat", "allowed_targets"), [
|
||||
("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}),
|
||||
])
|
||||
def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets):
|
||||
service, catalog, _ = schedule_service
|
||||
_, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式")
|
||||
result = service.process(
|
||||
f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
if result["status"] == "succeeded":
|
||||
assert result["orchestration_route"] == "mode_schedule_execute"
|
||||
execution = result["schedule_execution"]
|
||||
for phase in execution["phase_results"]:
|
||||
actions = phase["result"]["intent_ir"]["spec"]["actions"]
|
||||
seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"}
|
||||
assert seat_targets and seat_targets <= allowed_targets
|
||||
else:
|
||||
assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"}
|
||||
assert not result.get("schedule_execution", {}).get("phase_results")
|
||||
assert result.get("hardware_execution_allowed") is not True
|
||||
|
||||
|
||||
def start_waiting_schedule(service):
|
||||
"""Run only a no-device test callback, holding until the tested stop request."""
|
||||
calls = []
|
||||
entered = Event()
|
||||
schedule = ModeSchedule.from_dict({
|
||||
"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1,
|
||||
"phases": [
|
||||
{"id": phase_id, "name": phase_id, "duration_ms": 900000,
|
||||
"operations": [{"domain": "climate", "capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle", "operation": "set",
|
||||
"parameters": {"temperature_c": temperature}, "reason": "test only"}]}
|
||||
for phase_id, temperature in (("first", 28), ("second", 24))
|
||||
],
|
||||
})
|
||||
|
||||
def execute(phase):
|
||||
calls.append(phase.id)
|
||||
entered.set()
|
||||
return {"status": "succeeded"}
|
||||
|
||||
service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status")
|
||||
assert entered.wait(2)
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
|
||||
return calls
|
||||
|
||||
|
||||
def assistant_control(catalog, message, execute):
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
events = []
|
||||
response = assistant.stream(
|
||||
message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[],
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute, capability_snapshot=snapshot,
|
||||
capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot),
|
||||
)
|
||||
client.start_thread.assert_not_called()
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
return response, events
|
||||
|
||||
|
||||
def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service):
|
||||
service, _, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
result = service.process(
|
||||
"停止动态模式", provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["schedule_execution"]["status"] == "simulated"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "running"
|
||||
assert calls == ["first"]
|
||||
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
|
||||
assert persisted["orchestration_route"] == "mode_schedule_stop"
|
||||
assert persisted["trace_events"] == []
|
||||
|
||||
|
||||
def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service):
|
||||
service, _, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
result = service.process(
|
||||
"停止动态模式", provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode="hardware", vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["schedule_execution"]["status"] == "cancelled"
|
||||
assert result["schedule_execution"]["completed"] is False
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled"
|
||||
assert service.mode_schedule_runner.close(timeout=1)
|
||||
assert calls == ["first"]
|
||||
persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8"))
|
||||
assert persisted["orchestration_route"] == "mode_schedule_stop"
|
||||
assert persisted["schedule_execution"]["status"] == "cancelled"
|
||||
assert persisted["trace_events"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [
|
||||
("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"),
|
||||
])
|
||||
def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy(
|
||||
schedule_service, execution_mode, action_state, runner_state,
|
||||
):
|
||||
service, catalog, _ = schedule_service
|
||||
calls = start_waiting_schedule(service)
|
||||
|
||||
def execute(message, progress):
|
||||
return service.process(
|
||||
message, provider="local", conversation_id="conv-stop-status",
|
||||
execution_mode=execution_mode, vehicle_id="denza_n7",
|
||||
)
|
||||
|
||||
response, events = assistant_control(catalog, "停止动态模式", execute)
|
||||
assert response["success"] is True, response
|
||||
assert response["route"] == "vehicle_control"
|
||||
action = next(event for event in events if event["type"] == "action.completed")
|
||||
assert action["success"] is True
|
||||
assert action["state"] == action_state
|
||||
assert action["payload"]["orchestration_route"] == "mode_schedule_stop"
|
||||
assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state
|
||||
assert calls == ["first"]
|
||||
assert "已完成" not in response["answer"]
|
||||
assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"]
|
||||
|
||||
|
||||
def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service):
|
||||
service, catalog, _ = schedule_service
|
||||
preview, profile = create_saved(service, catalog)
|
||||
start_waiting_schedule(service)
|
||||
state = service.mode_schedule_runner.status("denza_n7")
|
||||
state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}]
|
||||
|
||||
def execute(message, progress):
|
||||
return {
|
||||
"status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True,
|
||||
"orchestration_route": "mode_schedule_execute", "schedule_execution": state,
|
||||
"mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"],
|
||||
}
|
||||
|
||||
response, events = assistant_control(catalog, f"进入{profile['name']}", execute)
|
||||
assert response["success"] is True, response
|
||||
action = next(event for event in events if event["type"] == "action.completed")
|
||||
assert action["success"] is True
|
||||
assert action["state"] == "running"
|
||||
assert action["payload"]["status"] == "running"
|
||||
assert action["payload"]["schedule_execution"]["completed"] is False
|
||||
assert "phase_results" not in action["payload"]["schedule_execution"]
|
||||
assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False)
|
||||
assert "尚未完成所有阶段" in action["display_text"]
|
||||
assert "尚未完成所有阶段" in response["answer"]
|
||||
completed = next(event for event in events if event["type"] == "work.completed")
|
||||
assert completed["state"] == "running"
|
||||
assert "已完成" not in completed["display_text"]
|
||||
|
||||
|
||||
def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error():
|
||||
state = {
|
||||
"run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed",
|
||||
"phase_index": 1, "repeat_index": 0, "completed": False,
|
||||
"error": "secret-token /private/path/driver.dll CAN payload 123456",
|
||||
"phase_results": [{"command_plan": {"data_hex": "deadbeef"}}],
|
||||
"raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"},
|
||||
"phase_count": {"nested": "unexpected"},
|
||||
}
|
||||
public = public_schedule_status(state)
|
||||
assert public["status"] == "failed"
|
||||
assert public["phase_index"] == 1
|
||||
assert public["completed"] is False
|
||||
assert public["error"] and public["error"] != state["error"]
|
||||
assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"}
|
||||
for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"):
|
||||
assert sensitive not in json.dumps(public, ensure_ascii=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"])
|
||||
def test_hvac_circulation_state_is_not_a_temporal_schedule(message):
|
||||
assert needs_mode_schedule(message) is False
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Natural-language edits cannot flatten or silently broaden dynamic phases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.mode_schedule_edit import edit_schedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
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",
|
||||
},
|
||||
):
|
||||
instance = SeatDemoService(REPOSITORY_ROOT)
|
||||
instance.artifact_root = tmp_path / "runs"
|
||||
instance.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=tmp_path / "user-modes",
|
||||
)
|
||||
try:
|
||||
with patch.object(
|
||||
instance.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("schedule edit reached hardware"),
|
||||
):
|
||||
yield instance
|
||||
finally:
|
||||
instance.close()
|
||||
|
||||
|
||||
def _build(service):
|
||||
template = service.scene_templates.get_template("boarding-warmup")
|
||||
result = service.preview_native_can(
|
||||
"构建一个上车预热模式",
|
||||
provider="local",
|
||||
conversation_id="conv-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=template.plan(),
|
||||
)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
return result
|
||||
|
||||
|
||||
def _edit(service, built, change):
|
||||
draft = built["mode_profile_draft"]
|
||||
reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}
|
||||
return service.preview_native_can(
|
||||
f"把上车预热模式的{change}",
|
||||
provider="local",
|
||||
conversation_id="conv-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
mode_profile_draft_context=reference,
|
||||
)
|
||||
|
||||
|
||||
def _parameters(schedule, index, capability, target="vehicle"):
|
||||
return next(
|
||||
op["parameters"]
|
||||
for op in schedule["phases"][index]["operations"]
|
||||
if op["capability"] == capability and op["target_scope"] == target
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
(
|
||||
"第二阶段空调温度调整到26度",
|
||||
"第2个阶段的空调温度变成26度",
|
||||
"舒适恒温阶段空调温度改为26度",
|
||||
),
|
||||
)
|
||||
def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change):
|
||||
built = _build(service)
|
||||
original = deepcopy(built["schedule"])
|
||||
revised = _edit(service, built, change)
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
expected = deepcopy(original)
|
||||
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
||||
assert revised["schedule"] == expected
|
||||
assert built["schedule"] == original
|
||||
assert revised["mode_profile_draft"]["schedule"] == expected
|
||||
assert revised["mode_profile_draft"]["draft_revision"] == 2
|
||||
assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"]
|
||||
assert revised["execution_mode"] == "dry_run"
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "expected_hold", "expected_repeat"),
|
||||
(
|
||||
("第二阶段持续时间调整为3分钟", 180000, 1),
|
||||
("第二阶段保持30秒", 30000, 1),
|
||||
("第二阶段时长改成2.5分钟", 150000, 1),
|
||||
("循环次数改成3次", 300000, 3),
|
||||
("重复两次", 300000, 2),
|
||||
),
|
||||
)
|
||||
def test_draft_time_and_repeat_edits_keep_operations(
|
||||
service, change, expected_hold, expected_repeat
|
||||
):
|
||||
built = _build(service)
|
||||
revised = _edit(service, built, change)
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold
|
||||
assert revised["schedule"]["repeat_count"] == expected_repeat
|
||||
for index in (0, 1):
|
||||
assert (
|
||||
revised["schedule"]["phases"][index]["operations"]
|
||||
== built["schedule"]["phases"][index]["operations"]
|
||||
)
|
||||
|
||||
|
||||
def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service):
|
||||
built = _build(service)
|
||||
revised = _edit(service, built, "全部阶段空调温度调整到25度")
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
expected = deepcopy(built["schedule"])
|
||||
for index in (0, 1):
|
||||
_parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25
|
||||
assert revised["schedule"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
(
|
||||
"空调温度调整到25度",
|
||||
"第三阶段空调温度调整到25度",
|
||||
"第二阶段持续16分钟",
|
||||
"循环21次",
|
||||
"重复1.5次",
|
||||
"持续30秒",
|
||||
"第二阶段主驾靠背调整到90%",
|
||||
"第二阶段空调温度调整到99度",
|
||||
"第二阶段打开后备箱",
|
||||
"第二阶段空调温度调整到26度并打开后备箱",
|
||||
),
|
||||
)
|
||||
def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change):
|
||||
built = _build(service)
|
||||
original = deepcopy(built["schedule"])
|
||||
revised = _edit(service, built, change)
|
||||
assert revised["status"] in {"needs_confirmation", "validation_failed"}
|
||||
assert not revised.get("mode_profile_draft")
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
source = service._load_mode_profile_draft_reference(
|
||||
{
|
||||
key: built["mode_profile_draft"][key]
|
||||
for key in ("name", "source_request_id", "vehicle_id")
|
||||
},
|
||||
conversation_id="conv-schedule-edit",
|
||||
mode_name="上车预热模式",
|
||||
vehicle_id="denza_n7",
|
||||
)[1]
|
||||
assert source["schedule"] == original
|
||||
|
||||
|
||||
def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service):
|
||||
built = _build(service)
|
||||
draft = built["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
|
||||
)
|
||||
mode_id = saved["mode_profile"]["id"]
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
path = Path(profile.source)
|
||||
before = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
rejected = service.preview_native_can(
|
||||
"把上车预热模式的空调温度调整到26度",
|
||||
provider="local",
|
||||
conversation_id="conv-saved-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert rejected["status"] == "needs_confirmation"
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
|
||||
|
||||
revised = service.preview_native_can(
|
||||
"把上车预热模式的第二阶段空调温度调整到26度",
|
||||
provider="local",
|
||||
conversation_id="conv-saved-schedule-edit",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert revised["status"] == "succeeded", revised.get("validation")
|
||||
assert revised["orchestration_route"] == "mode_profile_update"
|
||||
assert revised["hardware_execution_allowed"] is False
|
||||
actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict()
|
||||
expected = deepcopy(built["schedule"])
|
||||
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
||||
assert actual == expected
|
||||
assert revised["schedule"] == expected
|
||||
assert revised["mode_profile"]["behavior"] == "cyclic"
|
||||
|
||||
|
||||
def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service):
|
||||
built = _build(service)
|
||||
revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟")
|
||||
assert revised["status"] == "succeeded", revised.get("mode_creation_failure")
|
||||
repeated = _edit(service, revised, "重复两次")
|
||||
assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure")
|
||||
expected = deepcopy(built["schedule"])
|
||||
_parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26
|
||||
expected["phases"][1]["duration_ms"] = 180000
|
||||
expected["repeat_count"] = 2
|
||||
assert repeated["schedule"] == expected
|
||||
assert repeated["mode_profile_draft"]["draft_revision"] == 3
|
||||
draft = repeated["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]
|
||||
)
|
||||
assert (
|
||||
service.mode_profiles.get_profile(
|
||||
saved["mode_profile"]["id"], "denza_n7"
|
||||
).schedule.to_dict()
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service):
|
||||
source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"]
|
||||
for phase in source["phases"]:
|
||||
for operation in phase["operations"]:
|
||||
if operation["capability"] == "climate.set_temperature":
|
||||
operation["target_scope"] = "passenger"
|
||||
schedule = ModeSchedule.from_dict(source)
|
||||
updated = edit_schedule(
|
||||
schedule,
|
||||
"全部阶段空调温度调整到25度",
|
||||
parse_operations=lambda _: [
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"temperature_c": 25},
|
||||
}
|
||||
],
|
||||
target_is_explicit=service._mode_update_target_is_explicit,
|
||||
)
|
||||
for index in (0, 1):
|
||||
assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == {
|
||||
"temperature_c": 25
|
||||
}
|
||||
assert not any(
|
||||
op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle"
|
||||
for op in updated.phases[index].operations
|
||||
)
|
||||
|
||||
|
||||
def test_phase_motor_duration_cannot_become_a_hold_duration(service):
|
||||
built = _build(service)
|
||||
result = _edit(service, built, "第二阶段副驾靠背向后移动3s")
|
||||
assert result["status"] in {"needs_confirmation", "validation_failed"}
|
||||
assert not result.get("mode_profile_draft")
|
||||
|
||||
|
||||
def test_stage_selector_does_not_authorize_delayed_action_semantics(service):
|
||||
built = _build(service)
|
||||
result = _edit(service, built, "第二阶段30秒后空调温度调整到26度")
|
||||
assert result["status"] == "needs_confirmation"
|
||||
assert not result.get("mode_profile_draft")
|
||||
@@ -273,7 +273,8 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
draft = result["mode_profile_draft"]
|
||||
assert isinstance(draft, dict)
|
||||
assert draft["name"] == "小憩模式"
|
||||
assert draft["operation_count"] == len(preset.operations) == 9
|
||||
assert draft["operation_count"] == 9
|
||||
assert len(preset.operations) == 10 # The registry retains both optional seat variants.
|
||||
assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"}
|
||||
assert draft["existing_mode_id"] == "nap"
|
||||
assert isinstance(draft["existing_mode_revision"], str)
|
||||
@@ -283,6 +284,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
actions = _intent_actions(result)
|
||||
assert {action["capability"] for action in actions} == {
|
||||
operation.capability for operation in preset.operations
|
||||
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
|
||||
} == EXPECTED_NAP_CAPABILITIES
|
||||
expected_targets_and_parameters = {
|
||||
operation.capability: (
|
||||
@@ -292,6 +294,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile(
|
||||
operation.parameters,
|
||||
)
|
||||
for operation in preset.operations
|
||||
if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"}
|
||||
}
|
||||
actual_targets_and_parameters = {
|
||||
action["capability"]: (
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Seat-aware built-in defaults must remain safe and preserve saved user intent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
COMFORT_TEMPLATE_IDS = (
|
||||
"reading",
|
||||
"cinema",
|
||||
"camping",
|
||||
"quiet-night",
|
||||
"reception",
|
||||
"romantic",
|
||||
"winter-warmth",
|
||||
"summer-cooling",
|
||||
"long-trip-comfort",
|
||||
"relaxation",
|
||||
"seat-comfort",
|
||||
)
|
||||
DRIVER_COMFORT_CAPABILITIES = {
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path: Path) -> Iterator[SeatDemoService]:
|
||||
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",
|
||||
},
|
||||
):
|
||||
instance = SeatDemoService(REPOSITORY_ROOT)
|
||||
instance.artifact_root = tmp_path / "runs"
|
||||
instance.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=tmp_path / "user-modes",
|
||||
)
|
||||
try:
|
||||
with patch.object(
|
||||
instance.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("creating or previewing a mode reached real hardware"),
|
||||
):
|
||||
yield instance
|
||||
finally:
|
||||
instance.close()
|
||||
|
||||
|
||||
def _preview(service: SeatDemoService, instruction: str, *, plan=None) -> dict:
|
||||
result = service.preview_native_can(
|
||||
instruction,
|
||||
provider="local",
|
||||
conversation_id="conv-registered-seat-coverage",
|
||||
execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=plan,
|
||||
)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
return result
|
||||
|
||||
|
||||
def _action_map(result: dict) -> dict:
|
||||
return {
|
||||
(
|
||||
action["capability"],
|
||||
action["target"].get("instance") or action["target"].get("zone"),
|
||||
): action["parameters"]
|
||||
for action in result["intent_ir"]["spec"]["actions"]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", COMFORT_TEMPLATE_IDS)
|
||||
@pytest.mark.parametrize(("seat_prefix", "targets"), (
|
||||
("", {"passenger"}), ("主驾", {"driver"}),
|
||||
("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
))
|
||||
def test_comfort_templates_select_only_requested_seat_without_mutating_seat_seeds(
|
||||
service: SeatDemoService,
|
||||
template_id: str,
|
||||
seat_prefix: str,
|
||||
targets: set[str],
|
||||
) -> None:
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
original_revision = template.revision
|
||||
result = _preview(service, f"构建一个{seat_prefix}{template.name}", plan=template.plan())
|
||||
actions = _action_map(result)
|
||||
seat_keys = {key for key in actions if key[0].startswith("seat.")}
|
||||
assert {target for _, target in seat_keys} == targets
|
||||
assert {
|
||||
operation.target_scope for operation in template.operations if operation.domain == "seat"
|
||||
} == {"driver", "passenger"}
|
||||
assert all(
|
||||
operation.capability in DRIVER_COMFORT_CAPABILITIES
|
||||
for operation in template.operations
|
||||
if operation.domain == "seat" and operation.target_scope == "driver"
|
||||
), "driver positional or timed movement must not enter static comfort defaults"
|
||||
assert not any(
|
||||
"massage" in capability for capability, target in seat_keys if target == "passenger"
|
||||
)
|
||||
assert actions == {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in template.operations
|
||||
if operation.domain != "seat" or operation.target_scope in targets
|
||||
}
|
||||
assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy"
|
||||
assert service.scene_templates.get_template(template_id).revision == original_revision
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode_id", "mode_name"),
|
||||
(
|
||||
("office", "办公模式"),
|
||||
("nap", "小憩模式"),
|
||||
("meditation_space", "冥想空间模式"),
|
||||
("leisure_lounger", "休闲躺椅模式"),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(("seat_prefix", "targets"), (
|
||||
("", {"passenger"}), ("主驾", {"driver"}),
|
||||
("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}),
|
||||
))
|
||||
def test_registered_cabin_modes_select_only_requested_seat_on_creation(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
mode_name: str,
|
||||
seat_prefix: str,
|
||||
targets: set[str],
|
||||
) -> None:
|
||||
result = _preview(service, f"构建一个{seat_prefix}{mode_name}")
|
||||
actions = _action_map(result)
|
||||
assert {target for capability, target in actions if capability.startswith("seat.")} == targets
|
||||
if "passenger" in targets:
|
||||
assert ("seat.set_backrest_recline", "passenger") in actions
|
||||
draft = result["mode_profile_draft"]
|
||||
if seat_prefix:
|
||||
assert "existing_mode_id" not in draft, "a selected-seat copy must not overwrite the original preset"
|
||||
else:
|
||||
assert draft["existing_mode_id"] == mode_id
|
||||
assert draft["generation_source"] == "registered_mode_profile"
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
assert actions == {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in profile.operations
|
||||
if operation.domain != "seat" or operation.target_scope in targets
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_id", ("fresh-air", "rain-defrost"))
|
||||
def test_air_quality_and_visibility_templates_do_not_invent_seat_actions(
|
||||
service: SeatDemoService,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
result = _preview(service, f"构建一个{template.name}", plan=template.plan())
|
||||
assert not any(capability.startswith("seat.") for capability, _ in _action_map(result))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template_id",
|
||||
(
|
||||
"long-trip-comfort",
|
||||
"summer-cooling",
|
||||
"winter-warmth",
|
||||
"reception",
|
||||
"quiet-night",
|
||||
"romantic",
|
||||
),
|
||||
)
|
||||
def test_non_posture_templates_keep_both_seat_positions_unchanged(
|
||||
service: SeatDemoService,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
assert all(
|
||||
"position_pct" not in operation.parameters and "duration_ms" not in operation.parameters
|
||||
for operation in template.operations
|
||||
if operation.domain == "seat"
|
||||
)
|
||||
|
||||
|
||||
def test_native_driver_and_passenger_thermal_levels_are_not_confused(
|
||||
service: SeatDemoService,
|
||||
) -> None:
|
||||
for template_id in (
|
||||
"reading",
|
||||
"camping",
|
||||
"quiet-night",
|
||||
"reception",
|
||||
"romantic",
|
||||
"long-trip-comfort",
|
||||
"seat-comfort",
|
||||
):
|
||||
template = service.scene_templates.get_template(template_id)
|
||||
params = {(op.capability, op.target_scope): op.parameters for op in template.operations}
|
||||
assert params[("seat.set_driver_ventilation_native", "driver")] == {
|
||||
"state": "on",
|
||||
"level": 2,
|
||||
}
|
||||
assert params[("seat.set_ventilation", "passenger")] == {"state": "on", "level": 1}
|
||||
|
||||
|
||||
def test_upgraded_template_does_not_silently_add_seats_to_a_saved_custom_mode(
|
||||
service: SeatDemoService,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Simulate a previously saved user preference with the same name as a template.
|
||||
original = _preview(service, "构建一个观影模式,空调温度调整到25度")
|
||||
draft = original["mode_profile_draft"]
|
||||
assert len(_action_map(original)) == 1
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"],
|
||||
draft["instruction"],
|
||||
draft["source_request_id"],
|
||||
draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
paths = list((tmp_path / "user-modes").rglob("*.yaml"))
|
||||
hashes = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths}
|
||||
|
||||
repeated = _preview(service, "构建一个观影模式")
|
||||
assert repeated["mode_profile_draft"]["generation_source"] == "registered_mode_profile"
|
||||
assert _action_map(repeated) == _action_map(original)
|
||||
assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} == hashes
|
||||
assert len(service.scene_templates.get_template("cinema").operations) == 11
|
||||
|
||||
|
||||
def test_explicit_seat_only_requirement_is_not_expanded_to_other_seat_defaults(
|
||||
service: SeatDemoService,
|
||||
) -> None:
|
||||
result = _preview(service, "构建一个阅读模式,副驾座椅通风关闭")
|
||||
assert _action_map(result) == {("seat.set_ventilation", "passenger"): {"state": "off"}}
|
||||
assert result["mode_profile_draft"]["generation_source"] == "explicit_scene_requirements"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
|
||||
@pytest.mark.parametrize(("seat", "spoken_seat"), (("driver", "主驾"), ("passenger", "副驾驶")))
|
||||
def test_explicit_single_seat_invocation_selects_without_mutating_cabin_mode(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
seat: str,
|
||||
spoken_seat: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
original_revision = service.mode_profiles.profile_revision(profile)
|
||||
expected = {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in profile.operations
|
||||
if operation.domain != "seat" or operation.target_scope == seat
|
||||
}
|
||||
result = service.preview_native_can(
|
||||
f"请让{spoken_seat}进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id="conv-explicit-seat-mode-selection",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert _action_map(result) == expected
|
||||
assert (
|
||||
service.mode_profiles.profile_revision(
|
||||
service.mode_profiles.get_profile(mode_id, "denza_n7"),
|
||||
)
|
||||
== original_revision
|
||||
)
|
||||
assert any(
|
||||
operation.target_scope != seat
|
||||
for operation in service.mode_profiles.get_profile(mode_id, "denza_n7").operations
|
||||
if operation.domain == "seat"
|
||||
), "single-seat execution must not edit the persisted dual-seat profile"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode_id", "spoken_seat"),
|
||||
[
|
||||
(mode_id, seat)
|
||||
for mode_id in ("zero_gravity", "easy_entry_exit", "rocking_horse", "office")
|
||||
for seat in ("主驾", "后排", "所有座椅")
|
||||
if (mode_id, seat) != ("office", "主驾")
|
||||
],
|
||||
)
|
||||
def test_mode_invocation_still_rejects_genuinely_unsupported_seat_scopes(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
spoken_seat: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
result = service.preview_native_can(
|
||||
f"请让{spoken_seat}进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id="conv-explicit-seat-mode-rejection",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] in {"no_action", "needs_confirmation"}
|
||||
assert _action_map(result) == {}
|
||||
assert any(
|
||||
issue.get("reason") == "mode_target_scope_mismatch"
|
||||
for issue in result["intent_ir"]["spec"].get("ambiguities", [])
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
("创建一个主驾办公模式", "修改办公模式,只要主驾", "主驾办公模式"),
|
||||
)
|
||||
def test_mode_seat_selection_requires_an_explicit_invocation_not_configuration(
|
||||
service: SeatDemoService,
|
||||
message: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile("office", "denza_n7")
|
||||
assert service._explicit_mode_seat_selection(profile, message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger"))
|
||||
def test_unscoped_mode_invocation_keeps_registered_native_driver_actions(
|
||||
service: SeatDemoService,
|
||||
mode_id: str,
|
||||
) -> None:
|
||||
profile = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
result = service.preview_native_can(
|
||||
f"进入{profile.name}",
|
||||
provider="local",
|
||||
conversation_id="conv-mode-native-seat-preserved",
|
||||
execution_mode="dry_run",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert _action_map(result) == {
|
||||
(operation.capability, operation.target_scope): operation.parameters
|
||||
for operation in profile.operations
|
||||
}
|
||||
@@ -87,7 +87,10 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
|
||||
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"])
|
||||
assert any(
|
||||
"座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE"
|
||||
for item in result["validation"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), (
|
||||
@@ -96,9 +99,9 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv
|
||||
("创建一个仅主驾舒适模式", ("driver",)),
|
||||
("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个主副驾座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个云端座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")),
|
||||
("构建一个云端座椅舒适模式", ("passenger",)),
|
||||
("构建一个副驾空调与座椅舒适模式", ("passenger",)),
|
||||
("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)),
|
||||
("构建一个主驾座椅与副驾空调舒适模式", ("driver",)),
|
||||
("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)),
|
||||
))
|
||||
@@ -120,7 +123,6 @@ def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_
|
||||
@pytest.mark.parametrize("message", (
|
||||
"构建一个主驾舒适模式,副驾不要动",
|
||||
"构建一个主驾舒适模式,不要动副驾",
|
||||
"构建一个舒展模式,座椅靠背调整到45%",
|
||||
))
|
||||
def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
|
||||
scene_service, message,
|
||||
@@ -131,6 +133,19 @@ def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def test_unqualified_explicit_seat_requirement_defaults_to_passenger_without_model_additions(scene_service):
|
||||
result = _preview(scene_service, "构建一个舒展模式,座椅靠背调整到45%", execution_mode="dry_run")
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result["mode_profile_draft"]["operation_count"] == 1
|
||||
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_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service):
|
||||
result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%")
|
||||
assert result["status"] == "succeeded"
|
||||
|
||||
@@ -144,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) == 13
|
||||
assert len(templates) == 15
|
||||
assert all(template["assistant_callable"] is True for template in templates)
|
||||
|
||||
with patch.object(
|
||||
@@ -169,11 +169,30 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
|
||||
assert result["orchestration_route"] == "mode_profile_create_preview"
|
||||
draft = result["mode_profile_draft"]
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["operation_count"] == template["operation_count"]
|
||||
assert draft["source_template_id"] == template["id"]
|
||||
assert draft["source_template_revision"] == template["template_revision"]
|
||||
assert "existing_mode_id" not in draft
|
||||
|
||||
expected_requests = plan["requests"]
|
||||
if plan.get("schedule"):
|
||||
expected_schedule = deepcopy(plan["schedule"])
|
||||
for phase in expected_schedule["phases"]:
|
||||
phase["operations"] = [
|
||||
op for op in phase["operations"]
|
||||
if op["domain"] != "seat" or op["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["schedule"] == expected_schedule
|
||||
expected_requests = [
|
||||
{**op, "parameters": [{"name": key, "value": value}
|
||||
for key, value in op["parameters"].items()]}
|
||||
for phase in expected_schedule["phases"] for op in phase["operations"]
|
||||
]
|
||||
else:
|
||||
expected_requests = [
|
||||
request for request in expected_requests
|
||||
if request["domain"] != "seat" or request["target_scope"] == "passenger"
|
||||
]
|
||||
assert draft["operation_count"] == len(expected_requests)
|
||||
expected = {
|
||||
(
|
||||
str(request["capability"]),
|
||||
@@ -187,7 +206,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
|
||||
sort_keys=True,
|
||||
),
|
||||
)
|
||||
for request in plan["requests"]
|
||||
for request in expected_requests
|
||||
}
|
||||
actual = {
|
||||
(
|
||||
@@ -311,7 +330,10 @@ def test_purpose_descriptions_use_a_concise_name_and_zero_model_template_route(
|
||||
assert draft["name"] == expected_name
|
||||
assert draft["generation_source"] == "scene_template_copy"
|
||||
assert draft["source_template_id"] == template_id
|
||||
assert draft["operation_count"] == len(template.operations)
|
||||
assert draft["operation_count"] == sum(
|
||||
operation.domain != "seat" or operation.target_scope == "passenger"
|
||||
for operation in template.operations
|
||||
)
|
||||
assert passed_plans[0]["metadata"]["template_id"] == template_id
|
||||
assert events[0]["engine"] == "n7web-scene-template"
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
@@ -353,7 +375,7 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults(
|
||||
("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"),
|
||||
),
|
||||
)
|
||||
def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call(
|
||||
def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
message: str,
|
||||
expected_name: str,
|
||||
@@ -380,16 +402,13 @@ def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_c
|
||||
for (capability, target), parameters in _action_map(result).items()
|
||||
if capability.startswith("seat.")
|
||||
}
|
||||
assert {target for _capability, target in seat_actions} == {"driver", "passenger"}
|
||||
assert {target for _capability, target in seat_actions} == {"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,
|
||||
}
|
||||
@@ -422,7 +441,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
|
||||
side_effect=AssertionError("scene composition reached hardware execution"),
|
||||
):
|
||||
result = service.preview_native_can(
|
||||
f"构建一个{name}", provider="local", conversation_id="conv-seat-generated",
|
||||
f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
@@ -437,7 +456,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
|
||||
def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
) -> None:
|
||||
service, _catalog, _user_root = hybrid_service
|
||||
@@ -446,8 +465,11 @@ def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard(
|
||||
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["status"] == "succeeded"
|
||||
assert _action_map(result) == {
|
||||
("seat.set_backrest_recline", "passenger"): {"position_pct": 45},
|
||||
}
|
||||
assert result.get("mode_profile_draft")
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
@@ -552,7 +574,7 @@ def test_template_copy_can_be_revised_twice_without_losing_sibling_actions(
|
||||
assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run"
|
||||
assert shade_revised["hardware_execution_allowed"] is False
|
||||
assert final["hardware_execution_allowed"] is False
|
||||
assert len(built_actions) == len(shade_actions) == len(final_actions) == 7
|
||||
assert len(built_actions) == len(shade_actions) == len(final_actions) == 10
|
||||
assert built_actions[shade_key] == {"position_pct": 0}
|
||||
assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100}
|
||||
assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24}
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const web = path.resolve(__dirname, "../../apps/web-console");
|
||||
const assistant = fs.readFileSync(path.join(web, "assistant-chat.js"), "utf8");
|
||||
const app = fs.readFileSync(path.join(web, "app.js"), "utf8");
|
||||
|
||||
function functionSource(source, name) {
|
||||
const start = source.search(new RegExp(`(?: )?(?:async )?function ${name}\\(`));
|
||||
assert.ok(start >= 0, `missing production function ${name}`);
|
||||
const rest = source.slice(start);
|
||||
const next = rest.slice(1).search(/\n(?: )?(?:async )?function /);
|
||||
return next < 0 ? rest : rest.slice(0, next + 1);
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this.listeners = {};
|
||||
this.className = "";
|
||||
this.textContent = "";
|
||||
this.classList = { add: (name) => { this.className += ` ${name}`; } };
|
||||
}
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
setAttribute(name, value) { this.attributes[name] = value; }
|
||||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
querySelectorAll(selector) {
|
||||
return descendants(this).filter((item) => {
|
||||
if (selector.startsWith(".")) return item.className.split(/\s+/).includes(selector.slice(1));
|
||||
const match = selector.match(/^\[data-([\w-]+)(?:="([^"]*)")?\]$/);
|
||||
if (match) {
|
||||
const key = match[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
||||
return key in item.dataset && (match[2] === undefined || item.dataset[key] === match[2]);
|
||||
}
|
||||
return item.tagName === selector;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function descendants(element) {
|
||||
return element.children.flatMap((item) => typeof item === "object" ? [item, ...descendants(item)] : []);
|
||||
}
|
||||
|
||||
function textOf(element) {
|
||||
return [element.textContent, ...descendants(element).map((item) => item.textContent)].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const body = new Element("body");
|
||||
const calls = [];
|
||||
const document = {
|
||||
body,
|
||||
createElement: (tag) => new Element(tag),
|
||||
createTextNode: (text) => Object.assign(new Element("#text"), { textContent: text }),
|
||||
querySelectorAll: (selector) => body.querySelectorAll(selector),
|
||||
};
|
||||
const context = {
|
||||
document, window: {}, CSS: { escape: String },
|
||||
$: (selector, parent = body) => parent.querySelector(selector),
|
||||
rememberModeDraftContext() {}, supersedeModeDraftCards() {}, followLatest() {},
|
||||
normalizeModeName: String,
|
||||
saveModeProfileDraft: (...args) => calls.push({ kind: "save", args }),
|
||||
tryModeProfileDraft: (...args) => calls.push({ kind: "trial", args }),
|
||||
modeSecondsLabel: (value) => String(Number(value) / 1000),
|
||||
LEGACY_DYNAMIC_MODE_IDS: new Set(["rocking_horse", "dynamic_cradle"]),
|
||||
};
|
||||
vm.createContext(context);
|
||||
const constants = app.slice(app.indexOf("const MODE_DOMAIN_LABELS"), app.indexOf("function modeOperationParameters"))
|
||||
+ app.slice(app.indexOf("const CONFIG_CAPABILITY_LABELS"), app.indexOf("function makeConfigElement"));
|
||||
const assistantNames = ["modeDraftRows", "modeDraftScheduleView", "appendModeDraftRows", "appendModeDraftState"];
|
||||
const appNames = ["formatConfigValue", "modeOperationParameters", "formatModeOperationValue", "modeOperationView", "modeTimelineTargetSummary", "modeTimelineStepView", "modeScheduleDurationLabel", "modeScheduleView", "modeExecutionActionViews", "modeScheduleTrialMatches", "modeScheduleExecutionView", "isUserModeProfile", "isDynamicModeProfile", "createModeGalleryCard", "renderModeActionPopover"];
|
||||
vm.runInContext(constants + assistantNames.map((name) => functionSource(assistant, name)).join("\n")
|
||||
+ appNames.map((name) => functionSource(app, name)).join("\n"), context);
|
||||
return { context, body, calls };
|
||||
}
|
||||
|
||||
function operation(capability, parameters, scope = "vehicle") {
|
||||
return { domain: capability.split(".")[0], capability, target_scope: scope, operation: "set", parameters };
|
||||
}
|
||||
|
||||
function schedule(overrides = {}) {
|
||||
return {
|
||||
schema: "cognitive-agent-mode-schedule/v1",
|
||||
repeat_count: 2,
|
||||
phases: [
|
||||
{ id: "warm", name: "快速预热", duration_ms: 60000, operations: [
|
||||
operation("climate.set_temperature", { temperature_c: 28 }),
|
||||
operation("seat.set_driver_massage_native", { action: "mode", mode: "relax" }, "driver"),
|
||||
operation("seat.set_heating", { state: "on", level: 1 }, "passenger"),
|
||||
] },
|
||||
{ id: "comfortable", name: "舒适保持", duration_ms: 30000, operations: [
|
||||
operation("climate.set_temperature", { temperature_c: 24 }),
|
||||
operation("seat.set_driver_massage_native", { action: "off" }, "driver"),
|
||||
operation("seat.set_heating", { state: "off" }, "passenger"),
|
||||
] },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("assistant keeps phases, relative timing, repeat count, total duration and stop guidance", () => {
|
||||
const { context } = harness();
|
||||
const view = context.modeDraftScheduleView({ schedule: schedule() });
|
||||
assert.equal(view.summary, "2 个阶段 · 重复 2 次 · 总时长 3分");
|
||||
assert.equal(view.operationCount, 6);
|
||||
assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1分");
|
||||
assert.equal(view.phases[1].timing, "+1分 开始 · 持续 30秒");
|
||||
assert.match(view.stopHint, /停止动态模式/);
|
||||
assert.match(view.stopHint, /每轮/);
|
||||
});
|
||||
|
||||
test("driver and passenger scopes and changed device values are not collapsed", () => {
|
||||
const { context } = harness();
|
||||
const view = context.modeDraftScheduleView({ schedule: schedule() });
|
||||
assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
|
||||
["空调温度", "28℃"], ["主驾座椅按摩", "放松模式"], ["副驾座椅加热", "开启 · 1档"],
|
||||
]);
|
||||
assert.deepEqual(Array.from(view.phases[1].rows, (row) => [row.label, row.value]), [
|
||||
["空调温度", "24℃"], ["主驾座椅按摩", "关闭"], ["副驾座椅加热", "关闭"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("phase previews never apply whole-instruction zero-gravity grouping", () => {
|
||||
const { context } = harness();
|
||||
const view = context.modeDraftScheduleView({ instruction: "副驾零重力", schedule: schedule({ phases: [{
|
||||
duration_ms: 1500, operations: [
|
||||
operation("seat.set_longitudinal_position", { position_pct: 80 }, "passenger"),
|
||||
operation("seat.set_backrest_recline", { position_pct: 60 }, "passenger"),
|
||||
],
|
||||
}] }) });
|
||||
assert.equal(view.phases[0].rows.length, 2);
|
||||
assert.equal(view.phases[0].name, "阶段 1");
|
||||
assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1.5秒");
|
||||
});
|
||||
|
||||
test("per-zone climate targets are explicit even within the same phase", () => {
|
||||
const { context } = harness();
|
||||
const view = context.modeDraftScheduleView({ schedule: schedule({ phases: [{
|
||||
duration_ms: 2000, operations: [
|
||||
operation("climate.set_temperature", { temperature_c: 26 }, "front_left"),
|
||||
operation("climate.set_temperature", { temperature_c: 24 }, "front_right"),
|
||||
],
|
||||
}] }) });
|
||||
assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
|
||||
["主驾空调温度", "26℃"], ["副驾空调温度", "24℃"],
|
||||
]);
|
||||
});
|
||||
|
||||
for (const [name, value] of [
|
||||
["absent", undefined], ["unknown schema", schedule({ schema: "other" })],
|
||||
["zero repeat", schedule({ repeat_count: 0 })], ["unbounded repeat", schedule({ repeat_count: 21 })],
|
||||
["fractional repeat", schedule({ repeat_count: 1.5 })], ["string repeat", schedule({ repeat_count: "2" })],
|
||||
["empty phases", schedule({ phases: [] })], ["non-array phases", schedule({ phases: {} })],
|
||||
["zero duration", schedule({ phases: [{ duration_ms: 0, operations: [] }] })],
|
||||
["negative duration", schedule({ phases: [{ duration_ms: -1, operations: [] }] })],
|
||||
["invalid duration", schedule({ phases: [{ duration_ms: Infinity, operations: [] }] })],
|
||||
["missing operations", schedule({ phases: [{ duration_ms: 1000 }] })],
|
||||
]) {
|
||||
test(`schedule formatter safely rejects ${name}`, () => {
|
||||
const { context } = harness();
|
||||
assert.equal(context.modeDraftScheduleView({ schedule: value }), null);
|
||||
assert.equal(context.modeScheduleView({ schedule: value }), null);
|
||||
});
|
||||
}
|
||||
|
||||
test("hover formatter prefers stage operations over an overlapping flat projection", () => {
|
||||
const { context } = harness();
|
||||
const mode = { schedule: schedule(), operations: [operation("climate.set_temperature", { temperature_c: 24 })] };
|
||||
const rows = context.modeExecutionActionViews(mode);
|
||||
assert.equal(rows.length, 6);
|
||||
assert.equal(rows[0].phase, "阶段 1:快速预热");
|
||||
assert.equal(rows[3].phase, "阶段 2:舒适保持");
|
||||
assert.equal(rows[0].parameters, "目标温度:28 ℃");
|
||||
assert.equal(rows[3].parameters, "目标温度:24 ℃");
|
||||
assert.equal(rows[1].target, "主驾");
|
||||
assert.equal(rows[2].target, "副驾");
|
||||
assert.equal(context.modeScheduleView(mode).summary, context.modeDraftScheduleView(mode).summary);
|
||||
assert.equal(context.isDynamicModeProfile({ ...mode, behavior: "static" }), true);
|
||||
});
|
||||
|
||||
test("legacy static modes and seat ModeTimeline retain their original action semantics", () => {
|
||||
const { context } = harness();
|
||||
const operations = [operation("climate.set_temperature", { temperature_c: 24 })];
|
||||
assert.equal(context.modeExecutionActionViews({ operations }).length, 1);
|
||||
assert.equal(context.isDynamicModeProfile({ operations, behavior: "static" }), false);
|
||||
const mode = { operations, timeline: {
|
||||
entry: { targets: { backrest_recline: { position_pct: 40 } }, timeout_ms: 15000 },
|
||||
phases: [{ name: "抬起", targets: { backrest_recline: { position_pct: 50 } }, duration_ms: 2000 }],
|
||||
} };
|
||||
const views = context.modeExecutionActionViews(mode);
|
||||
assert.equal(views.length, 3);
|
||||
assert.equal(views[1].action, "进入动态模式初始位置");
|
||||
assert.match(views[1].parameters, /保护上限 15s/);
|
||||
assert.equal(views[2].action, "循环阶段 1:抬起");
|
||||
assert.match(views[2].parameters, /持续 2s/);
|
||||
assert.equal(context.isDynamicModeProfile(mode), true);
|
||||
});
|
||||
|
||||
test("assistant renders a stage card, preserving opaque save/trial request context", () => {
|
||||
const { context, body, calls } = harness();
|
||||
const message = { body: new Element("div"), article: new Element("article") };
|
||||
body.append(message.body);
|
||||
const draft = { name: "上车预热模式", source_request_id: "opaque-preview-id", schedule: schedule(), operation_count: 6 };
|
||||
const flatActions = [];
|
||||
context.appendModeDraftState(message, draft, flatActions);
|
||||
const card = message.body.children[0];
|
||||
assert.equal(card.querySelectorAll(".assistant-mode-schedule-phase").length, 2);
|
||||
assert.equal(card.querySelectorAll(".assistant-mode-draft-row").length, 6);
|
||||
assert.equal(card.querySelector(".assistant-mode-draft-badge").textContent, "6 项动作");
|
||||
assert.match(textOf(card), /28℃/);
|
||||
assert.match(textOf(card), /24℃/);
|
||||
assert.match(textOf(card), /重复 2 次 · 总时长 3分/);
|
||||
assert.match(textOf(card), /停止动态模式/);
|
||||
card.querySelector(".assistant-mode-draft-save").listeners.click();
|
||||
card.querySelector(".assistant-mode-draft-trial").listeners.click();
|
||||
assert.equal(calls[0].kind, "save");
|
||||
assert.equal(calls[0].args[2], draft);
|
||||
assert.equal(calls[1].kind, "trial");
|
||||
assert.equal(calls[1].args[1], draft);
|
||||
assert.equal(calls[1].args[2], flatActions);
|
||||
});
|
||||
|
||||
test("management schedule details are independently expandable without executing the card", () => {
|
||||
const { context } = harness();
|
||||
const card = context.createModeGalleryCard({ id: "custom-warm", name: "上车预热模式", origin: "user", schedule: schedule() });
|
||||
const details = card.querySelector(".mode-gallery-schedule-details");
|
||||
assert.ok(details);
|
||||
assert.equal(details.querySelector("summary").textContent, "查看分阶段执行动作");
|
||||
assert.equal(details.querySelectorAll("li").length, 2);
|
||||
assert.equal(details.querySelectorAll("button").length, 0);
|
||||
assert.match(textOf(details), /快速预热/);
|
||||
assert.match(textOf(details), /舒适保持/);
|
||||
assert.match(textOf(details), /主驾 · 设置主驾座椅按摩:动作:切换按摩模式,工作模式:放松/);
|
||||
assert.match(textOf(card.querySelector(".mode-gallery-card-meta")), /重复 2 次 · 总时长 3分/);
|
||||
});
|
||||
|
||||
test("popover presents stage timings and stop guidance without interpreting text as HTML", () => {
|
||||
const { context } = harness();
|
||||
const popover = new Element("aside");
|
||||
for (const [tag, key] of [["strong", "modeActionTitle"], ["b", "modeActionCount"], ["ol", "modeActionList"]]) {
|
||||
const item = new Element(tag);
|
||||
item.dataset[key] = "true";
|
||||
popover.append(item);
|
||||
}
|
||||
context.ensureModeActionPopover = () => popover;
|
||||
const value = schedule();
|
||||
value.phases[0].name = '<img src=x onerror="alert(1)">';
|
||||
context.renderModeActionPopover({ name: "测试动态模式", schedule: value });
|
||||
assert.equal(popover.querySelectorAll(".mode-chip-action-copy").length, 6);
|
||||
assert.equal(popover.querySelectorAll(".mode-schedule-popover-phase").length, 6);
|
||||
assert.match(textOf(popover), /阶段 1:<img/);
|
||||
assert.match(textOf(popover), /停止动态模式/);
|
||||
assert.match(textOf(popover), /\+1分 开始 · 持续 30秒/);
|
||||
assert.equal(popover.querySelectorAll("img").length, 0);
|
||||
});
|
||||
|
||||
test("execution distinguishes accepted schedule, virtual trial and stopped device state", () => {
|
||||
const { context } = harness();
|
||||
const running = context.modeScheduleExecutionView({ status: "running", schedule_execution: { status: "running" } });
|
||||
assert.equal(running.state, "processing");
|
||||
assert.equal(running.title, "动态时间轴已启动");
|
||||
assert.doesNotMatch(running.title, /完成/);
|
||||
const trial = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 90000 } });
|
||||
assert.equal(trial.title, "动态时间轴模拟试运行通过");
|
||||
assert.match(trial.detail, /未向车辆下发控制/);
|
||||
const stop = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "cancelled" } });
|
||||
assert.equal(stop.title, "动态时间轴已停止");
|
||||
assert.match(stop.detail, /不会自动回滚/);
|
||||
assert.equal(context.modeScheduleExecutionView({ status: "failed", schedule_execution: { status: "failed" } }), null);
|
||||
});
|
||||
|
||||
test("virtual trial verifies temporal structure, not just a flattened action set", () => {
|
||||
const { context } = harness();
|
||||
const draft = { schedule: schedule() };
|
||||
const response = () => ({ schedule: schedule(), schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 180000 } });
|
||||
assert.equal(context.modeScheduleTrialMatches(draft, response()), true);
|
||||
assert.equal(context.modeScheduleTrialMatches({}, {}), true, "legacy static drafts remain unchanged");
|
||||
for (const mutate of [
|
||||
(value) => { value.schedule.repeat_count = 1; },
|
||||
(value) => { value.schedule.phases.reverse(); },
|
||||
(value) => { value.schedule.phases[0].duration_ms = 45000; value.schedule.phases[1].duration_ms = 45000; },
|
||||
(value) => { value.schedule.phases[0].operations[0].parameters.temperature_c = 26; },
|
||||
(value) => { value.schedule.phases[0].operations[1].target_scope = "passenger"; },
|
||||
(value) => { value.schedule_execution.virtual_elapsed_ms = 90000; },
|
||||
(value) => { value.schedule_execution.completed = false; },
|
||||
(value) => { value.schedule_execution.status = "running"; },
|
||||
(value) => { delete value.schedule; },
|
||||
]) {
|
||||
const value = response();
|
||||
mutate(value);
|
||||
assert.equal(context.modeScheduleTrialMatches(draft, value), false);
|
||||
}
|
||||
});
|
||||
|
||||
function pollHarness(statuses = []) {
|
||||
const { context } = harness();
|
||||
const timers = new Map();
|
||||
const requests = [];
|
||||
const updates = [];
|
||||
const modeUpdates = [];
|
||||
const vehicle = { value: "denza_n7" };
|
||||
let timerId = 0;
|
||||
context.activeSchedulePoll = null;
|
||||
context.AbortController = AbortController;
|
||||
context.conversation = "conversation-1";
|
||||
context.conversationId = () => context.conversation;
|
||||
context.setActionStatus = (_message, copy, state) => updates.push({ copy, state });
|
||||
const originalQuery = context.$;
|
||||
context.$ = (selector, parent) => selector === "#vehicleSelect" ? vehicle : originalQuery(selector, parent);
|
||||
context.window.setTimeout = (callback, delay) => {
|
||||
assert.equal(delay, 2000);
|
||||
timers.set(++timerId, callback);
|
||||
return timerId;
|
||||
};
|
||||
context.window.clearTimeout = (id) => timers.delete(id);
|
||||
context.window.CognitiveAgentConsole = { updateScheduleExecutionStatus: (...args) => modeUpdates.push(args) };
|
||||
context.window.CognitiveAgentNetwork = { request: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
const next = statuses.shift();
|
||||
if (next instanceof Error) throw next;
|
||||
if (typeof next === "function") return next();
|
||||
return { ok: true, status: 200, json: async () => next };
|
||||
} };
|
||||
vm.runInContext(["stopScheduleStatusPoll", "startScheduleStatusPoll"].map((name) => functionSource(assistant, name)).join("\n"), context);
|
||||
const start = (session = "session-1") => context.startScheduleStatusPoll({
|
||||
vehicle_id: "denza_n7", mode_profile: { id: "test-mode" }, schedule_execution: { session_id: session },
|
||||
}, {});
|
||||
const tick = async () => {
|
||||
const [id, callback] = timers.entries().next().value || [];
|
||||
assert.ok(callback, "poll timer must be scheduled");
|
||||
timers.delete(id);
|
||||
await callback();
|
||||
};
|
||||
return { context, timers, requests, updates, modeUpdates, vehicle, start, tick };
|
||||
}
|
||||
|
||||
test("polls only the active vehicle using GET and updates the original status until completion", async () => {
|
||||
const h = pollHarness([
|
||||
{ status: "running", session_id: "session-1", vehicle_id: "denza_n7", phase_index: 1, repeat_index: 0 },
|
||||
{ status: "completed", session_id: "session-1", completed: true },
|
||||
]);
|
||||
h.start();
|
||||
assert.equal(h.requests.length, 0);
|
||||
await h.tick();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].url, "/api/modes/schedule/status?vehicle_id=denza_n7");
|
||||
assert.equal(h.requests[0].options.method, "GET");
|
||||
assert.equal(h.requests[0].options.body, undefined);
|
||||
assert.match(h.updates[0].copy, /第 1 轮 · 阶段 2/);
|
||||
assert.equal(h.updates[0].state, "working");
|
||||
await h.tick();
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.context.activeSchedulePoll, null);
|
||||
assert.match(h.updates[1].copy, /时间轴已结束/);
|
||||
assert.deepEqual(h.modeUpdates, [["test-mode", "completed"]]);
|
||||
});
|
||||
|
||||
for (const status of ["failed", "cancelled", "stopped", "idle"]) {
|
||||
test(`terminal ${status} ends polling without a new request or action`, async () => {
|
||||
const h = pollHarness([{ status, session_id: "session-1", error: "阶段下发失败" }]);
|
||||
h.start();
|
||||
await h.tick();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.updates[0].state, status === "failed" ? "failed" : "success");
|
||||
if (status === "failed") assert.match(h.updates[0].copy, /阶段下发失败/);
|
||||
if (["cancelled", "stopped"].includes(status)) assert.match(h.updates[0].copy, /已停止/);
|
||||
});
|
||||
}
|
||||
|
||||
test("a transient status failure is visible and retried, not reported as execution failure", async () => {
|
||||
const h = pollHarness([new Error("offline"), { status: "completed", completed: true }]);
|
||||
h.start();
|
||||
await h.tick();
|
||||
assert.match(h.updates[0].copy, /暂时无法获取时间轴状态/);
|
||||
assert.equal(h.updates[0].state, "working");
|
||||
assert.equal(h.timers.size, 1);
|
||||
await h.tick();
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test("vehicle or conversation changes terminate observation before another request", async () => {
|
||||
for (const kind of ["vehicle", "conversation"]) {
|
||||
const h = pollHarness();
|
||||
h.start();
|
||||
if (kind === "vehicle") h.vehicle.value = "another-vehicle";
|
||||
else h.context.conversation = "conversation-2";
|
||||
await h.tick();
|
||||
assert.equal(h.requests.length, 0);
|
||||
assert.equal(h.context.activeSchedulePoll, null);
|
||||
assert.equal(h.timers.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("replacement session does not apply a different schedule outcome to the old message", async () => {
|
||||
const h = pollHarness([{ status: "failed", session_id: "another-session" }]);
|
||||
h.start();
|
||||
await h.tick();
|
||||
assert.match(h.updates[0].copy, /已被新的运行任务替换/);
|
||||
assert.doesNotMatch(h.updates[0].copy, /执行失败/);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.modeUpdates.length, 0);
|
||||
});
|
||||
|
||||
test("stop aborts an in-flight status read and ignores its late completion", async () => {
|
||||
let resolve;
|
||||
const response = new Promise((done) => { resolve = done; });
|
||||
const h = pollHarness([() => response]);
|
||||
h.start();
|
||||
const pending = h.tick();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.timers.size, 0, "an in-flight read cannot overlap another timer");
|
||||
h.context.stopScheduleStatusPoll();
|
||||
assert.equal(h.requests[0].options.signal.aborted, true);
|
||||
resolve({ ok: true, status: 200, json: async () => ({ status: "completed", completed: true }) });
|
||||
await pending;
|
||||
assert.equal(h.updates.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test("cleanup is wired to new conversation, vehicle changes and page exit", () => {
|
||||
assert.match(assistant, /if \(!target\?\.closest\("#newConversation"\)\) return;\s+stopScheduleStatusPoll\(\);/);
|
||||
assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/);
|
||||
assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/);
|
||||
});
|
||||
@@ -284,7 +284,7 @@ def test_mode_catalog_projects_bounded_action_details_without_free_form_reasons(
|
||||
snapshot = _catalog(vehicle_service).snapshot()
|
||||
office = next(item for item in snapshot["modes"] if item["id"] == "office")
|
||||
|
||||
assert len(office["operations"]) == office["operation_count"] == 10
|
||||
assert len(office["operations"]) == office["operation_count"] == 12
|
||||
temperature = next(
|
||||
item
|
||||
for item in office["operations"]
|
||||
@@ -327,7 +327,7 @@ def test_saved_mode_detail_fast_answer_is_exact_read_only_and_natural(
|
||||
"遮阳帘",
|
||||
"氛围灯",
|
||||
"副驾空调温度设为25℃",
|
||||
"共10项动作",
|
||||
"共12项动作",
|
||||
"本次仅查询,未下发车辆控制",
|
||||
)
|
||||
)
|
||||
@@ -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) == 13
|
||||
assert len(templates) == 15
|
||||
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-27"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-27"' in html
|
||||
assert 'href="/assistant-chat.css?v=codex-assistant-28"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-28"' 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-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
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
|
||||
assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-28"' in service_worker
|
||||
|
||||
|
||||
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
||||
|
||||
@@ -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-27") < html.index(
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-28") < html.index(
|
||||
"/energy-monitor.js?v=energy-monitor-4"
|
||||
)
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
|
||||
|
||||
|
||||
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
|
||||
|
||||
@@ -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-v278"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker
|
||||
|
||||
|
||||
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_creation_seat_policy import (
|
||||
ModeCreationSeatPolicyError,
|
||||
creation_seat_scopes,
|
||||
project_seed_operations,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("text", "expected"), [
|
||||
("构建一个阅读模式", {"passenger"}),
|
||||
("构建一个座椅动态模式", {"passenger"}),
|
||||
("构建一个主驾阅读模式", {"driver"}),
|
||||
("为驾驶员构建一个舒适模式", {"driver"}),
|
||||
("构建一个副驾阅读模式", {"passenger"}),
|
||||
("构建一个副驾驶座椅舒适模式", {"passenger"}),
|
||||
("构建一个副驾驶席阅读模式", {"passenger"}),
|
||||
("为副驾驶位构建阅读模式", {"passenger"}),
|
||||
("构建一个主副驾阅读模式", {"driver", "passenger"}),
|
||||
("构建一个双座舒适模式", {"driver", "passenger"}),
|
||||
("构建一个前排两座舒适模式", {"driver", "passenger"}),
|
||||
("构建一个两个座椅阅读模式", {"driver", "passenger"}),
|
||||
("构建一个两张座椅阅读模式", {"driver", "passenger"}),
|
||||
("构建一个两座椅阅读模式", {"driver", "passenger"}),
|
||||
("构建一个主驾空调舒适模式", {"passenger"}),
|
||||
("构建一个模式,主驾温度26度", {"passenger"}),
|
||||
("构建一个副驾座椅舒适模式,主驾空调26度", {"passenger"}),
|
||||
("构建一个主驾舒适模式,副驾空调26度", {"driver"}),
|
||||
("构建一个阅读模式,不要动主驾", {"passenger"}),
|
||||
("构建一个主驾阅读模式,副驾不要动", {"driver"}),
|
||||
("构建一个模式,只联动副驾,不要联动主驾", {"passenger"}),
|
||||
("构建一个后排座椅舒适模式", {"unsupported"}),
|
||||
("构建一个未知座位模式", {"unsupported"}),
|
||||
("构建一个任意座位模式", {"unsupported"}),
|
||||
])
|
||||
def test_creation_seat_selection(text, expected):
|
||||
assert creation_seat_scopes(text) == expected
|
||||
|
||||
|
||||
def test_seed_projection_is_non_mutating_and_does_not_borrow_capabilities():
|
||||
seed = [
|
||||
{"domain": "seat", "capability": "seat.set_driver_ventilation_native", "target_scope": "driver", "parameters": {"level": 2}},
|
||||
{"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger", "parameters": {"position_pct": 45}},
|
||||
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "parameters": {"temperature_c": 24}},
|
||||
]
|
||||
original = deepcopy(seed)
|
||||
projected = project_seed_operations(seed, frozenset({"driver"}))
|
||||
assert projected == [seed[0], seed[2]]
|
||||
projected[0]["parameters"]["level"] = 1
|
||||
assert seed == original
|
||||
with pytest.raises(ModeCreationSeatPolicyError):
|
||||
project_seed_operations(seed[1:], frozenset({"driver"}))
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_catalog_exact_mode_match_count,
|
||||
_catalog_exact_scene_template_plan,
|
||||
)
|
||||
from cognitive_agent.application.mode_profile_intent import (
|
||||
mode_creation_source_name,
|
||||
parse_mode_profile_create,
|
||||
)
|
||||
from cognitive_agent.application.scene_composer import (
|
||||
SCENE_COMPOSER_INSTRUCTIONS,
|
||||
SCHEDULE_COMPOSER_INSTRUCTIONS,
|
||||
scene_context_for_mode_creation,
|
||||
)
|
||||
from cognitive_agent.application.scene_templates import SceneTemplateRegistry
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "name"), [
|
||||
("构建主驾阅读模式", "主驾阅读模式"),
|
||||
("为主驾构建一个阅读模式", "主驾阅读模式"),
|
||||
("帮我为副驾驶构建一个阅读模式", "副驾阅读模式"),
|
||||
("给驾驶席创建一个阅读模式", "主驾阅读模式"),
|
||||
("可以帮我为主驾构建一个阅读模式吗?", "主驾阅读模式"),
|
||||
("构建一个阅读模式,针对主驾", "主驾阅读模式"),
|
||||
("构建一个阅读模式,仅联动副驾座椅", "副驾阅读模式"),
|
||||
("构建一个阅读模式,给主驾使用,先看看", "主驾阅读模式"),
|
||||
("构建一个主驾驶座椅专属的阅读模式", "主驾阅读模式"),
|
||||
("构建一个主驾适合看书的功能模式", "主驾看书模式"),
|
||||
("构建一个适合主驾看书的功能模式", "主驾看书模式"),
|
||||
("为主驾和副驾创建一个阅读模式", "主副驾阅读模式"),
|
||||
("构建一个双座阅读模式", "主副驾阅读模式"),
|
||||
("构建一个阅读模式,针对双座", "主副驾阅读模式"),
|
||||
])
|
||||
def test_scoped_creation_keeps_distinct_name_and_fast_template_source(message, name):
|
||||
assert parse_mode_profile_create(message) == (name, "")
|
||||
registry = SceneTemplateRegistry(ROOT / "config" / "scene_templates")
|
||||
template = registry.resolve_exact_name(name)
|
||||
assert template is not None and template.id == "reading"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "expected"), [
|
||||
("为主驾构建一个阅读模式,空调调整到26度", ("主驾阅读模式", "空调调整到26度")),
|
||||
("构建一个阅读模式,针对主驾,空调调整到26度", ("阅读模式", "针对主驾,空调调整到26度")),
|
||||
("构建一个主驾阅读模式,靠背向后移动3s", ("主驾阅读模式", "靠背向后移动3s")),
|
||||
("构建一个名为主驾驶阅读模式", ("主驾驶阅读模式", "")),
|
||||
("构建一个“阅读模式”,针对主驾", ("阅读模式", "")),
|
||||
])
|
||||
def test_explicit_names_and_actual_device_instructions_are_preserved(message, expected):
|
||||
assert parse_mode_profile_create(message) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"不要为主驾构建一个阅读模式", "如何为主驾构建一个阅读模式?",
|
||||
"为主驾构建一个阅读模式,但不要执行", "引用为主驾构建一个阅读模式",
|
||||
])
|
||||
def test_scope_grammar_does_not_bypass_authorization(message):
|
||||
assert parse_mode_profile_create(message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["后排阅读模式", "左后排阅读模式", "第三排阅读模式", "未知座位阅读模式"])
|
||||
def test_unsupported_seats_never_alias_to_front_seat_templates(name):
|
||||
assert mode_creation_source_name(name) == name
|
||||
assert SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "source", "template_id"), [
|
||||
("主驾座椅舒适模式", "座椅舒适模式", "seat-comfort"),
|
||||
("主驾和副驾座椅舒适模式", "座椅舒适模式", "seat-comfort"),
|
||||
("主副驾座椅动态模式", "座椅动态模式", "seat-dynamic"),
|
||||
("副驾座椅的动态模式", "座椅的动态模式", "seat-dynamic"),
|
||||
])
|
||||
def test_seat_word_is_preserved_when_it_is_the_template_subject(name, source, template_id):
|
||||
assert mode_creation_source_name(name) == source
|
||||
template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name)
|
||||
assert template is not None and template.id == template_id
|
||||
|
||||
|
||||
def test_catalog_seed_fallback_is_exact_and_preserves_explicit_match_priority():
|
||||
template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").get_template("reading").public_dict(include_plan=True)
|
||||
template["assistant_callable"] = True
|
||||
catalog = {
|
||||
"source_status": {"scene_templates": "ready", "modes": "ready"},
|
||||
"scene_templates": [template],
|
||||
"modes": [{"id": "office", "name": "办公模式"}],
|
||||
}
|
||||
assert _catalog_exact_scene_template_plan(catalog, "主驾阅读模式") == template["plan"]
|
||||
assert _catalog_exact_scene_template_plan(catalog, "后排阅读模式") is None
|
||||
assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 1
|
||||
catalog["modes"].extend([
|
||||
{"id": "driver-office-a", "name": "主驾办公模式"},
|
||||
{"id": "driver-office-b", "name": "主驾办公模式"},
|
||||
])
|
||||
assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "expected"), [
|
||||
("构建一个神游模式", {"passenger"}),
|
||||
("构建一个主驾神游模式", {"driver", "front_left"}),
|
||||
("构建一个主副驾神游模式", {"driver", "front_left", "passenger"}),
|
||||
("构建一个神游模式,主驾空调26度", {"passenger"}),
|
||||
("构建一个后排神游模式", set()),
|
||||
])
|
||||
def test_model_context_scopes_only_seat_capabilities_and_does_not_mutate_source(message, expected):
|
||||
context = {
|
||||
"composition_revision": "revision", "vehicle": {"id": "denza_n7"},
|
||||
"capabilities": [
|
||||
{"id": "seat.driver", "domain": "seat", "target_scopes": ["driver", "front_left"]},
|
||||
{"id": "seat.passenger", "domain": "seat", "target_scopes": ["passenger"]},
|
||||
{"id": "climate.zone", "domain": "climate", "target_scopes": ["driver", "passenger"]},
|
||||
{"id": "roof.shade", "domain": "roof", "target_scopes": ["vehicle"]},
|
||||
],
|
||||
}
|
||||
original = deepcopy(context)
|
||||
projected = scene_context_for_mode_creation(context, message)
|
||||
scopes = {scope for capability in projected["capabilities"] if capability["domain"] == "seat"
|
||||
for scope in capability["target_scopes"]}
|
||||
assert scopes == expected
|
||||
assert [capability for capability in projected["capabilities"] if capability["domain"] != "seat"] == original["capabilities"][2:]
|
||||
assert context == original
|
||||
assert projected["seat_scope_policy"]["default_scope"] == "passenger"
|
||||
|
||||
|
||||
def test_static_and_dynamic_prompts_use_one_selected_seat():
|
||||
assert "defaults to the passenger seat only" in SCENE_COMPOSER_INSTRUCTIONS
|
||||
assert "retarget a passenger capability to the driver" in SCENE_COMPOSER_INSTRUCTIONS
|
||||
assert "selected seat scope in every phase" in SCHEDULE_COMPOSER_INSTRUCTIONS
|
||||
|
||||
|
||||
def test_identical_explicit_mode_names_do_not_share_cached_plans_across_seats():
|
||||
context = {"composition_revision": "revision", "vehicle": {"id": "denza_n7"}, "capabilities": []}
|
||||
# Explicitly named drafts may intentionally keep the same display name even
|
||||
# when the outer request selects a different seat.
|
||||
driver = scene_context_for_mode_creation(context, "为主驾构建一个名为神游模式")
|
||||
passenger = scene_context_for_mode_creation(context, "为副驾构建一个名为神游模式")
|
||||
driver_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=driver)
|
||||
passenger_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=passenger)
|
||||
assert driver_key != passenger_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seat", [
|
||||
"两个座椅", "两张座椅", "两座椅", "两座", "两个座位", "前排两座",
|
||||
"前排两座椅", "前排两个座椅", "前排两张座椅", "双座", "双座椅",
|
||||
])
|
||||
@pytest.mark.parametrize("pattern", ["构建一个{}阅读模式", "为{}构建一个阅读模式", "构建一个阅读模式,针对{}"])
|
||||
def test_all_two_seat_wordings_have_one_canonical_name(seat, pattern):
|
||||
message = pattern.format(seat)
|
||||
assert parse_mode_profile_create(message) == ("主副驾阅读模式", "")
|
||||
assert mode_creation_source_name(f"{seat}阅读模式") == "阅读模式"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个主驾阅读模式,针对副驾", "构建一个副驾阅读模式,针对主驾",
|
||||
"为主驾构建一个副驾阅读模式", "为副驾构建一个主驾阅读模式",
|
||||
"构建一个主副驾阅读模式,只针对主驾", "构建一个主驾阅读模式,针对双座",
|
||||
"为主驾构建一个名为副驾阅读模式", "构建一个“副驾阅读模式”,针对主驾",
|
||||
])
|
||||
def test_conflicting_seat_selection_cannot_silently_become_a_two_seat_creation(message):
|
||||
assert parse_mode_profile_create(message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "source"), [
|
||||
("副驾驶座椅阅读模式", "阅读模式"), ("主驾座椅的阅读模式", "阅读模式"),
|
||||
("主驾座椅办公模式", "办公模式"), ("副驾座位的观影模式", "观影模式"),
|
||||
("副驾座椅看书模式", "看书模式"), ("主驾座椅露营模式", "露营模式"),
|
||||
("主驾座椅舒适模式", "座椅舒适模式"), ("副驾座椅动态模式", "座椅动态模式"),
|
||||
("乘客座椅舒适模式", "座椅舒适模式"), ("两个座椅的动态模式", "座椅动态模式"),
|
||||
("两张座椅舒适模式", "座椅舒适模式"), ("前排两座动态模式", "座椅动态模式"),
|
||||
])
|
||||
def test_seat_location_wrappers_do_not_hide_generic_scene_sources_or_actual_seat_topics(name, source):
|
||||
assert mode_creation_source_name(name) == source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"构建一个两个座椅的动态模式", "构建一个两张座椅的动态模式", "构建一个前排两座动态模式",
|
||||
])
|
||||
def test_double_seat_dynamic_topic_is_not_lost_with_the_scope_qualifier(message):
|
||||
assert parse_mode_profile_create(message) == ("主副驾座椅动态模式", "")
|
||||
@@ -46,8 +46,18 @@ def test_runtime_mode_catalog_exposes_safe_executable_action_details() -> None:
|
||||
if mode["id"] == "office"
|
||||
)
|
||||
|
||||
assert len(office["operations"]) == office["operation_count"] == 10
|
||||
assert len(office["operations"]) == office["operation_count"] == 12
|
||||
assert office["operations"][0] == {
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_driver_ventilation_native",
|
||||
"target_scope": "driver",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on", "level": 2},
|
||||
}
|
||||
assert next(
|
||||
operation for operation in office["operations"]
|
||||
if operation["capability"] == "seat.set_longitudinal_position"
|
||||
) == {
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_longitudinal_position",
|
||||
"target_scope": "passenger",
|
||||
@@ -237,7 +247,7 @@ def test_top_mode_action_formatter_uses_device_target_action_and_parameter_value
|
||||
actions = json.loads(completed.stdout)
|
||||
|
||||
assert len(actions) == office["operation_count"]
|
||||
assert actions[0] == {
|
||||
assert next(action for action in actions if action["action"] == "调节座椅前后位置") == {
|
||||
"device": "座椅",
|
||||
"target": "副驾",
|
||||
"action": "调节座椅前后位置",
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from queue import Queue
|
||||
from threading import Event, Lock, current_thread
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_schedule import (
|
||||
MODE_SCHEDULE_SCHEMA,
|
||||
ModeSchedule,
|
||||
ModeSchedulePhase,
|
||||
ModeScheduleRunner,
|
||||
)
|
||||
|
||||
|
||||
def operation(capability="climate.set_temperature", target="whole_vehicle", **parameters):
|
||||
return {
|
||||
"domain": capability.split(".")[0], "capability": capability,
|
||||
"target_scope": target, "operation": "set",
|
||||
"parameters": parameters or {"temperature_c": 24}, "reason": "舒适阶段",
|
||||
}
|
||||
|
||||
|
||||
def document(*, repeat=1, duration=1000):
|
||||
return {
|
||||
"schema": MODE_SCHEDULE_SCHEMA, "repeat_count": repeat,
|
||||
"phases": [
|
||||
{"id": "warmup", "name": "预热", "duration_ms": duration,
|
||||
"operations": [operation(temperature_c=28)]},
|
||||
{"id": "comfort", "name": "舒适", "duration_ms": duration,
|
||||
"operations": [operation(temperature_c=24)]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_round_trip_has_independent_parameters_and_counts():
|
||||
source = document(repeat=3, duration=1500)
|
||||
schedule = ModeSchedule.from_dict(source)
|
||||
assert schedule.to_dict() == source
|
||||
assert schedule.total_duration_ms == 9000
|
||||
assert schedule.operation_count == 2
|
||||
source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 10
|
||||
returned = schedule.to_dict()
|
||||
returned["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 11
|
||||
assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, [], "schedule", 1, True])
|
||||
def test_schedule_root_must_be_object(value):
|
||||
with pytest.raises(ValueError):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["schema", "repeat_count", "phases"])
|
||||
def test_schedule_requires_all_contract_fields(field):
|
||||
value = document()
|
||||
del value[field]
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 21, -1, 1.0, "2", True, False, None, float("nan")])
|
||||
def test_repeat_count_is_a_bounded_non_boolean_integer(value):
|
||||
with pytest.raises(ValueError, match="repeat_count"):
|
||||
ModeSchedule.from_dict(document(repeat=value))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 999, 900001, 1000.0, "1000", True, None, float("inf")])
|
||||
def test_phase_duration_is_a_bounded_non_boolean_integer(value):
|
||||
with pytest.raises(ValueError, match="duration_ms"):
|
||||
ModeSchedule.from_dict(document(duration=value))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 1, 9])
|
||||
def test_phase_count_bounds(count):
|
||||
value = document()
|
||||
value["phases"] = [dict(value["phases"][0], id=f"phase-{index}") for index in range(count)]
|
||||
with pytest.raises(ValueError, match="2 to 8"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
def test_total_duration_includes_repeats_and_enforces_one_hour():
|
||||
assert ModeSchedule.from_dict(document(repeat=2, duration=900000)).total_duration_ms == 3600000
|
||||
with pytest.raises(ValueError, match="60 minutes"):
|
||||
ModeSchedule.from_dict(document(repeat=3, duration=900000))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [(), ("phases", 0), ("phases", 0, "operations", 0)])
|
||||
def test_unknown_fields_rejected_at_every_contract_level(path):
|
||||
value = document()
|
||||
target = value
|
||||
for key in path:
|
||||
target = target[key]
|
||||
target["untrusted_instruction"] = "bypass validation"
|
||||
with pytest.raises(ValueError, match="unknown"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
def test_non_string_object_keys_are_rejected():
|
||||
value = document()
|
||||
value[1] = "unknown"
|
||||
with pytest.raises(ValueError, match="string keys"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema", ["v2", "", None, 1, True])
|
||||
def test_schema_is_exact(schema):
|
||||
value = document()
|
||||
value["schema"] = schema
|
||||
with pytest.raises(ValueError, match="schema"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["id", "name", "duration_ms", "operations"])
|
||||
def test_phase_requires_all_contract_fields(field):
|
||||
value = document()
|
||||
del value["phases"][0][field]
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase_id", ["", "Bad ID", "hello.world", "座椅", "x" * 65, 5])
|
||||
def test_phase_ids_are_bounded_identifiers(phase_id):
|
||||
value = document()
|
||||
value["phases"][0]["id"] = phase_id
|
||||
with pytest.raises(ValueError):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
def test_duplicate_phase_ids_rejected():
|
||||
value = document()
|
||||
value["phases"][1]["id"] = value["phases"][0]["id"]
|
||||
with pytest.raises(ValueError, match="ids must be unique"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [[], [operation()] * 13, {}, "operations"])
|
||||
def test_operation_count_and_array_contract(value):
|
||||
source = document()
|
||||
source["phases"][0]["operations"] = value
|
||||
with pytest.raises(ValueError):
|
||||
ModeSchedule.from_dict(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("capability", [
|
||||
"seat.set_driver_axis_native", "seat.set_driver_position_native", "seat.move",
|
||||
"seat.apply_dynamic_mode", "door.set_vehicle_lock", "window.set_motion", "custom.anything",
|
||||
])
|
||||
def test_only_stable_scene_composable_capabilities_allowed(capability):
|
||||
value = document()
|
||||
value["phases"][0]["operations"] = [operation(capability)]
|
||||
with pytest.raises(ValueError, match="stable"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["toggle", "increase", "decrease", "activate", "apply", None, True])
|
||||
def test_relative_or_non_set_operation_types_rejected(kind):
|
||||
value = document()
|
||||
value["phases"][0]["operations"][0]["operation"] = kind
|
||||
with pytest.raises(ValueError, match="absolute set"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["duration_ms", "duration", "seconds", "direction", "delta", "relative", "axis", "toggle"])
|
||||
def test_phase_holds_cannot_be_smuggled_as_axis_motion(name):
|
||||
value = document()
|
||||
value["phases"][0]["operations"][0]["parameters"][name] = 1000
|
||||
with pytest.raises(ValueError, match="relative, motion or timing"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), {}, [], None])
|
||||
def test_non_json_or_non_scalar_parameters_rejected(value):
|
||||
source = document()
|
||||
source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = value
|
||||
with pytest.raises(ValueError):
|
||||
ModeSchedule.from_dict(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["toggle", "relative", "increase", "decrease"])
|
||||
def test_relative_parameter_values_rejected(value):
|
||||
source = document()
|
||||
source["phases"][0]["operations"][0]["parameters"]["state"] = value
|
||||
with pytest.raises(ValueError, match="relative state"):
|
||||
ModeSchedule.from_dict(source)
|
||||
|
||||
|
||||
def test_domain_must_match_capability():
|
||||
value = document()
|
||||
value["phases"][0]["operations"][0]["domain"] = "seat"
|
||||
with pytest.raises(ValueError, match="stable"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["passenger", "front_right"])
|
||||
def test_duplicate_physical_seat_target_aliases_rejected(target):
|
||||
value = document()
|
||||
value["phases"][0]["operations"] = [
|
||||
operation("seat.set_ventilation", "passenger", state="on", level=1),
|
||||
operation("seat.set_ventilation", target, state="off"),
|
||||
]
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
ModeSchedule.from_dict(value)
|
||||
|
||||
|
||||
def test_driver_and_passenger_comfort_are_valid_independent_operations():
|
||||
value = document()
|
||||
value["phases"][0]["operations"] = [
|
||||
operation("seat.set_driver_ventilation_native", "driver", state="on", level=2),
|
||||
operation("seat.set_driver_massage_native", "driver", action="mode", mode="relax"),
|
||||
operation("seat.set_ventilation", "passenger", state="on", level=1),
|
||||
operation("seat.set_backrest_recline", "passenger", position_pct=40),
|
||||
]
|
||||
assert ModeSchedule.from_dict(value).operation_count == 5
|
||||
|
||||
|
||||
def test_direct_dataclass_construction_cannot_bypass_duration_or_repeat_limits():
|
||||
with pytest.raises(ValueError, match="duration_ms"):
|
||||
ModeSchedulePhase("a", "a", True, (operation(),))
|
||||
phase = ModeSchedulePhase.from_dict(document()["phases"][0])
|
||||
second = ModeSchedulePhase.from_dict(document()["phases"][1])
|
||||
with pytest.raises(ValueError, match="repeat_count"):
|
||||
ModeSchedule((phase, second), True)
|
||||
|
||||
|
||||
def test_simulation_has_no_thread_or_real_wait_and_preserves_repetition_order():
|
||||
schedule = ModeSchedule.from_dict(document(repeat=3, duration=2000))
|
||||
calls = []
|
||||
caller = current_thread()
|
||||
|
||||
def execute(phase):
|
||||
assert current_thread() is caller
|
||||
calls.append(phase.id)
|
||||
return {"success": True}
|
||||
|
||||
with (
|
||||
patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("thread")),
|
||||
patch("cognitive_agent.application.mode_schedule.Event.wait", side_effect=AssertionError("wait")),
|
||||
):
|
||||
result = ModeScheduleRunner.simulate(schedule, execute)
|
||||
assert result["status"] == "completed"
|
||||
assert result["completed"] is True
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["virtual_elapsed_ms"] == 12000
|
||||
assert result["executed_phases"] == 6
|
||||
assert result["phase_index"] == 1
|
||||
assert result["repeat_index"] == 2
|
||||
assert calls == ["warmup", "comfort"] * 3
|
||||
assert [item["virtual_start_ms"] for item in result["phase_results"]] == list(range(0, 12000, 2000))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failed_result", [
|
||||
None, [], {}, {"success": False}, {"status": "failed"}, {"status": "submitted_unverified"},
|
||||
{"status": "error", "success": True}, {"status": "succeeded", "success": False},
|
||||
{"success": True, "error": "rejected by safety"},
|
||||
{"status": "pending"}, {"success": 1},
|
||||
])
|
||||
def test_simulation_fails_closed_on_unconfirmed_or_failed_execution(failed_result):
|
||||
calls = []
|
||||
|
||||
def execute(phase):
|
||||
calls.append(phase.id)
|
||||
return failed_result
|
||||
|
||||
result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute)
|
||||
assert result["status"] == "failed"
|
||||
assert result["completed"] is False
|
||||
assert result["error"]
|
||||
assert result["virtual_elapsed_ms"] == 0
|
||||
assert calls == ["warmup"]
|
||||
|
||||
|
||||
def test_simulation_stops_on_second_phase_exception_and_reports_prior_hold():
|
||||
def execute(phase):
|
||||
if phase.id == "comfort":
|
||||
raise RuntimeError("runtime safety rejection")
|
||||
return {"status": "succeeded"}
|
||||
|
||||
result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute)
|
||||
assert result["completed"] is False
|
||||
assert result["error"] == "RuntimeError: runtime safety rejection"
|
||||
assert result["virtual_elapsed_ms"] == 1000
|
||||
assert result["executed_phases"] == 1
|
||||
|
||||
|
||||
def test_executor_mutations_do_not_change_next_repetition_or_source():
|
||||
values = []
|
||||
schedule = ModeSchedule.from_dict(document(repeat=2))
|
||||
|
||||
def execute(phase):
|
||||
values.append(phase.operations[0]["parameters"]["temperature_c"])
|
||||
phase.operations[0]["parameters"]["temperature_c"] = 999
|
||||
return {"status": "ok"}
|
||||
|
||||
assert ModeScheduleRunner.simulate(schedule, execute)["completed"]
|
||||
assert values == [28, 24, 28, 24]
|
||||
assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28
|
||||
|
||||
|
||||
class ManualClock:
|
||||
def __init__(self):
|
||||
self.value = 0.0
|
||||
self.holds = Queue()
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
def wait(self, cancelled, seconds):
|
||||
release = Event()
|
||||
self.holds.put((seconds, release))
|
||||
while not cancelled.is_set():
|
||||
if release.wait(0.01):
|
||||
self.value += seconds
|
||||
return False
|
||||
return True
|
||||
|
||||
def release(self, seconds=1.0):
|
||||
expected, release = self.holds.get(timeout=2)
|
||||
assert expected == seconds
|
||||
release.set()
|
||||
|
||||
|
||||
def test_runner_holds_without_repeated_dispatch_and_finishes_finite_repeats():
|
||||
clock = ManualClock()
|
||||
calls = Queue()
|
||||
runner = ModeScheduleRunner(clock=clock, wait=clock.wait)
|
||||
schedule = ModeSchedule.from_dict(document(repeat=2))
|
||||
try:
|
||||
runner.start("car", schedule, lambda phase: calls.put(phase.id) or {"success": True}, session_id="a")
|
||||
for expected in ["warmup", "comfort", "warmup", "comfort"]:
|
||||
assert calls.get(timeout=2) == expected
|
||||
assert calls.empty()
|
||||
state = runner.status("car")
|
||||
assert state["status"] == "running"
|
||||
assert state["completed"] is False
|
||||
assert state["session_id"] == "a"
|
||||
clock.release()
|
||||
# Joining a bounded, virtual-time worker is deterministic and does not
|
||||
# cancel it as close() would do while it leaves its final hold.
|
||||
runner._runs["car"].thread.join(2)
|
||||
assert runner.status("car")["status"] == "completed"
|
||||
assert runner.status("car")["elapsed_ms"] == 4000
|
||||
assert runner.status("car")["executed_phases"] == 4
|
||||
finally:
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_cancel_interrupts_real_hold_before_next_phase():
|
||||
started = Event()
|
||||
calls = []
|
||||
runner = ModeScheduleRunner()
|
||||
|
||||
def execute(phase):
|
||||
calls.append(phase.id)
|
||||
started.set()
|
||||
return {"status": "success"}
|
||||
|
||||
runner.start("car", ModeSchedule.from_dict(document(duration=900000)), execute)
|
||||
assert started.wait(2)
|
||||
assert runner.cancel("car")["status"] == "cancelled"
|
||||
assert runner.close(timeout=1)
|
||||
assert calls == ["warmup"]
|
||||
assert runner.status("car")["completed"] is False
|
||||
|
||||
|
||||
def test_wrong_session_cannot_cancel_active_timeline():
|
||||
started = Event()
|
||||
runner = ModeScheduleRunner()
|
||||
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: started.set() or {"success": True}, session_id="a")
|
||||
assert started.wait(2)
|
||||
assert runner.cancel("car", session_id="b")["status"] == "running"
|
||||
assert runner.cancel("car", session_id="a")["status"] == "cancelled"
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_replace_waits_for_inflight_callback_and_never_overlaps_same_car():
|
||||
entered = Event()
|
||||
release = Event()
|
||||
second_entered = Event()
|
||||
calls = []
|
||||
inside = 0
|
||||
guard = Lock()
|
||||
runner = ModeScheduleRunner()
|
||||
|
||||
def first(phase):
|
||||
nonlocal inside
|
||||
with guard:
|
||||
inside += 1
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
calls.append("old")
|
||||
with guard:
|
||||
inside -= 1
|
||||
return {"success": True}
|
||||
|
||||
def second(phase):
|
||||
with guard:
|
||||
assert inside == 0
|
||||
calls.append("new")
|
||||
second_entered.set()
|
||||
return {"success": True}
|
||||
|
||||
try:
|
||||
old = runner.start("car", ModeSchedule.from_dict(document()), first, session_id="old")
|
||||
old_run = runner._runs["car"]
|
||||
assert entered.wait(2)
|
||||
new = runner.start("car", ModeSchedule.from_dict(document()), second, session_id="new")
|
||||
assert old["run_id"] != new["run_id"]
|
||||
assert old_run.status == "cancelled"
|
||||
assert old_run.cancel_reason == "replaced"
|
||||
assert not second_entered.is_set()
|
||||
release.set()
|
||||
assert second_entered.wait(2)
|
||||
assert calls == ["old", "new"]
|
||||
finally:
|
||||
release.set()
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_different_vehicles_do_not_share_a_global_execution_lock():
|
||||
first_entered, second_entered, release = Event(), Event(), Event()
|
||||
runner = ModeScheduleRunner()
|
||||
|
||||
def first(phase):
|
||||
first_entered.set()
|
||||
assert release.wait(2)
|
||||
return {"success": True}
|
||||
|
||||
try:
|
||||
runner.start("car-a", ModeSchedule.from_dict(document()), first)
|
||||
assert first_entered.wait(2)
|
||||
runner.start("car-b", ModeSchedule.from_dict(document()), lambda phase: second_entered.set() or {"success": True})
|
||||
assert second_entered.wait(2)
|
||||
finally:
|
||||
release.set()
|
||||
assert runner.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["return", "exception"])
|
||||
def test_runner_stops_later_phases_and_repeats_after_failure(failure):
|
||||
calls = []
|
||||
runner = ModeScheduleRunner(wait=lambda event, duration: False)
|
||||
|
||||
def execute(phase):
|
||||
calls.append(phase.id)
|
||||
if phase.id == "comfort":
|
||||
if failure == "exception":
|
||||
raise ValueError("bad runtime capability")
|
||||
return {"status": "rejected", "error": "bad runtime capability"}
|
||||
return {"status": "ok"}
|
||||
|
||||
runner.start("car", ModeSchedule.from_dict(document(repeat=3)), execute)
|
||||
runner._runs["car"].thread.join(2)
|
||||
assert runner.status("car")["status"] == "failed"
|
||||
assert "bad runtime capability" in runner.status("car")["error"]
|
||||
assert runner.status("car")["executed_phases"] == 1
|
||||
assert calls == ["warmup", "comfort"]
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_close_prevents_start_and_reports_uninterruptible_callback_honestly():
|
||||
entered, release = Event(), Event()
|
||||
runner = ModeScheduleRunner()
|
||||
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: entered.set() or release.wait(2) or {"success": True})
|
||||
assert entered.wait(2)
|
||||
assert runner.close(timeout=0) is False
|
||||
assert runner.status("car")["status"] == "cancelled"
|
||||
assert runner.status("car")["cancel_reason"] == "closed"
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
|
||||
release.set()
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_idle_and_terminal_cancellation_are_idempotent():
|
||||
runner = ModeScheduleRunner(wait=lambda event, duration: False)
|
||||
assert runner.status("unknown")["status"] == "idle"
|
||||
assert runner.cancel("unknown")["status"] == "idle"
|
||||
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
|
||||
runner._runs["car"].thread.join(2)
|
||||
assert runner.cancel("car")["status"] == "completed"
|
||||
assert runner.close()
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_start_revalidates_caller_mutation_before_accepting_schedule():
|
||||
runner = ModeScheduleRunner()
|
||||
schedule = ModeSchedule.from_dict(document())
|
||||
schedule.phases[0].operations[0]["operation"] = "toggle"
|
||||
with pytest.raises(ValueError, match="absolute set"):
|
||||
runner.start("car", schedule, lambda phase: {"success": True})
|
||||
assert runner.status("car")["status"] == "idle"
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_failed_thread_start_is_not_left_running_and_close_remains_safe():
|
||||
runner = ModeScheduleRunner()
|
||||
with (
|
||||
patch("cognitive_agent.application.mode_schedule.Thread.start", side_effect=RuntimeError("thread unavailable")),
|
||||
pytest.raises(RuntimeError, match="thread unavailable"),
|
||||
):
|
||||
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
|
||||
assert runner.status("car")["status"] == "failed"
|
||||
assert runner.close()
|
||||
|
||||
|
||||
def test_injected_cancelled_wait_cannot_leave_stale_running_status():
|
||||
runner = ModeScheduleRunner(wait=lambda event, duration: True)
|
||||
runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True})
|
||||
runner._runs["car"].thread.join(2)
|
||||
assert runner.status("car")["status"] == "cancelled"
|
||||
assert runner.close()
|
||||
@@ -46,6 +46,26 @@ def test_saved_mode_update_parser_accepts_natural_variants(
|
||||
assert parse_mode_profile_update(message) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", (
|
||||
"第二阶段保持30秒", "第二阶段持续16分钟", "循环21次", "重复两次",
|
||||
"重复1.5次", "持续30秒", "全部阶段持续2分钟",
|
||||
))
|
||||
def test_schedule_update_predicates_stay_on_control_plane_even_when_bounds_are_invalid(change):
|
||||
assert parse_mode_profile_update(f"把上车预热模式的{change}") == ("上车预热模式", change)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", (
|
||||
"把上车预热模式的第二阶段保持30秒合适吗?",
|
||||
"不要把上车预热模式的第二阶段保持30秒",
|
||||
"上车预热模式的第二阶段不要保持30秒",
|
||||
"把上车预热模式的重复两次,但是不要保存",
|
||||
"解释把上车预热模式的重复两次",
|
||||
"创建一个上车预热模式,第二阶段保持30秒",
|
||||
))
|
||||
def test_schedule_update_predicates_do_not_authorize_queries_negation_or_creation(message):
|
||||
assert parse_mode_profile_update(message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
(
|
||||
|
||||
@@ -792,7 +792,13 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
|
||||
"roof.set_shade_position",
|
||||
]
|
||||
call = client.structured_calls[0]
|
||||
assert call["application_context"] == _scene_context()
|
||||
expected_context = _scene_context()
|
||||
expected_context["seat_scope_policy"] = {
|
||||
"selected_scopes": ["passenger"],
|
||||
"default_scope": "passenger",
|
||||
"applies_to": "seat capabilities in every phase",
|
||||
}
|
||||
assert call["application_context"] == expected_context
|
||||
assert call["output_schema"]["type"] == "object"
|
||||
assert call["output_schema"]["additionalProperties"] is False
|
||||
assert "requests" in call["output_schema"]["required"]
|
||||
|
||||
Reference in New Issue
Block a user