merge: sync feature/N7Web into feature/N7Web-Tech
# Conflicts: # apps/web-console/app.js # apps/web-console/assistant-chat.js # apps/web-console/index.html # apps/web-console/service-worker.js # src/cognitive_agent/server.py # tests/e2e/test_seat_web_api.py # tests/unit/test_assistant_frontend_contract.py # tests/unit/test_energy_monitor_frontend_contract.py # tests/unit/test_frontend_theme_contract.py # tests/unit/test_mode_scene_catalog.py
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const [baseURL, evidenceDir] = process.argv.slice(2);
|
||||
const desktop = { width: 1600, height: 1000 };
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.N7WEB_BROWSER_CHANNEL
|
||||
? { channel: process.env.N7WEB_BROWSER_CHANNEL }
|
||||
: process.platform === "win32" ? { channel: "msedge" } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: desktop, serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
const errors = [];
|
||||
const unexpectedWrites = [];
|
||||
let heldResponse = null;
|
||||
let nextReply = "success";
|
||||
const answer = (success) => [
|
||||
{ type: "action.completed", success, payload: {
|
||||
status: success ? "succeeded" : "failed", execution_mode: "dry_run",
|
||||
} },
|
||||
{ type: "answer.completed", answer: success ? "模式执行测试完成" : "模式执行测试失败" },
|
||||
].map((item) => JSON.stringify(item)).join("\n") + "\n";
|
||||
const fulfill = (route, success = true) => route.fulfill({
|
||||
status: 200, contentType: "application/x-ndjson", body: answer(success),
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ''}`);
|
||||
});
|
||||
async function handleApiRoute(route) {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (['/api/hardware/seat-state', '/api/hardware/vehicle-state'].includes(pathname)) {
|
||||
// This legacy GET may attempt automatic firmware recovery. Never touch
|
||||
// the physical gateway while verifying browser interactions.
|
||||
await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}});
|
||||
return;
|
||||
}
|
||||
// The isolated server deliberately does not launch Codex or publish a
|
||||
// physical display mirror. Keep these background integration probes local.
|
||||
if (pathname === '/api/health' || pathname === '/api/extension/display-sync') {
|
||||
await route.fulfill({json:{status:'ok',assistant:{status:'ready',available:true}}});
|
||||
return;
|
||||
}
|
||||
if (pathname === "/api/assistant/stream") {
|
||||
requests.push(route.request().postDataJSON());
|
||||
if (nextReply === "hold") { heldResponse = route; return; }
|
||||
const success = nextReply !== "fail";
|
||||
nextReply = "success";
|
||||
await fulfill(route, success);
|
||||
return;
|
||||
}
|
||||
if (route.request().method() !== "GET") {
|
||||
unexpectedWrites.push(route.request().url());
|
||||
await route.fulfill({ status: 403, body: "test forbids vehicle writes" });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
}
|
||||
await context.route("**/api/**", handleApiRoute);
|
||||
const rail = page.locator(".mode-chips");
|
||||
const office = rail.locator('button[data-mode-profile-id="office"]');
|
||||
const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending());
|
||||
const waitState = (id, state) => page.waitForFunction(
|
||||
({ id, state }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === state,
|
||||
{ id, state },
|
||||
);
|
||||
async function expectOneCommand(action, command) {
|
||||
const before = requests.length;
|
||||
await action();
|
||||
await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false');
|
||||
await waitIdle();
|
||||
assert.equal(requests.length, before + 1, "gesture must submit exactly once");
|
||||
assert.equal(requests.at(-1).message, command);
|
||||
}
|
||||
try {
|
||||
await page.goto(baseURL, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3);
|
||||
await page.waitForFunction(() => document.body.classList.contains('assistant-chat-enabled'));
|
||||
assert.equal(await page.title(), "511工程-智能座舱");
|
||||
assert.equal(new URL(page.url()).pathname, "/");
|
||||
assert.ok((await page.locator('body').innerText()).includes("整车孪生界面"));
|
||||
assert.ok(await rail.evaluate((element) => element.scrollWidth > element.clientWidth));
|
||||
|
||||
// This is a real mouse sequence: pointerdown -> pointerup -> click. The
|
||||
// prior pointer capture bug cannot be detected by button.dispatchEvent().
|
||||
await page.evaluate(() => window.CognitiveAgentAssistant.close());
|
||||
await expectOneCommand(() => office.click(), "进入办公模式");
|
||||
await waitState("office", "succeeded");
|
||||
assert.equal(await page.locator('#assistantDrawerToggle').getAttribute('aria-expanded'), 'true');
|
||||
assert.ok((await page.locator('#assistantMessages').innerText()).includes('进入办公模式'));
|
||||
|
||||
// A small physical hand movement is still a click.
|
||||
const box = await office.boundingBox();
|
||||
await expectOneCommand(async () => {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
}, "进入办公模式");
|
||||
|
||||
// Dragging from a card must scroll, without accidentally running it.
|
||||
const beforeDrag = requests.length;
|
||||
const startScroll = await rail.evaluate((element) => element.scrollLeft);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 - 90, box.y + box.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
assert.ok(await rail.evaluate((element) => element.scrollLeft) > startScroll);
|
||||
assert.equal(requests.length, beforeDrag);
|
||||
await rail.evaluate((element) => { element.scrollLeft = 0; });
|
||||
await expectOneCommand(() => office.click(), "进入办公模式");
|
||||
|
||||
// Keyboard activation and management cards share the same submission path.
|
||||
await office.focus();
|
||||
await expectOneCommand(() => page.keyboard.press('Enter'), "进入办公模式");
|
||||
await office.focus();
|
||||
await expectOneCommand(() => page.keyboard.press('Space'), "进入办公模式");
|
||||
await page.locator('#modeGalleryToggle').click();
|
||||
await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "进入小憩模式");
|
||||
await waitState("nap", "succeeded");
|
||||
|
||||
// Busy requests are not duplicated, nor does another card cancel them.
|
||||
nextReply = 'hold';
|
||||
const beforeBusy = requests.length;
|
||||
await office.click();
|
||||
await waitState('office', 'running');
|
||||
await office.click();
|
||||
assert.equal(requests.length, beforeBusy + 1);
|
||||
assert.equal(await page.evaluate(() => window.CognitiveAgentAssistant.isPending()), true);
|
||||
await fulfill(heldResponse);
|
||||
nextReply = 'success';
|
||||
await waitIdle();
|
||||
await waitState('office', 'succeeded');
|
||||
nextReply = 'fail';
|
||||
await expectOneCommand(() => office.click(), "进入办公模式");
|
||||
await waitState('office', 'failed');
|
||||
|
||||
// A catalog with few visible cards must also work without overflow.
|
||||
await rail.evaluate(element => {
|
||||
for (const child of element.children) {
|
||||
if (child.dataset.modeProfileId !== 'office') child.style.display = 'none';
|
||||
}
|
||||
});
|
||||
await page.waitForFunction(() => {const e=document.querySelector('.mode-chips');return e.scrollWidth <= e.clientWidth;});
|
||||
await expectOneCommand(() => office.click(), "进入办公模式");
|
||||
await rail.evaluate(element => {
|
||||
for (const child of element.children) child.style.removeProperty('display');
|
||||
});
|
||||
await page.setViewportSize(desktop);
|
||||
await page.screenshot({path:path.join(evidenceDir,'mode-rail-desktop.png')});
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(unexpectedWrites, []);
|
||||
|
||||
// Touch input uses native scrolling and also submits once when tapped.
|
||||
const touchContext = await browser.newContext({viewport:{width:720,height:1000},hasTouch:true,serviceWorkers:'block'});
|
||||
await touchContext.route('**/api/**', handleApiRoute);
|
||||
const touchPage = await touchContext.newPage();
|
||||
await touchPage.goto(baseURL, {waitUntil:'domcontentloaded'});
|
||||
await touchPage.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3);
|
||||
const beforeTouch = requests.length;
|
||||
await touchPage.locator('.mode-chips button[data-mode-profile-id="office"]').tap();
|
||||
await touchPage.waitForFunction(() => document.querySelector('.mode-chips [data-mode-profile-id="office"]').dataset.modeState === 'succeeded');
|
||||
assert.equal(requests.length,beforeTouch+1);
|
||||
assert.equal(requests.at(-1).message,'进入办公模式');
|
||||
await touchPage.screenshot({path:path.join(evidenceDir,'mode-rail-touch.png')});
|
||||
await touchContext.close();
|
||||
console.log('PASS: click, jitter, drag, Enter, Space, gallery, busy, failure, resize, touch; no runtime errors');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -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,93 @@
|
||||
"""Exercise real pointer/click dispatch on the complete console in Chromium."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from http import HTTPStatus
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
|
||||
|
||||
|
||||
def test_mode_rail_real_browser_interactions(tmp_path: Path) -> None:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("Node.js is required for the browser interaction regression")
|
||||
env = dict(os.environ)
|
||||
bundled_modules = Path(node).resolve().parent.parent / "node_modules"
|
||||
if bundled_modules.is_dir():
|
||||
env["NODE_PATH"] = os.pathsep.join(
|
||||
item for item in (str(bundled_modules), env.get("NODE_PATH")) if item
|
||||
)
|
||||
available = subprocess.run(
|
||||
[node, "-e", "require.resolve('playwright')"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
if available.returncode:
|
||||
pytest.skip("Playwright is required for the real browser regression")
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false",
|
||||
},
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "artifacts"
|
||||
|
||||
class BrowserHandler(SeatDemoHandler):
|
||||
auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False))
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if urlparse(self.path).path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
|
||||
self._json({"status": "unavailable", "hardware_execution_allowed": False})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
# Only GET reaches this test server. Browser requests to the
|
||||
# assistant are intercepted; unexpected writes fail closed.
|
||||
self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN)
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
BrowserHandler.service = service
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
node,
|
||||
str(Path(__file__).with_name("mode_rail_browser.cjs")),
|
||||
f"http://127.0.0.1:{server.server_port}",
|
||||
str(tmp_path),
|
||||
],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stdout + completed.stderr
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
service.close()
|
||||
@@ -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()
|
||||
@@ -52,6 +52,7 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
"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",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
@@ -306,6 +307,37 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
|
||||
self.assertIn("GET", response.headers["Access-Control-Allow-Methods"])
|
||||
|
||||
def test_schedule_status_stays_authenticated_and_sanitized_beside_public_catalog(self) -> None:
|
||||
endpoint = f"{self.base_url}/api/modes/schedule/status?vehicle_id=denza_n7"
|
||||
unauthenticated = urllib.request.build_opener()
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
unauthenticated.open(endpoint, timeout=3)
|
||||
self.assertEqual(caught.exception.code, 401)
|
||||
state = {
|
||||
"status": "running", "vehicle_id": "denza_n7", "session_id": "conv-mode-schedule-api",
|
||||
"run_id": "schedule-private-safe-id", "phase_index": 0, "repeat_index": 0,
|
||||
"phase_count": 2, "repeat_count": 2, "executed_phases": 0,
|
||||
"elapsed_ms": 250, "total_duration_ms": 6000, "completed": False,
|
||||
"phase_results": [{"raw_can": "private-command-bytes"}],
|
||||
"hardware_client_id": "private-bench-client",
|
||||
"error": "private transport stack or secret",
|
||||
}
|
||||
with (
|
||||
patch.object(self.service.mode_schedule_runner, "status", return_value=state) as status,
|
||||
self.opener.open(endpoint, timeout=3) as response,
|
||||
):
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
status.assert_called_once_with("denza_n7")
|
||||
self.assertEqual(payload["status"], "running")
|
||||
self.assertEqual(payload["phase_count"], 2)
|
||||
self.assertEqual(payload["total_duration_ms"], 6000)
|
||||
self.assertEqual(payload["completed"], False)
|
||||
self.assertIn("后续阶段已停止", payload["error"])
|
||||
self.assertNotIn("phase_results", payload)
|
||||
self.assertNotIn("hardware_client_id", payload)
|
||||
self.assertNotIn("private-command-bytes", json.dumps(payload))
|
||||
self.assertNotIn("private transport", json.dumps(payload))
|
||||
|
||||
def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None:
|
||||
original_registry = self.service.mode_profiles
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -2489,6 +2521,9 @@ operations:
|
||||
if item["type"] == "validated_commands"
|
||||
]
|
||||
self.assertTrue(validated_payloads)
|
||||
# The local-first stage publishes known IR controls before CAN parsing;
|
||||
# subsequent evidence must include the complete combined command set.
|
||||
self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
|
||||
self.assertTrue(
|
||||
any(
|
||||
{"native_climate", "self_extension"}.issubset(
|
||||
@@ -2497,6 +2532,9 @@ operations:
|
||||
for payload in validated_payloads
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all("self_extension" in payload["function_configs"] for payload in validated_payloads)
|
||||
)
|
||||
first_validated = next(
|
||||
payload
|
||||
for payload in validated_payloads
|
||||
@@ -2749,11 +2787,16 @@ operations:
|
||||
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertEqual(events[-1]["error"], "can_transport_failed")
|
||||
validated = [
|
||||
validated_payloads = [
|
||||
item["payload"]
|
||||
for item in events
|
||||
if item["type"] == "validated_commands"
|
||||
][-1]
|
||||
]
|
||||
self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
|
||||
validated = next(
|
||||
payload for payload in validated_payloads
|
||||
if {"native_climate", "self_extension"}.issubset(payload["function_configs"])
|
||||
)
|
||||
self.assertEqual(validated["status"], "processing")
|
||||
self.assertEqual(
|
||||
set(validated["function_configs"]),
|
||||
@@ -3126,6 +3169,31 @@ operations:
|
||||
planner.assert_called_once()
|
||||
self.assertEqual(planner.call_args.args[0]["utterance"], utterance)
|
||||
|
||||
def test_peripheral_timing_aliases_preserve_sequence_before_local_action_execution(self) -> None:
|
||||
handler = object.__new__(SeatDemoHandler)
|
||||
handler.service = SimpleNamespace(is_mode_profile_configuration_request=lambda _text: False)
|
||||
capabilities = [
|
||||
{"id": identity, "status": "learned", "device_id": "third-party-light",
|
||||
"device_name": "第三方氛围灯", "card_uid": "1A53DD95", "action": action, "slot": slot}
|
||||
for identity, action, slot in (("on", "开启灯光", 1), ("red", "红色灯光", 2), ("off", "关闭灯光", 3))
|
||||
]
|
||||
handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: capabilities)
|
||||
handler.nfc = SimpleNamespace(snapshot=lambda: {
|
||||
"reader": {"online": True}, "card": {"action": "present", "uid": "1A53DD95"},
|
||||
})
|
||||
for duration in ("5s", "5S", "500ms", "500MS", "1.5s", "5秒", "半分钟"):
|
||||
with (
|
||||
self.subTest(duration=duration),
|
||||
patch.object(handler, "_execute_active_peripheral_request", return_value={"status": "succeeded"}) as planner,
|
||||
patch.object(handler, "_execute_local_peripheral_actions", side_effect=AssertionError("sequence flattened")),
|
||||
):
|
||||
result = handler._execute_request_with_peripherals("/api/requests", {
|
||||
"utterance": f"打开第三方氛围灯,红灯闪烁{duration}后,最后关闭第三方氛围灯",
|
||||
"provider": "local", "execution_mode": "dry_run",
|
||||
})
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
planner.assert_called_once()
|
||||
|
||||
def test_post_request_returns_complete_dry_run(self) -> None:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.scene_composer import normalize_scene_output
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scene_service(tmp_path_factory):
|
||||
service = SeatDemoService()
|
||||
service.artifact_root = tmp_path_factory.mktemp("scene-explicit-seat-scope")
|
||||
service.mode_profiles = ModeProfileRegistry(
|
||||
REPOSITORY_ROOT / "config" / "modes",
|
||||
user_root=tmp_path_factory.mktemp("scene-explicit-seat-user-modes"),
|
||||
)
|
||||
try:
|
||||
yield service, AssistantCapabilityCatalog(service).scene_composition_context()
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def _plan(context, scopes):
|
||||
requests = []
|
||||
if "driver" in scopes:
|
||||
requests.append({
|
||||
"capability": "seat.set_driver_massage_native",
|
||||
"target_scope": "driver",
|
||||
"parameters": {"action": "mode", "mode": "relax"},
|
||||
})
|
||||
if "passenger" in scopes:
|
||||
requests.append({
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"target_scope": "passenger",
|
||||
"parameters": {"position_pct": 45},
|
||||
})
|
||||
requests.append({
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
"parameters": {"temperature_c": 24},
|
||||
})
|
||||
return normalize_scene_output({
|
||||
"requests": requests, "ambiguities": [], "confidence": 0.95,
|
||||
}, context)
|
||||
|
||||
|
||||
def _preview(
|
||||
scene_service, message, scopes=("driver", "passenger"), *, execution_mode="hardware",
|
||||
):
|
||||
service, context = scene_service
|
||||
with patch.object(
|
||||
service.hardware_execution, "reserve",
|
||||
side_effect=AssertionError("scene scope validation must not execute hardware"),
|
||||
):
|
||||
return service.preview_native_can(
|
||||
message,
|
||||
provider="local",
|
||||
conversation_id="conv-explicit-scene-seat-scope",
|
||||
execution_mode=execution_mode,
|
||||
vehicle_id="denza_n7",
|
||||
assistant_plan=_plan(context, scopes),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", (
|
||||
"构建一个主驾座椅舒适模式",
|
||||
"创建一个仅主驾舒适模式",
|
||||
"创建一个只给驾驶员坐着放松的功能模式",
|
||||
"构建一个副驾座椅舒适模式",
|
||||
"构建一个仅副驾舒适模式",
|
||||
"构建一个右前座椅舒适模式",
|
||||
"构建一个后排座椅舒适模式",
|
||||
"构建一个主驾座椅与副驾空调舒适模式",
|
||||
"构建一个主驾阅读灯与副驾座椅舒适模式",
|
||||
))
|
||||
def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_service, message):
|
||||
result = _preview(scene_service, message)
|
||||
assert result["status"] != "succeeded"
|
||||
assert result.get("mode_profile_draft") is None
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result.get("command_plan") is None
|
||||
assert result.get("command_plans") == {}
|
||||
intent = result.get("intent_ir") or {}
|
||||
assert (intent.get("spec") or {}).get("actions", []) == []
|
||||
assert any(
|
||||
"座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE"
|
||||
for item in result["validation"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "scopes"), (
|
||||
("构建一个主驾座椅舒适模式", ("driver",)),
|
||||
("构建一个副驾座椅舒适模式", ("passenger",)),
|
||||
("创建一个仅主驾舒适模式", ("driver",)),
|
||||
("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个主副驾座椅舒适模式", ("driver", "passenger")),
|
||||
("构建一个云端座椅舒适模式", ("passenger",)),
|
||||
("构建一个副驾空调与座椅舒适模式", ("passenger",)),
|
||||
("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)),
|
||||
("构建一个主驾座椅与副驾空调舒适模式", ("driver",)),
|
||||
("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)),
|
||||
))
|
||||
def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_zones(
|
||||
scene_service, message, scopes,
|
||||
):
|
||||
result = _preview(scene_service, message, scopes)
|
||||
assert result["status"] == "succeeded", result.get("validation")
|
||||
assert result["mode_profile_draft"]
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
actions = result["intent_ir"]["spec"]["actions"]
|
||||
assert {
|
||||
action["target"]["instance"]
|
||||
for action in actions if action["domain"] == "seat"
|
||||
} == set(scopes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", (
|
||||
"构建一个主驾舒适模式,副驾不要动",
|
||||
"构建一个主驾舒适模式,不要动副驾",
|
||||
))
|
||||
def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation(
|
||||
scene_service, message,
|
||||
):
|
||||
result = _preview(scene_service, message, execution_mode="dry_run")
|
||||
assert result["status"] != "succeeded"
|
||||
assert result.get("mode_profile_draft") is None
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def test_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"
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
actions = result["intent_ir"]["spec"]["actions"]
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["capability"] == "seat.set_backrest_recline"
|
||||
assert actions[0]["target"]["instance"] == "passenger"
|
||||
assert actions[0]["parameters"]["position_pct"] == 45
|
||||
|
||||
|
||||
def test_rear_window_scene_is_not_rejected_as_an_unsupported_seat(scene_service):
|
||||
service, context = scene_service
|
||||
plan = normalize_scene_output({
|
||||
"requests": [{
|
||||
"capability": "window.set_position",
|
||||
"target_scope": "rear_right",
|
||||
"parameters": {"position_pct": 45},
|
||||
}],
|
||||
"ambiguities": [], "confidence": 0.95,
|
||||
}, context)
|
||||
result = service.preview_native_can(
|
||||
"构建一个后排换气模式", provider="local",
|
||||
conversation_id="conv-rear-window-not-seat",
|
||||
execution_mode="dry_run", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["mode_profile_draft"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("text", "expected"), (
|
||||
("进入主驾舒适模式", frozenset({"driver"})),
|
||||
("进入仅副驾舒适模式", frozenset({"passenger"})),
|
||||
("进入主副驾座椅舒适模式", frozenset({"driver", "passenger"})),
|
||||
("进入主驾座椅与副驾的空调舒适模式", frozenset({"driver"})),
|
||||
("进入副驾座椅与主驾的阅读灯模式", frozenset({"passenger"})),
|
||||
("进入座椅舒适与右前车窗模式", None),
|
||||
("进入座椅舒适与后排阅读灯模式", None),
|
||||
("进入后排座椅舒适模式", frozenset({"unsupported"})),
|
||||
))
|
||||
def test_scene_scope_resolution_only_uses_seat_or_unqualified_scene_targets(text, expected):
|
||||
assert resolve_scene_seat_scopes(text) == expected
|
||||
|
||||
|
||||
def test_new_creation_cannot_borrow_draft_reference_to_bypass_explicit_scope(scene_service):
|
||||
service, context = scene_service
|
||||
built = _preview(scene_service, "构建一个主驾独享舒适模式", ("driver",))
|
||||
reference = {key: built["mode_profile_draft"][key] for key in (
|
||||
"name", "source_request_id", "vehicle_id",
|
||||
)}
|
||||
result = service.preview_native_can(
|
||||
"构建一个主驾独享舒适模式", provider="local",
|
||||
conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run",
|
||||
vehicle_id="denza_n7", assistant_plan=_plan(context, ("driver", "passenger")),
|
||||
mode_profile_draft_context=reference,
|
||||
)
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("initial_scope", "name", "change", "added_capability"), (
|
||||
("driver", "主驾释压舒适模式", "副驾座椅通风调整为1档", "seat.set_ventilation"),
|
||||
("passenger", "副驾释压舒适模式", "主驾座椅通风调整为低档", "seat.set_driver_ventilation_native"),
|
||||
))
|
||||
def test_explicit_revision_can_extend_old_label_scope_then_save_and_replay(
|
||||
scene_service, initial_scope, name, change, added_capability,
|
||||
):
|
||||
service, _context = scene_service
|
||||
built = _preview(scene_service, f"构建一个{name}", (initial_scope,))
|
||||
source_draft = built["mode_profile_draft"]
|
||||
reference = {key: source_draft[key] for key in (
|
||||
"name", "source_request_id", "vehicle_id",
|
||||
)}
|
||||
revised = service.preview_native_can(
|
||||
f"把{name}的{change}", provider="local",
|
||||
conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run",
|
||||
vehicle_id="denza_n7", mode_profile_draft_context=reference,
|
||||
)
|
||||
assert revised["status"] == "succeeded", revised.get("validation")
|
||||
draft = revised["mode_profile_draft"]
|
||||
assert draft["generation_source"] == "draft_revision"
|
||||
assert draft["operation_count"] == source_draft["operation_count"] + 1
|
||||
actions = revised["intent_ir"]["spec"]["actions"]
|
||||
assert added_capability in {action["capability"] for action in actions}
|
||||
assert {
|
||||
action["target"]["instance"] for action in actions if action["domain"] == "seat"
|
||||
} == {"driver", "passenger"}
|
||||
saved = service.create_mode_profile_card(
|
||||
draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"],
|
||||
)
|
||||
assert saved["status"] == "succeeded"
|
||||
replayed = service.execute_mode_profile_card(
|
||||
saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7",
|
||||
)
|
||||
assert replayed["status"] == "succeeded", replayed.get("validation")
|
||||
assert {
|
||||
(action["capability"], action["target"]["instance"])
|
||||
for action in replayed["intent_ir"]["spec"]["actions"] if action["domain"] == "seat"
|
||||
} == {
|
||||
(action["capability"], action["target"]["instance"])
|
||||
for action in actions if action["domain"] == "seat"
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.codex_assistant import CodexAssistantService
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.scene_composer import normalize_scene_output
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
@@ -94,6 +95,7 @@ def _assistant_preview(
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast_answer = catalog.fast_answer(message, snapshot)
|
||||
events: list[dict[str, object]] = []
|
||||
raw_results: list[dict[str, object]] = []
|
||||
passed_plans: list[dict[str, object] | None] = []
|
||||
@@ -122,6 +124,7 @@ def _assistant_preview(
|
||||
conversation_id=conversation_id,
|
||||
principal="scene-template-hybrid-test",
|
||||
history=[],
|
||||
authoritative_answer=fast_answer.answer if fast_answer else None,
|
||||
emit=lambda event: events.append(event) or True,
|
||||
execute_control=execute_control,
|
||||
capability_snapshot=snapshot,
|
||||
@@ -141,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run(
|
||||
templates = snapshot["scene_templates"]
|
||||
|
||||
assert isinstance(templates, list)
|
||||
assert len(templates) == 12
|
||||
assert len(templates) == 15
|
||||
assert all(template["assistant_callable"] is True for template in templates)
|
||||
|
||||
with patch.object(
|
||||
@@ -166,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"]),
|
||||
@@ -184,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 = {
|
||||
(
|
||||
@@ -308,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"))
|
||||
@@ -339,6 +364,115 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults(
|
||||
client.start_turn.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected_name", "template_id"),
|
||||
(
|
||||
("构建一个座椅舒适的功能模式", "座椅舒适模式", "seat-comfort"),
|
||||
("构建一个让人坐着放松的功能模式", "坐着放松模式", "relaxation"),
|
||||
("请帮我创建一个适合乘坐舒适的模式吧", "乘坐舒适模式", "seat-comfort"),
|
||||
("帮我构建一个让我们坐着舒服的功能模式", "坐着舒服模式", "seat-comfort"),
|
||||
("创建一个座椅放松模式并预览", "座椅放松模式", "relaxation"),
|
||||
("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"),
|
||||
),
|
||||
)
|
||||
def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
message: str,
|
||||
expected_name: str,
|
||||
template_id: str,
|
||||
) -> None:
|
||||
service, catalog, user_root = hybrid_service
|
||||
with patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError("seat comfort preview reached hardware execution"),
|
||||
):
|
||||
response, result, _events, client, _plans = _assistant_preview(
|
||||
service, catalog, message, conversation_id="conv-seat-comfort-preview",
|
||||
)
|
||||
assert response["route"] == "scene_template"
|
||||
assert response["success"] is True
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result["mode_profile_draft"]["name"] == expected_name
|
||||
assert result["mode_profile_draft"]["source_template_id"] == template_id
|
||||
seat_actions = {
|
||||
(capability, target): parameters
|
||||
for (capability, target), parameters in _action_map(result).items()
|
||||
if capability.startswith("seat.")
|
||||
}
|
||||
assert {target for _capability, target in seat_actions} == {"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_ventilation", "passenger"] == {
|
||||
"state": "on", "level": 1,
|
||||
}
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ("云端座椅舒展模式", "晚间乘坐释压模式"))
|
||||
def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merge(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
name: str,
|
||||
) -> None:
|
||||
service, catalog, user_root = hybrid_service
|
||||
plan = normalize_scene_output({
|
||||
"requests": [
|
||||
{"capability": "seat.set_driver_massage_native", "target_scope": "driver",
|
||||
"parameters": {"action": "mode", "mode": "relax"}},
|
||||
{"capability": "seat.set_driver_heating_native", "target_scope": "driver",
|
||||
"parameters": {"state": "on", "level": 2}},
|
||||
{"capability": "seat.set_backrest_recline", "target_scope": "passenger",
|
||||
"parameters": {"position_pct": 45}},
|
||||
{"capability": "seat.set_heating", "target_scope": "passenger",
|
||||
"parameters": {"state": "on", "level": 1}},
|
||||
],
|
||||
"ambiguities": [], "confidence": 0.95,
|
||||
}, catalog.scene_composition_context())
|
||||
with patch.object(
|
||||
service.hardware_execution, "reserve",
|
||||
side_effect=AssertionError("scene composition reached hardware execution"),
|
||||
):
|
||||
result = service.preview_native_can(
|
||||
f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated",
|
||||
execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan,
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition"
|
||||
assert set(_action_map(result)) == {
|
||||
("seat.set_driver_massage_native", "driver"),
|
||||
("seat.set_driver_heating_native", "driver"),
|
||||
("seat.set_backrest_recline", "passenger"),
|
||||
("seat.set_heating", "passenger"),
|
||||
}
|
||||
assert not list(user_root.rglob("*.yaml"))
|
||||
|
||||
|
||||
def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
) -> None:
|
||||
service, _catalog, _user_root = hybrid_service
|
||||
result = service.preview_native_can(
|
||||
"构建一个舒展模式,座椅靠背调整到45%", provider="local",
|
||||
conversation_id="conv-seat-explicit-target", execution_mode="hardware",
|
||||
vehicle_id="denza_n7",
|
||||
)
|
||||
assert result["status"] == "succeeded"
|
||||
assert _action_map(result) == {
|
||||
("seat.set_backrest_recline", "passenger"): {"position_pct": 45},
|
||||
}
|
||||
assert result.get("mode_profile_draft")
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
|
||||
|
||||
def test_template_metadata_cannot_authorize_modified_or_stale_template_actions(
|
||||
hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path],
|
||||
) -> None:
|
||||
@@ -440,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) == 12
|
||||
assert len(templates) == 15
|
||||
assert {item["name"] for item in templates} >= {
|
||||
"观影模式",
|
||||
"露营模式",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Execute the mode preview formatter against actual seat/light parameter shapes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def render_rows():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
runtime = (
|
||||
Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime"
|
||||
/ "dependencies" / "node" / "bin"
|
||||
)
|
||||
node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None)
|
||||
assert node, "Node.js is required to validate rendered mode action text"
|
||||
source = (ROOT / "apps" / "web-console" / "assistant-chat.js").read_text(encoding="utf-8")
|
||||
start = source.index(" function modeDraftRows(")
|
||||
end = source.index("\n function ", start + 1)
|
||||
script = source[start:end] + "\nconst input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));\n"
|
||||
script += "console.log(JSON.stringify(modeDraftRows(input.draft, input.actions)));"
|
||||
|
||||
def render(actions, instruction=""):
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
input=json.dumps({"draft": {"instruction": instruction}, "actions": actions}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
return render
|
||||
|
||||
|
||||
def action(capability, parameters, zone="driver"):
|
||||
return {
|
||||
"domain": capability.split(".")[0],
|
||||
"capability": capability,
|
||||
"target": {"zone": zone},
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["ventilation", "heating"])
|
||||
@pytest.mark.parametrize(
|
||||
("parameters", "expected"),
|
||||
[
|
||||
({"state": "on", "level": 1}, "开启 · 高档"),
|
||||
({"state": "on", "level": 2}, "开启 · 低档"),
|
||||
({"state": "on"}, "开启 · 低档"),
|
||||
({"state": "off", "level": 1}, "关闭"),
|
||||
],
|
||||
)
|
||||
def test_driver_native_comfort_uses_its_own_level_scale(render_rows, kind, parameters, expected):
|
||||
rows = render_rows([action(f"seat.set_driver_{kind}_native", parameters, "front_left")])
|
||||
assert rows[0]["label"] == f"主驾座椅{'通风' if kind == 'ventilation' else '加热'}"
|
||||
assert rows[0]["value"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["ventilation", "heating"])
|
||||
@pytest.mark.parametrize("level", [1, 2, 3, 4, 5])
|
||||
def test_passenger_levels_are_not_translated_using_driver_can_codes(render_rows, kind, level):
|
||||
rows = render_rows([action(f"seat.set_{kind}", {"state": "on", "level": level}, "passenger")])
|
||||
assert rows[0]["label"] == f"副驾座椅{'通风' if kind == 'ventilation' else '加热'}"
|
||||
assert rows[0]["value"] == f"开启 · {level}档"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parameters", "expected"),
|
||||
[
|
||||
({"action": "off", "mode": "relax"}, "关闭"),
|
||||
({"action": "mode", "mode": "wave"}, "波浪模式"),
|
||||
({"action": "mode", "mode": "pulse"}, "脉冲模式"),
|
||||
({"action": "mode", "mode": "stretch"}, "舒展模式"),
|
||||
({"action": "mode", "mode": "slack"}, "舒缓模式"),
|
||||
({"action": "mode", "mode": "relax"}, "放松模式"),
|
||||
({"action": "strength", "strength": 1}, "轻柔力度"),
|
||||
({"action": "strength", "strength": 2}, "中等力度"),
|
||||
({"action": "strength", "strength": 3}, "较强力度"),
|
||||
({"action": "mode"}, "按摩模式待确认"),
|
||||
({"action": "strength"}, "按摩力度待确认"),
|
||||
],
|
||||
)
|
||||
def test_massage_preview_distinguishes_operation_mode_and_strength(render_rows, parameters, expected):
|
||||
rows = render_rows([action("seat.set_driver_massage_native", parameters)])
|
||||
assert rows[0]["label"] == "主驾座椅按摩"
|
||||
assert rows[0]["value"] == expected
|
||||
|
||||
|
||||
def test_ambient_native_color_and_brightness_remain_distinct_rows(render_rows):
|
||||
rows = render_rows([
|
||||
action("ambient_light.set_color_index", {"color_index": 127}, "vehicle"),
|
||||
action("ambient_light.set_brightness_level", {"brightness_level": 2}, "vehicle"),
|
||||
])
|
||||
assert [(row["label"], row["value"]) for row in rows] == [
|
||||
("氛围灯颜色", "色号 127"),
|
||||
("氛围灯亮度", "2档"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brightness", [0, 20, 100])
|
||||
def test_rgb_illumination_keeps_exact_color_and_brightness(render_rows, brightness):
|
||||
rows = render_rows([
|
||||
action("ambient_light.set_illumination", {"color_hex": "#FFB347", "brightness_pct": brightness}, "vehicle"),
|
||||
])
|
||||
assert rows[0]["value"] == f"颜色 #FFB347 · 亮度 {brightness}%"
|
||||
|
||||
|
||||
def test_zero_gravity_grouping_keeps_driver_comfort_visible(render_rows):
|
||||
rows = render_rows([
|
||||
action("seat.set_longitudinal_position", {"position_pct": 100}, "passenger"),
|
||||
action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "front_right"),
|
||||
action("seat.set_driver_massage_native", {"action": "mode", "mode": "relax"}),
|
||||
], "副驾零重力模式,并开启主驾放松按摩")
|
||||
assert [(row["label"], row["value"]) for row in rows] == [
|
||||
("副驾座椅", "零重力模式"),
|
||||
("主驾座椅按摩", "放松模式"),
|
||||
]
|
||||
|
||||
|
||||
def test_position_and_power_rows_keep_existing_values(render_rows):
|
||||
rows = render_rows([
|
||||
action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "passenger"),
|
||||
action("ambient_light.set_power", {"state": "off"}, "vehicle"),
|
||||
action("seat.set_ventilation", {"state": "off", "level": 2}, "passenger"),
|
||||
])
|
||||
assert [(row["label"], row["value"]) for row in rows] == [
|
||||
("副驾靠背角度", "81%(150°)"),
|
||||
("氛围灯", "关闭"),
|
||||
("副驾座椅通风", "关闭"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capability", "mode", "label", "value"),
|
||||
[
|
||||
("climate.set_control_mode", "auto", "空调控制模式", "自动控制"),
|
||||
("climate.set_control_mode", "manual", "空调控制模式", "手动控制"),
|
||||
("climate.set_cycle_mode", "internal", "空调循环", "内循环"),
|
||||
("climate.set_cycle_mode", "external", "空调循环", "外循环"),
|
||||
("climate.set_outlet_mode", "face", "空调出风模式", "吹面"),
|
||||
("climate.set_outlet_mode", "face_feet", "空调出风模式", "吹面 / 吹脚"),
|
||||
("climate.set_outlet_mode", "feet", "空调出风模式", "吹脚"),
|
||||
("climate.set_outlet_mode", "feet_defrost", "空调出风模式", "吹脚 / 除霜"),
|
||||
("climate.set_outlet_mode", "defrost", "空调出风模式", "除霜"),
|
||||
("climate.set_outlet_mode", "face_feet_defrost", "空调出风模式", "吹面 / 吹脚 / 除霜"),
|
||||
("climate.set_outlet_mode", "face_defrost", "空调出风模式", "吹面 / 除霜"),
|
||||
],
|
||||
)
|
||||
def test_climate_modes_use_capability_specific_labels(render_rows, capability, mode, label, value):
|
||||
rows = render_rows([action(capability, {"mode": mode}, "vehicle")])
|
||||
assert (rows[0]["label"], rows[0]["value"]) == (label, value)
|
||||
|
||||
|
||||
def test_climate_control_circulation_and_outlet_are_not_conflated(render_rows):
|
||||
rows = render_rows([
|
||||
action("climate.set_control_mode", {"mode": "auto"}, "vehicle"),
|
||||
action("climate.set_cycle_mode", {"mode": "internal"}, "vehicle"),
|
||||
action("climate.set_outlet_mode", {"mode": "face_feet"}, "vehicle"),
|
||||
action("climate.set_fan_level", {"level": 2}, "vehicle"),
|
||||
])
|
||||
assert [(row["label"], row["value"]) for row in rows] == [
|
||||
("空调控制模式", "自动控制"),
|
||||
("空调循环", "内循环"),
|
||||
("空调出风模式", "吹面 / 吹脚"),
|
||||
("空调风量", "2档"),
|
||||
]
|
||||
@@ -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) == ("主副驾座椅动态模式", "")
|
||||
@@ -5,6 +5,8 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -44,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",
|
||||
@@ -241,7 +253,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": "调节座椅前后位置",
|
||||
@@ -309,6 +321,7 @@ const context = {
|
||||
modeProfiles() { return [savedMode]; },
|
||||
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
|
||||
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
||||
hideModeActionPopover() {},
|
||||
showToast(message) { calls.push(["toast", message]); },
|
||||
executeSavedModeProfile(mode, options) {
|
||||
calls.push(["execute", mode.id, options.command]);
|
||||
@@ -438,6 +451,44 @@ console.log(JSON.stringify({ result: context.result, calls }));
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("phase", "input_mode"), (
|
||||
("capturing", "manual"),
|
||||
("capturing", "wake"),
|
||||
("finalizing", "manual"),
|
||||
("finalizing", "wake"),
|
||||
("arming", "manual"),
|
||||
))
|
||||
def test_mode_click_during_dictation_does_not_create_stuck_running_state(
|
||||
phase: str, input_mode: str,
|
||||
) -> None:
|
||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
start = app.index("function applyModeCommand")
|
||||
end = app.index("\n\nfunction applyRuntimeTargetPolicy", start)
|
||||
script = "const snippet = " + json.dumps(app[start:end]) + ";\n"
|
||||
script += "const speech = " + json.dumps({"phase": phase, "inputMode": input_mode}) + ";\n"
|
||||
script += r"""
|
||||
const vm = require("node:vm");
|
||||
const calls = [];
|
||||
const context = {
|
||||
window: {
|
||||
CognitiveAgentAssistant: { isPending() { return false; } },
|
||||
CognitiveAgentSpeech: speech,
|
||||
},
|
||||
showToast(message) { calls.push(message); },
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(snippet + '\nthis.result = applyModeCommand("进入办公模式", "office");', context);
|
||||
console.log(JSON.stringify({ result: context.result, calls }));
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[node_runtime(), "-e", script], check=True, capture_output=True, encoding="utf-8",
|
||||
)
|
||||
assert json.loads(completed.stdout) == {
|
||||
"result": False,
|
||||
"calls": ["请等待语音整理完成后再发送"],
|
||||
}
|
||||
|
||||
|
||||
def test_top_and_management_mode_cards_share_the_assistant_submission_entrypoint() -> None:
|
||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
install_start = app.index('document.addEventListener("DOMContentLoaded"')
|
||||
|
||||
@@ -11,6 +11,7 @@ from cognitive_agent.application.mode_scene_catalog import (
|
||||
ModeSceneMqttBurstPublisher,
|
||||
build_mode_scene_catalog,
|
||||
)
|
||||
from cognitive_agent.application.mode_schedule import ModeSchedule
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
|
||||
|
||||
|
||||
@@ -153,7 +154,7 @@ def test_repository_presets_are_exported_without_internal_command_flows() -> Non
|
||||
assert catalog["scene_count"] >= 13
|
||||
assert len({scene["id"] for scene in catalog["scenes"]}) == catalog["scene_count"]
|
||||
by_id = {scene["id"]: scene for scene in catalog["scenes"]}
|
||||
assert by_id["office"]["operation_count"] == 10
|
||||
assert by_id["office"]["operation_count"] == len(registry.get_profile("office", "denza_n7").operations)
|
||||
assert by_id["rocking_horse"]["behavior"] == "cyclic"
|
||||
assert by_id["rocking_horse"]["timeline"]["repeat"] == "until_stopped_or_timeout"
|
||||
encoded = json.dumps(catalog, ensure_ascii=False)
|
||||
@@ -403,3 +404,87 @@ def test_mode_scene_mqtt_burst_publishes_only_one_exact_card_three_times() -> No
|
||||
assert publisher.snapshot()["state"] == "burst_complete"
|
||||
finally:
|
||||
publisher.close()
|
||||
|
||||
|
||||
def _schedule() -> dict:
|
||||
return ModeSchedule.from_dict({
|
||||
"schema": "cognitive-agent-mode-schedule/v1",
|
||||
"repeat_count": 2,
|
||||
"phases": [
|
||||
{"id": "warm", "name": "暖舱", "duration_ms": 1000, "operations": [
|
||||
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||
"operation": "set", "parameters": {"temperature_c": 28}},
|
||||
]},
|
||||
{"id": "settle", "name": "舒适", "duration_ms": 2000, "operations": [
|
||||
{"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||
"operation": "set", "parameters": {"temperature_c": 24}},
|
||||
{"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger",
|
||||
"operation": "set", "parameters": {"position_pct": 45}},
|
||||
]},
|
||||
],
|
||||
}).to_dict()
|
||||
|
||||
|
||||
def test_schedule_catalog_preserves_phases_but_never_exports_flat_executable_actions(tmp_path: Path) -> None:
|
||||
registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user")
|
||||
schedule = _schedule()
|
||||
profile = registry.save_mode(
|
||||
"分阶段舒适模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
||||
schedule=ModeSchedule.from_dict(schedule),
|
||||
command_flow={"source_request_id": "req-private", "raw_can": "private-transport-bytes"},
|
||||
)
|
||||
catalog = build_mode_scene_catalog(registry, "denza_n7")
|
||||
scene = next(item for item in catalog["scenes"] if item["id"] == profile.id)
|
||||
assert scene["schedule"] == schedule
|
||||
assert scene["behavior"] == "cyclic"
|
||||
assert scene["operation_count"] == 3
|
||||
assert scene["domains"] == ["climate", "seat"]
|
||||
assert scene["actions"] == [], "legacy clients must not execute all timeline stages at once"
|
||||
assert scene["execution"] == {
|
||||
"kind": "server_schedule", "mode_id": profile.id,
|
||||
"client_actions_supported": False, "requires_authentication": True,
|
||||
}
|
||||
assert "服务端时间轴" in scene["execution_notice"]
|
||||
assert scene["preview_actions_scope"] == "single_cycle"
|
||||
preview = scene["preview_actions"]
|
||||
assert [action["phase_id"] for action in preview] == ["warm", "settle", "settle"]
|
||||
assert [action["at_ms"] for action in preview] == [0, 1000, 1000]
|
||||
assert [action["phase_duration_ms"] for action in preview] == [1000, 2000, 2000]
|
||||
assert [action["celsius"] for action in preview if action["type"] == "climate_temperature"] == [28, 24]
|
||||
assert preview[-1]["target_scope"] == "passenger"
|
||||
assert preview[-1]["parameters"] == {"position_pct": 45}
|
||||
assert ModeSchedule.from_dict(scene["schedule"]).total_duration_ms == 6000
|
||||
encoded = json.dumps(catalog)
|
||||
assert "source_request_id" not in encoded
|
||||
assert "private-transport-bytes" not in encoded
|
||||
assert "command_flow" not in encoded
|
||||
|
||||
|
||||
def test_schedule_publisher_updates_after_save_reload_duration_change_and_delete(tmp_path: Path) -> None:
|
||||
registry = ModeProfileRegistry(tmp_path / "presets", user_root=tmp_path / "user")
|
||||
publisher = ModeSceneCatalogPublisher(tmp_path / "catalog.json", "denza_n7")
|
||||
registry.add_change_listener(publisher.publish)
|
||||
assert publisher.snapshot()["scene_count"] == 0
|
||||
schedule = _schedule()
|
||||
created = registry.save_mode(
|
||||
"动态释压模式", schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
||||
schedule=ModeSchedule.from_dict(schedule),
|
||||
)
|
||||
first = publisher.snapshot()
|
||||
assert first["scenes"][0]["schedule"] == schedule
|
||||
registry.reload()
|
||||
assert publisher.snapshot() == first
|
||||
schedule["phases"][1]["duration_ms"] = 5000
|
||||
registry.save_mode(
|
||||
created.name, schedule["phases"][0]["operations"], vehicle_id="denza_n7",
|
||||
mode_id=created.id, schedule=ModeSchedule.from_dict(schedule),
|
||||
)
|
||||
changed = publisher.snapshot()
|
||||
assert changed["catalog_revision"] != first["catalog_revision"]
|
||||
assert changed["scenes"][0]["profile_revision"] != first["scenes"][0]["profile_revision"]
|
||||
assert changed["scenes"][0]["schedule"]["phases"][1]["duration_ms"] == 5000
|
||||
assert changed["scenes"][0]["actions"] == []
|
||||
assert json.loads(publisher.path.read_text(encoding="utf-8")) == changed
|
||||
registry.delete_user_mode(created.id, "denza_n7")
|
||||
assert publisher.snapshot()["scene_count"] == 0
|
||||
assert publisher.snapshot()["scenes"] == []
|
||||
|
||||
@@ -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",
|
||||
(
|
||||
|
||||
@@ -36,6 +36,9 @@ EXPECTED_SCENE_CAPABILITIES = {
|
||||
"seat.set_legrest_height",
|
||||
"seat.set_longitudinal_position",
|
||||
"seat.set_ventilation",
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
"window.set_position",
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,57 @@ from cognitive_agent.application.codex_assistant import (
|
||||
CONTROL_TERMS = ("空调", "座椅", "车窗", "遮阳帘", "氛围灯", "阅读灯", "出风口")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "mode_name"), [
|
||||
("构建一个观影模式", "观影模式"),
|
||||
("为主驾构建一个观影模式", "主驾观影模式"),
|
||||
])
|
||||
def test_local_first_callbacks_do_not_intercept_scoped_mode_draft_creation(message, mode_name):
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events = []
|
||||
control_calls = []
|
||||
|
||||
def execute_control(instruction, _progress, plan):
|
||||
control_calls.append((instruction, plan))
|
||||
return _preview_result(mode_name)
|
||||
|
||||
result = assistant.stream(
|
||||
message, conversation_id="conv-merged-scene-router", principal="test",
|
||||
history=[], emit=events.append, execute_control=execute_control,
|
||||
preview_control=lambda *_args: pytest.fail("mode previews must not partially execute via local-first controls"),
|
||||
supplement_control=lambda *_args: pytest.fail("mode previews must not become unresolved control supplements"),
|
||||
control_terms=CONTROL_TERMS,
|
||||
capability_snapshot=_catalog_with_templates(_scene_template()),
|
||||
scene_capability_context=_scene_context(),
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["route"] == "scene_template"
|
||||
assert [instruction for instruction, _ in control_calls] == [message]
|
||||
assert not client.structured_calls and not client.legacy_calls
|
||||
|
||||
|
||||
def test_local_first_callbacks_preserve_schedule_stop_route_and_answer():
|
||||
client = StructuredSceneClient()
|
||||
assistant = CodexAssistantService(client)
|
||||
events = []
|
||||
result = assistant.stream(
|
||||
"停止动态模式", conversation_id="conv-merged-schedule-stop", principal="test",
|
||||
history=[], emit=events.append,
|
||||
execute_control=lambda instruction, _progress: {
|
||||
"request_id": "req-stop-merged", "utterance": instruction,
|
||||
"status": "succeeded", "execution_mode": "dry_run",
|
||||
"orchestration_route": "mode_schedule_stop",
|
||||
"schedule_execution": {"status": "idle"},
|
||||
},
|
||||
preview_control=lambda *_args: pytest.fail("schedule stop must retain its scheduler route"),
|
||||
supplement_control=lambda *_args: pytest.fail("schedule stop is not an unresolved control"),
|
||||
control_terms=CONTROL_TERMS,
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert "已模拟停止动态模式" in result["answer"]
|
||||
assert not client.structured_calls and not client.legacy_calls
|
||||
|
||||
|
||||
def _structured_plan(
|
||||
*,
|
||||
temperature: float = 24,
|
||||
@@ -793,6 +844,11 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
|
||||
]
|
||||
call = client.structured_calls[0]
|
||||
expected_context = _scene_context()
|
||||
expected_context["seat_scope_policy"] = {
|
||||
"selected_scopes": ["passenger"],
|
||||
"default_scope": "passenger",
|
||||
"applies_to": "seat capabilities in every phase",
|
||||
}
|
||||
expected_context["natural_language_routing"] = {
|
||||
"original_utterance": "构建一个星夜模式",
|
||||
"parsed_clauses": [],
|
||||
|
||||
@@ -15,6 +15,8 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
(
|
||||
("帮我构建一个适合看书的功能模式", ("看书模式", "")),
|
||||
("构建一个适合放松的功能模式", ("放松模式", "")),
|
||||
("构建一个座椅舒适的功能模式", ("座椅舒适模式", "")),
|
||||
("构建一个让人坐着放松的功能模式", ("坐着放松模式", "")),
|
||||
("请帮我创建适合阅读的模式吧", ("阅读模式", "")),
|
||||
("我想生成一个用来读书的模式并预览", ("读书模式", "")),
|
||||
("请生成用于放松的模式,先看看", ("放松模式", "")),
|
||||
@@ -67,6 +69,8 @@ def test_explicit_names_are_never_rewritten(
|
||||
("帮我构建一个适合看书的功能模式", "reading"),
|
||||
("请创建一个用于阅读的功能模式", "reading"),
|
||||
("构建一个适合放松的功能模式", "relaxation"),
|
||||
("构建一个座椅舒适的功能模式", "seat-comfort"),
|
||||
("构建一个让人坐着放松的功能模式", "relaxation"),
|
||||
("我想创建一套用来舒缓的模式", "relaxation"),
|
||||
("帮我构建一个适合看电影的功能模式", "cinema"),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.mode_profile_intent import parse_mode_profile_create
|
||||
from cognitive_agent.application.scene_composer import build_scene_output_schema
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def catalog():
|
||||
service = SeatDemoService()
|
||||
try:
|
||||
yield AssistantCapabilityCatalog(service)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", (
|
||||
"构建一个座椅舒适的功能模式",
|
||||
"构建一个让人坐着放松的功能模式",
|
||||
"可以帮我创建一个适合座椅放松的功能模式吗?",
|
||||
"构建一个主驾按摩的功能模式",
|
||||
"创建一个空调舒适的功能模式",
|
||||
"构建一个适合看书的功能模式,阅读灯打开",
|
||||
))
|
||||
def test_mode_creation_is_not_claimed_by_capability_fast_answer(catalog, message):
|
||||
assert parse_mode_profile_create(message) is not None
|
||||
assert catalog.fast_answer(message) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", (
|
||||
"支持主驾座椅按摩吗?",
|
||||
"主驾与副驾座椅有哪些功能?",
|
||||
"座椅通风支持哪些档位?",
|
||||
"副驾座椅支持按摩吗?",
|
||||
))
|
||||
def test_genuine_seat_questions_remain_read_only_capability_answers(catalog, message):
|
||||
assert parse_mode_profile_create(message) is None
|
||||
assert catalog.fast_answer(message) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", (
|
||||
"不要构建座椅舒适模式",
|
||||
"构建一个座椅舒适模式,但不要执行",
|
||||
"取消创建坐着放松模式",
|
||||
"如何构建一个座椅舒适模式?",
|
||||
"解释构建一个座椅舒适的功能模式这句话",
|
||||
))
|
||||
def test_negations_and_read_only_seat_mode_requests_never_authorize_creation(message):
|
||||
assert parse_mode_profile_create(message) is None
|
||||
|
||||
|
||||
def _response(capability, target, parameters):
|
||||
return {
|
||||
"requests": [{"capability": capability, "target_scope": target, "parameters": parameters}],
|
||||
"ambiguities": [],
|
||||
"confidence": 0.95,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("capability", "target", "parameters", "valid"), (
|
||||
("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 2}, True),
|
||||
("seat.set_driver_heating_native", "driver", {"state": "on", "level": 1}, True),
|
||||
("seat.set_driver_heating_native", "front_left", {"state": "off"}, True),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax"}, True),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "strength", "strength": 1}, True),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "off"}, True),
|
||||
("seat.set_driver_massage_native", "passenger", {"action": "mode", "mode": "relax"}, False),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "mode"}, False),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "mode", "strength": 1}, False),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "off", "mode": "relax"}, False),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax", "strength": 1}, False),
|
||||
("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "unknown"}, False),
|
||||
("seat.set_driver_heating_native", "passenger", {"state": "on", "level": 2}, False),
|
||||
("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 3}, False),
|
||||
("seat.set_driver_axis_native", "driver", {"axis": "backrest", "direction": "backward", "duration_ms": 500}, False),
|
||||
("seat.set_driver_position_native", "driver", {"slot": 1}, False),
|
||||
("seat.set_backrest_recline", "driver", {"position_pct": 45}, False),
|
||||
))
|
||||
def test_scene_schema_respects_real_driver_and_passenger_capabilities(
|
||||
catalog, capability, target, parameters, valid,
|
||||
):
|
||||
context = catalog.scene_composition_context()
|
||||
validator = Draft202012Validator(build_scene_output_schema(context))
|
||||
assert validator.is_valid(_response(capability, target, parameters)) is valid
|
||||
|
||||
|
||||
def test_scene_context_removes_driver_capabilities_when_runtime_marks_them_unavailable(catalog):
|
||||
snapshot = deepcopy(catalog.snapshot())
|
||||
for capability in snapshot["capabilities"]:
|
||||
if capability["id"].startswith("seat.set_driver_"):
|
||||
capability["assistant_callable"] = False
|
||||
context = catalog.scene_composition_context(snapshot)
|
||||
assert not any(item["id"].startswith("seat.set_driver_") for item in context["capabilities"])
|
||||
validator = Draft202012Validator(build_scene_output_schema(context))
|
||||
assert not validator.is_valid(_response(
|
||||
"seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax"},
|
||||
))
|
||||
|
||||
|
||||
def test_seat_comfort_detail_answer_uses_seat_specific_natural_language(catalog):
|
||||
answer = catalog.fast_answer("座椅舒适模式会控制哪些设备?")
|
||||
assert answer is not None
|
||||
assert "主驾座椅通风开启低档" in answer.answer
|
||||
assert "主驾座椅按摩设为放松模式" in answer.answer
|
||||
assert "副驾座椅通风" in answer.answer
|
||||
assert "副驾座椅靠背" in answer.answer
|
||||
assert "action=" not in answer.answer
|
||||
assert "主驾主驾" not in answer.answer
|
||||
Reference in New Issue
Block a user