Merge origin/feature/N7Web into feature/N7Web
Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
This commit is contained in:
@@ -595,6 +595,58 @@ test("the change-into predicate carries only a same-name opaque draft reference"
|
||||
assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false);
|
||||
});
|
||||
|
||||
for (const phrase of [
|
||||
"在午休模式中加入空调、氛围灯、阅读灯、车窗等设备动作",
|
||||
"请帮我在午休模式里添加空调和车窗动作",
|
||||
"给午休模式增加氛围灯",
|
||||
"为午休模式补充阅读灯",
|
||||
"往午休模式内加入空调,温度26度",
|
||||
"向午休模式中加上关闭车窗",
|
||||
"午休模式中再加入空调温度26度",
|
||||
"午休模式再补充车窗和阅读灯",
|
||||
"把关闭车窗加入到午休模式中",
|
||||
"请将空调26度和柔和氛围灯添加到午休模式",
|
||||
]) {
|
||||
test(`device additions carry only the same-name opaque draft: ${phrase}`, async () => {
|
||||
const events = modeDraftEvents("req-draft-add-devices", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
events.splice(0, events.length, { type: "answer.completed", answer: "已收到草稿补充请求" });
|
||||
harness.input.value = phrase;
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
const request = JSON.parse(harness.requestCalls.at(-1).options.body);
|
||||
assert.equal(request.message, phrase);
|
||||
assert.deepEqual(request.mode_profile_draft_context, {
|
||||
name: "午休模式", source_request_id: "req-draft-add-devices", vehicle_id: "demo-vehicle",
|
||||
});
|
||||
assert.equal(harness.requestCalls.every((call) => call.url === "/api/assistant/stream"), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
"在露营模式中加入空调和车窗",
|
||||
"把空调添加到露营模式中",
|
||||
"在午休模式中加入空调可以吗?",
|
||||
"午休模式中加入哪些设备合适?",
|
||||
"在午休模式中不要加入空调",
|
||||
"在午休模式中先别添加车窗",
|
||||
"打开空调",
|
||||
"设置空调出风模式为吹脚",
|
||||
]) {
|
||||
test(`non-update or other-mode additions do not borrow a draft: ${phrase}`, async () => {
|
||||
const events = modeDraftEvents("req-draft-add-isolation", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
events.splice(0, events.length, { type: "answer.completed", answer: "已收到" });
|
||||
harness.input.value = phrase;
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
const request = JSON.parse(harness.requestCalls.at(-1).options.body);
|
||||
assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false);
|
||||
});
|
||||
}
|
||||
|
||||
test("shade position rows explain the target opening at semantic boundaries", async () => {
|
||||
const positions = [0, 30, 50, 100];
|
||||
const events = [
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"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 }),
|
||||
isN7SyncedModeProfile: () => false,
|
||||
modeEditorReadableCommands: () => [],
|
||||
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 [["small", "modeActionEyebrow"], ["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("direct saved-mode execution keeps a running schedule active and starts polling", () => {
|
||||
const updates = [];
|
||||
const polls = [];
|
||||
const context = {
|
||||
beginModeExecution() { throw new Error("an existing execution handle must be reused"); },
|
||||
setMessageContent: (_message, content, options) => updates.push({ kind: "content", content, options }),
|
||||
setActionStatus: (_message, content, state) => updates.push({ kind: "status", content, state }),
|
||||
appendDispatchedControls: (_message, result) => updates.push({ kind: "controls", result }),
|
||||
startScheduleStatusPoll: (result, message) => polls.push({ result, message }),
|
||||
persistMessages: () => updates.push({ kind: "persist" }),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(functionSource(assistant, "completeModeExecution"), context);
|
||||
const message = { body: {} };
|
||||
const result = {
|
||||
status: "running",
|
||||
vehicle_id: "denza_n7",
|
||||
schedule_execution: { status: "running", session_id: "session-direct" },
|
||||
};
|
||||
|
||||
context.completeModeExecution(
|
||||
{ message, modeName: "动态舒适", commandText: "进入动态舒适" },
|
||||
{ id: "dynamic-comfort" },
|
||||
result,
|
||||
);
|
||||
|
||||
assert.match(updates.find((item) => item.kind === "content").content, /已启动动态时间轴/);
|
||||
assert.deepEqual(updates.find((item) => item.kind === "status"), {
|
||||
kind: "status",
|
||||
content: "动态时间轴执行中,可输入“停止动态模式”提前结束",
|
||||
state: "working",
|
||||
});
|
||||
assert.equal(polls.length, 1);
|
||||
assert.equal(polls[0].result, result);
|
||||
assert.equal(polls[0].message, message);
|
||||
});
|
||||
|
||||
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+(?:event\.preventDefault\?\.\(\);\s+)?stopScheduleStatusPoll\(\);/);
|
||||
assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/);
|
||||
assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/);
|
||||
});
|
||||
@@ -298,7 +298,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"]
|
||||
@@ -341,7 +341,7 @@ def test_saved_mode_detail_fast_answer_is_exact_read_only_and_natural(
|
||||
"遮阳帘",
|
||||
"氛围灯",
|
||||
"副驾空调温度设为25℃",
|
||||
"共10项动作",
|
||||
"共12项动作",
|
||||
"本次仅查询,未下发车辆控制",
|
||||
)
|
||||
)
|
||||
@@ -372,7 +372,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) == 16
|
||||
assert {item["name"] for item in templates} >= {
|
||||
"观影模式",
|
||||
"露营模式",
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
|
||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"'
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"'
|
||||
in service_worker
|
||||
)
|
||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||
@@ -316,7 +316,7 @@ def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_tur
|
||||
assert "rememberModeDraftContext(draft)" in assistant
|
||||
assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant
|
||||
assert "function isModeDraftUpdateRequest(message, context)" in assistant
|
||||
assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant
|
||||
assert "normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name)" in assistant
|
||||
|
||||
|
||||
def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context():
|
||||
|
||||
@@ -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档"),
|
||||
]
|
||||
@@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
||||
"/energy-monitor.js?v=energy-monitor-5-performance"
|
||||
)
|
||||
assert (
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"'
|
||||
'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"'
|
||||
in service_worker
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.firmware_workbench import (
|
||||
FINAL_RELEASE_MESSAGE,
|
||||
FirmwareWorkbenchService,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
task = service.task(task_id)
|
||||
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
return task
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("firmware pipeline did not reach a terminal state")
|
||||
|
||||
|
||||
def make_image(tmp_path: Path) -> Path:
|
||||
image = tmp_path / "Seat_Controller_demo.hex"
|
||||
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
|
||||
return image
|
||||
|
||||
|
||||
def test_pipeline_publishes_a_downloadable_ota_package(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
"http://192.168.43.130:8000/api/firmware-workbench/releases",
|
||||
)
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能"
|
||||
|
||||
first = service.start_task(instruction)
|
||||
task = wait_for_terminal(service, str(first["task"]["id"]))
|
||||
assert task["status"] == "success"
|
||||
assert task["message"] == FINAL_RELEASE_MESSAGE
|
||||
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
|
||||
assert len(task["test_results"]) == 7
|
||||
|
||||
snapshot = service.snapshot()
|
||||
assert snapshot["baselines"] == [
|
||||
{
|
||||
"id": "time_only",
|
||||
"name": "时间控制基线",
|
||||
"capabilities": ["时间控制"],
|
||||
}
|
||||
]
|
||||
assert len(snapshot["artifacts"]) == 2
|
||||
assert len(snapshot["releases"]) == 1
|
||||
artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"])
|
||||
baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline"))
|
||||
release = snapshot["releases"][0]
|
||||
assert baseline["capabilities"] == ["时间控制"]
|
||||
assert baseline["protected"] is True
|
||||
assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"]
|
||||
assert artifact["status"] == "published"
|
||||
assert release["download_url"] == (
|
||||
f"http://192.168.43.130:8000/api/firmware-workbench/releases/"
|
||||
f"{release['id']}/download"
|
||||
)
|
||||
assert service.latest_release()["download_url"] == release["download_url"]
|
||||
latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
||||
assert latest_manifest["download_url"] == release["download_url"]
|
||||
package, filename = service.resolve_release_download(str(release["id"]))
|
||||
assert filename == artifact["firmware_filename"]
|
||||
assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"]
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
assert {
|
||||
"Seat_Controller_demo.hex",
|
||||
"manifest.json",
|
||||
"requirements.json",
|
||||
"validation.json",
|
||||
"feature_profile.json",
|
||||
} <= set(archive.namelist())
|
||||
assert (tmp_path / "ota" / "latest.json").is_file()
|
||||
assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"]
|
||||
service.close()
|
||||
|
||||
monkeypatch.setenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
"http://192.168.43.131:8000/api/firmware-workbench/releases",
|
||||
)
|
||||
migrated = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
migrated_release = migrated.latest_release()
|
||||
assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/")
|
||||
migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
||||
assert migrated_latest["download_url"] == migrated_release["download_url"]
|
||||
migrated.close()
|
||||
|
||||
|
||||
def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
first = service.start_task("增加座椅加热功能。")
|
||||
first_task = wait_for_terminal(service, str(first["task"]["id"]))
|
||||
second = service.start_task("增加座椅加热功能。")
|
||||
|
||||
assert first["task"]["id"] != second["task"]["id"]
|
||||
assert "duplicate" not in first
|
||||
assert "duplicate" not in second
|
||||
second_task = wait_for_terminal(service, str(second["task"]["id"]))
|
||||
assert first_task["status"] == "success"
|
||||
assert second_task["status"] == "success"
|
||||
assert first_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
||||
assert second_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
||||
service.close()
|
||||
|
||||
|
||||
def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加座椅通风功能")
|
||||
task = wait_for_terminal(service, str(started["task"]["id"]))
|
||||
package_path, _ = service.resolve_artifact_download(str(task["artifact_id"]))
|
||||
release_path, _ = service.resolve_release_download(str(task["release_id"]))
|
||||
|
||||
result = service.delete_artifact(str(task["artifact_id"]))
|
||||
assert result["deleted"] is True
|
||||
assert not package_path.exists()
|
||||
assert not release_path.exists()
|
||||
snapshot = service.snapshot()
|
||||
assert [artifact["id"] for artifact in snapshot["artifacts"]] == [
|
||||
"fw-art-time-only-baseline"
|
||||
]
|
||||
assert snapshot["releases"] == []
|
||||
|
||||
try:
|
||||
service.delete_artifact("fw-art-time-only-baseline")
|
||||
except Exception as exc: # noqa: BLE001 - public error type is asserted below
|
||||
assert getattr(exc, "code", None) == "baseline_protected"
|
||||
else:
|
||||
raise AssertionError("time-only baseline must not be deletable")
|
||||
service.close()
|
||||
|
||||
|
||||
def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: profile position=1 ventilation=0 heating=0")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only")
|
||||
task = wait_for_terminal(service, str(started["task"]["id"]))
|
||||
|
||||
assert task["status"] == "success"
|
||||
assert task["baseline_capabilities"] == ["时间控制"]
|
||||
assert task["requested_capabilities"] == ["位置控制"]
|
||||
assert task["target_capabilities"] == ["时间控制", "位置控制"]
|
||||
artifact = service.snapshot()["artifacts"][0]
|
||||
assert artifact["capabilities"] == ["时间控制", "位置控制"]
|
||||
assert artifact["capability_diff"]["added"] == ["位置控制"]
|
||||
assert "vent" not in artifact["firmware_filename"]
|
||||
assert "heat" not in artifact["firmware_filename"]
|
||||
service.close()
|
||||
|
||||
|
||||
def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
allow_build = threading.Event()
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build started")
|
||||
while not allow_build.is_set():
|
||||
if cancel.wait(0.01):
|
||||
raise InterruptedError
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加座椅通风功能")
|
||||
task_id = str(started["task"]["id"])
|
||||
deadline = time.monotonic() + 3
|
||||
while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
service.cancel_task(task_id)
|
||||
canceled = wait_for_terminal(service, task_id)
|
||||
assert canceled["status"] == "canceled"
|
||||
assert any(stage["status"] == "canceled" for stage in canceled["stages"])
|
||||
|
||||
allow_build.set()
|
||||
retried = service.retry_task(task_id)
|
||||
assert retried["id"] == task_id
|
||||
completed = wait_for_terminal(service, task_id)
|
||||
assert completed["status"] == "success"
|
||||
service.close()
|
||||
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WEB = ROOT / "apps" / "web-console"
|
||||
|
||||
|
||||
def source(name: str) -> str:
|
||||
return (WEB / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_workbench_is_a_standalone_development_surface() -> None:
|
||||
html = source("firmware-workbench.html")
|
||||
script = source("firmware-workbench.js")
|
||||
|
||||
assert "座椅固件开发工作台" in html
|
||||
assert '<textarea id="instructionInput"' in html
|
||||
assert 'id="startTaskButton"' in html
|
||||
assert 'id="cancelTaskButton"' in html
|
||||
assert 'id="pipeline"' in html
|
||||
assert 'id="logStream"' in html
|
||||
assert 'id="artifactTableBody"' in html
|
||||
assert 'id="baselineSelect"' in html
|
||||
assert 'id="targetCapabilities"' in html
|
||||
assert "执行摘要" not in html
|
||||
assert "单屏工作台" not in html
|
||||
assert "相同指令只会复用同一任务" not in html
|
||||
assert "固件交付流水线</h2>" not in html
|
||||
assert html.count("<option") == 1
|
||||
assert '<option value="time_only">时间控制</option>' in html
|
||||
for english_label in ("DEVELOPMENT BRIEF", "DELIVERY PIPELINE", "LIVE EXECUTION", "SOFTWARE ARTIFACTS"):
|
||||
assert english_label not in html
|
||||
assert "普通聊天" not in html
|
||||
for stage in ("指令输入", "需求解析", "基线检查", "代码生成", "编译构建", "测试验证", "固件打包", "发布服务器"):
|
||||
assert stage in script or stage in html
|
||||
for status in ("等待", "运行中", "待确认", "成功", "失败", "取消", "阻塞"):
|
||||
assert status in script or status in html
|
||||
|
||||
|
||||
def test_workbench_exposes_artifact_actions_and_always_starts_a_new_task() -> None:
|
||||
html = source("firmware-workbench.html")
|
||||
script = source("firmware-workbench.js")
|
||||
entry = source("firmware-workbench-entry.js")
|
||||
index = source("index.html")
|
||||
worker = source("service-worker.js")
|
||||
vite = source("vite.config.js")
|
||||
|
||||
for label in ("查看详情", "能力差异", "代码差异", "验证记录", "下载固件", "发布到服务器", "删除固件产物"):
|
||||
assert label in script
|
||||
assert "检测到相同指令" not in script
|
||||
assert "result.duplicate" not in script
|
||||
assert "已发布,待车机端下载升级" not in script
|
||||
assert 'id = "firmwareWorkbenchToggle"' in entry
|
||||
assert 'className = "firmware-workbench-layer"' in entry
|
||||
assert "/firmware-workbench-entry.js?v=2" in index
|
||||
assert "/firmware-workbench-entry.css?v=1" in index
|
||||
for asset in (
|
||||
"/firmware-workbench-entry.css?v=1",
|
||||
"/firmware-workbench-entry.js?v=2",
|
||||
"/firmware-workbench.html",
|
||||
"/firmware-workbench.css?v=5",
|
||||
"/firmware-workbench.js?v=3",
|
||||
):
|
||||
assert f'"{asset}"' in worker
|
||||
for runtime_file in (
|
||||
"firmware-workbench-entry.js",
|
||||
"firmware-workbench-entry.css",
|
||||
"firmware-workbench.html",
|
||||
"firmware-workbench.js",
|
||||
"firmware-workbench.css",
|
||||
):
|
||||
assert f'"{runtime_file}"' in vite
|
||||
@@ -99,5 +99,5 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' in worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"' in worker
|
||||
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -52,7 +54,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
assert source_asset in main
|
||||
assert f'"{cached_asset}"' in service_worker
|
||||
assert 'import "./styles/design-system.css"' in main
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"' in service_worker
|
||||
|
||||
|
||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||
@@ -95,6 +97,21 @@ def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||
assert "border-radius: 0 !important" in theme
|
||||
|
||||
|
||||
def test_theme_bootstrap_is_allowed_by_csp_without_unsafe_inline() -> None:
|
||||
html = (WEB / "index.html").read_text(encoding="utf-8")
|
||||
inline_script = re.search(r"<script>(.*?)</script>", html, flags=re.DOTALL)
|
||||
assert inline_script is not None
|
||||
digest = base64.b64encode(
|
||||
hashlib.sha256(inline_script.group(1).encode("utf-8")).digest()
|
||||
).decode("ascii")
|
||||
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert f"'sha256-{digest}'" in server
|
||||
assert "'unsafe-inline'" not in server
|
||||
|
||||
|
||||
def test_reference_theme_uses_one_transform_only_floating_card_contract() -> None:
|
||||
theme = source("codepen-glass-theme.css")
|
||||
|
||||
|
||||
@@ -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": "调节座椅前后位置",
|
||||
@@ -266,7 +278,11 @@ def test_mode_chip_scroller_keeps_clicks_and_only_captures_real_drags() -> None:
|
||||
capture = snippet.index("group.setPointerCapture(pointerId);")
|
||||
assert capture > threshold
|
||||
assert "let pointerCaptured = false;" in snippet
|
||||
assert "if (pointerCaptured) group.releasePointerCapture?.(pointerId);" in snippet
|
||||
assert "const finishedPointerId = pointerId;" in snippet
|
||||
assert "pointerId = null;" in snippet
|
||||
assert "group.hasPointerCapture?.(finishedPointerId)" in snippet
|
||||
assert "group.releasePointerCapture(finishedPointerId);" in snippet
|
||||
assert 'group.addEventListener("lostpointercapture", finishPointerDrag);' in snippet
|
||||
assert 'event.stopImmediatePropagation();' in snippet
|
||||
|
||||
|
||||
@@ -309,6 +325,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]); },
|
||||
hasExecutableModeCommandFlow(mode) {
|
||||
const flow = mode?.command_flow;
|
||||
@@ -418,6 +435,7 @@ const context = {
|
||||
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
|
||||
setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); },
|
||||
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
||||
hideModeActionPopover() {},
|
||||
showToast(message) { calls.push(["toast", message]); },
|
||||
hasExecutableModeCommandFlow(mode) {
|
||||
const flow = mode?.command_flow;
|
||||
@@ -484,6 +502,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"')
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_authorize_vehicle_request,
|
||||
_validated_mode_profile_draft_context,
|
||||
)
|
||||
from cognitive_agent.application.mode_profile_intent import (
|
||||
is_mode_profile_update_shape,
|
||||
parse_mode_profile_update,
|
||||
)
|
||||
|
||||
DRAFT_REFERENCE = {
|
||||
"name": "躺平模式", "source_request_id": "req-augmentation-source-123456", "vehicle_id": "denza_n7",
|
||||
}
|
||||
CONTROL_TERMS = ("空调", "氛围灯", "阅读灯", "车窗", "座椅")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["在", "给", "为", "往", "向", ""])
|
||||
@pytest.mark.parametrize("verb", ["加入", "添加", "增加", "补充", "加上", "追加", "新增"])
|
||||
def test_named_mode_device_augmentation_is_recognized(prefix, verb):
|
||||
message = f"{prefix}躺平模式中{verb}空调、氛围灯、阅读灯、车窗等设备动作"
|
||||
assert parse_mode_profile_update(message) == ("躺平模式", f"{verb}空调、氛围灯、阅读灯、车窗等设备动作")
|
||||
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) == DRAFT_REFERENCE
|
||||
authorization = _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE)
|
||||
assert authorization.instruction == message
|
||||
assert authorization.reason == "explicit_mode_profile_draft_update"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "change"), [
|
||||
("请在躺平模式里补充空调", "补充空调"),
|
||||
("帮我给躺平模式添加空调", "添加空调"),
|
||||
("为躺平模式新增阅读灯", "新增阅读灯"),
|
||||
("躺平模式再补充车窗", "补充车窗"),
|
||||
("躺平模式中再加入阅读灯", "再加入阅读灯"),
|
||||
("在躺平模式中加入空调24度、阅读灯打开", "加入空调24度、阅读灯打开"),
|
||||
("把空调24度、阅读灯打开加入到躺平模式中", "加入空调24度、阅读灯打开"),
|
||||
("将车窗、氛围灯追加进躺平模式", "追加车窗、氛围灯"),
|
||||
("请把空调加入躺平模式吧", "加入空调"),
|
||||
("把副驾空调温度25.5度补充到躺平模式", "补充副驾空调温度25.5度"),
|
||||
("在“躺平模式”中添加空调", "添加空调"),
|
||||
])
|
||||
def test_append_word_order_preserves_device_parameters_and_scoped_name(message, change):
|
||||
parsed = parse_mode_profile_update(message)
|
||||
assert parsed is not None and parsed[0] == "躺平模式"
|
||||
# A leading 再 modifier is harmless; neither parameters nor target words
|
||||
# may be lost when the reverse-order request is normalized.
|
||||
assert parsed[1].removeprefix("再") == change.removeprefix("再")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"不要在躺平模式中加入空调", "在躺平模式中不要加入空调",
|
||||
"躺平模式中先不要补充阅读灯", "给躺平模式添加空调,但不要执行",
|
||||
"给躺平模式添加空调,算了", "给躺平模式添加空调,撤销",
|
||||
"躺平模式中取消加入空调", "如何在躺平模式中加入空调?",
|
||||
"在躺平模式中加入空调好吗?", "在躺平模式中能否加入空调",
|
||||
"把空调加入到躺平模式会怎样?", "解释在躺平模式中加入空调这句话",
|
||||
"在躺平模式中加入哪些设备?", "引用在躺平模式中加入空调",
|
||||
"在空调出风模式中加入阅读灯", "在副驾按摩模式中添加车窗",
|
||||
"在躺平模式中加入", "给躺平模式补充",
|
||||
])
|
||||
def test_negated_read_only_or_device_property_requests_do_not_authorize_augmentation(message):
|
||||
assert parse_mode_profile_update(message) is None
|
||||
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) is None
|
||||
authorization = _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE)
|
||||
assert authorization.reason not in {"explicit_mode_profile_draft_update", "explicit_mode_profile_update"}
|
||||
|
||||
|
||||
def test_other_named_mode_never_borrows_the_selected_draft_reference():
|
||||
message = "在阅读模式中加入空调"
|
||||
assert parse_mode_profile_update(message) == ("阅读模式", "加入空调")
|
||||
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) is None
|
||||
assert _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE).reason == "explicit_mode_profile_update"
|
||||
|
||||
|
||||
def test_augmentation_reference_cannot_include_browser_supplied_device_actions():
|
||||
reference = {**DRAFT_REFERENCE, "operations": [{"capability": "seat.anything"}]}
|
||||
with pytest.raises(ValueError, match="fields are invalid"):
|
||||
_validated_mode_profile_draft_context(reference, message="在躺平模式中加入空调")
|
||||
|
||||
|
||||
def test_speech_update_shape_preserves_named_augmentation_even_when_negated():
|
||||
assert is_mode_profile_update_shape("在躺平模式中加入空调,但不要执行")
|
||||
assert parse_mode_profile_update("在躺平模式中加入空调,但不要执行") is None
|
||||
|
||||
|
||||
def test_device_augmentation_bypasses_general_agent_and_partial_control_paths():
|
||||
client = type("UnavailableClient", (), {"available": False})()
|
||||
assistant = CodexAssistantService(client)
|
||||
message = "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作"
|
||||
calls = []
|
||||
events = []
|
||||
|
||||
def execute_control(instruction, _progress):
|
||||
calls.append(instruction)
|
||||
return {
|
||||
"request_id": "req-revised-123456", "status": "succeeded", "execution_mode": "dry_run",
|
||||
"hardware_execution_allowed": False,
|
||||
"orchestration_route": "mode_profile_create_preview",
|
||||
"intent_ir": {"spec": {"actions": [{"domain": "climate"}]}},
|
||||
"mode_profile_draft": {
|
||||
"name": "躺平模式", "source_request_id": "req-revised-123456", "vehicle_id": "denza_n7",
|
||||
"instruction": "进入躺平模式", "operation_count": 1,
|
||||
"generation_source": "draft_revision", "draft_revision": 2,
|
||||
},
|
||||
}
|
||||
|
||||
reference = deepcopy(DRAFT_REFERENCE)
|
||||
result = assistant.stream(
|
||||
message, conversation_id="conv-augmentation-intent-123456", principal="test",
|
||||
history=[], emit=events.append, execute_control=execute_control,
|
||||
preview_control=lambda *_args: pytest.fail("augmentation must not run partial hardware controls"),
|
||||
supplement_control=lambda *_args: pytest.fail("augmentation must not become a vehicle supplement"),
|
||||
mode_profile_draft_context=reference, control_terms=CONTROL_TERMS,
|
||||
)
|
||||
assert calls == [message]
|
||||
assert result["success"] is True
|
||||
assert result["result"]["mode_profile_draft"]["name"] == "躺平模式"
|
||||
assert reference == DRAFT_REFERENCE
|
||||
assert events[0]["route"] == "vehicle_control"
|
||||
@@ -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
|
||||
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
||||
assert package["scripts"]["build"] == "vite build"
|
||||
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
||||
assert 'outDir: outputRoot' in vite
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"' in service_worker
|
||||
assert "unpkg.com" not in html
|
||||
assert "cdn.jsdelivr.net" not in html
|
||||
|
||||
|
||||
Reference in New Issue
Block a user