436 lines
21 KiB
JavaScript
436 lines
21 KiB
JavaScript
"use strict";
|
||
|
||
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
const test = require("node:test");
|
||
const vm = require("node:vm");
|
||
|
||
const web = path.resolve(__dirname, "../../apps/web-console");
|
||
const assistant = fs.readFileSync(path.join(web, "assistant-chat.js"), "utf8");
|
||
const app = fs.readFileSync(path.join(web, "app.js"), "utf8");
|
||
|
||
function functionSource(source, name) {
|
||
const start = source.search(new RegExp(`(?: )?(?:async )?function ${name}\\(`));
|
||
assert.ok(start >= 0, `missing production function ${name}`);
|
||
const rest = source.slice(start);
|
||
const next = rest.slice(1).search(/\n(?: )?(?:async )?function /);
|
||
return next < 0 ? rest : rest.slice(0, next + 1);
|
||
}
|
||
|
||
class Element {
|
||
constructor(tagName) {
|
||
this.tagName = tagName;
|
||
this.children = [];
|
||
this.dataset = {};
|
||
this.attributes = {};
|
||
this.listeners = {};
|
||
this.className = "";
|
||
this.textContent = "";
|
||
this.classList = { add: (name) => { this.className += ` ${name}`; } };
|
||
}
|
||
append(...children) { this.children.push(...children); }
|
||
replaceChildren(...children) { this.children = children; }
|
||
setAttribute(name, value) { this.attributes[name] = value; }
|
||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||
querySelectorAll(selector) {
|
||
return descendants(this).filter((item) => {
|
||
if (selector.startsWith(".")) return item.className.split(/\s+/).includes(selector.slice(1));
|
||
const match = selector.match(/^\[data-([\w-]+)(?:="([^"]*)")?\]$/);
|
||
if (match) {
|
||
const key = match[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
||
return key in item.dataset && (match[2] === undefined || item.dataset[key] === match[2]);
|
||
}
|
||
return item.tagName === selector;
|
||
});
|
||
}
|
||
}
|
||
|
||
function descendants(element) {
|
||
return element.children.flatMap((item) => typeof item === "object" ? [item, ...descendants(item)] : []);
|
||
}
|
||
|
||
function textOf(element) {
|
||
return [element.textContent, ...descendants(element).map((item) => item.textContent)].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function harness() {
|
||
const body = new Element("body");
|
||
const calls = [];
|
||
const document = {
|
||
body,
|
||
createElement: (tag) => new Element(tag),
|
||
createTextNode: (text) => Object.assign(new Element("#text"), { textContent: text }),
|
||
querySelectorAll: (selector) => body.querySelectorAll(selector),
|
||
};
|
||
const context = {
|
||
document, window: {}, CSS: { escape: String },
|
||
$: (selector, parent = body) => parent.querySelector(selector),
|
||
rememberModeDraftContext() {}, supersedeModeDraftCards() {}, followLatest() {},
|
||
normalizeModeName: String,
|
||
saveModeProfileDraft: (...args) => calls.push({ kind: "save", args }),
|
||
tryModeProfileDraft: (...args) => calls.push({ kind: "trial", args }),
|
||
modeSecondsLabel: (value) => String(Number(value) / 1000),
|
||
LEGACY_DYNAMIC_MODE_IDS: new Set(["rocking_horse", "dynamic_cradle"]),
|
||
};
|
||
vm.createContext(context);
|
||
const constants = app.slice(app.indexOf("const MODE_DOMAIN_LABELS"), app.indexOf("function modeOperationParameters"))
|
||
+ app.slice(app.indexOf("const CONFIG_CAPABILITY_LABELS"), app.indexOf("function makeConfigElement"));
|
||
const assistantNames = ["modeDraftRows", "modeDraftScheduleView", "appendModeDraftRows", "appendModeDraftState"];
|
||
const appNames = ["formatConfigValue", "modeOperationParameters", "formatModeOperationValue", "modeOperationView", "modeTimelineTargetSummary", "modeTimelineStepView", "modeScheduleDurationLabel", "modeScheduleView", "modeExecutionActionViews", "modeScheduleTrialMatches", "modeScheduleExecutionView", "isUserModeProfile", "isDynamicModeProfile", "createModeGalleryCard", "renderModeActionPopover"];
|
||
vm.runInContext(constants + assistantNames.map((name) => functionSource(assistant, name)).join("\n")
|
||
+ appNames.map((name) => functionSource(app, name)).join("\n"), context);
|
||
return { context, body, calls };
|
||
}
|
||
|
||
function operation(capability, parameters, scope = "vehicle") {
|
||
return { domain: capability.split(".")[0], capability, target_scope: scope, operation: "set", parameters };
|
||
}
|
||
|
||
function schedule(overrides = {}) {
|
||
return {
|
||
schema: "cognitive-agent-mode-schedule/v1",
|
||
repeat_count: 2,
|
||
phases: [
|
||
{ id: "warm", name: "快速预热", duration_ms: 60000, operations: [
|
||
operation("climate.set_temperature", { temperature_c: 28 }),
|
||
operation("seat.set_driver_massage_native", { action: "mode", mode: "relax" }, "driver"),
|
||
operation("seat.set_heating", { state: "on", level: 1 }, "passenger"),
|
||
] },
|
||
{ id: "comfortable", name: "舒适保持", duration_ms: 30000, operations: [
|
||
operation("climate.set_temperature", { temperature_c: 24 }),
|
||
operation("seat.set_driver_massage_native", { action: "off" }, "driver"),
|
||
operation("seat.set_heating", { state: "off" }, "passenger"),
|
||
] },
|
||
],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
test("assistant keeps phases, relative timing, repeat count, total duration and stop guidance", () => {
|
||
const { context } = harness();
|
||
const view = context.modeDraftScheduleView({ schedule: schedule() });
|
||
assert.equal(view.summary, "2 个阶段 · 重复 2 次 · 总时长 3分");
|
||
assert.equal(view.operationCount, 6);
|
||
assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1分");
|
||
assert.equal(view.phases[1].timing, "+1分 开始 · 持续 30秒");
|
||
assert.match(view.stopHint, /停止动态模式/);
|
||
assert.match(view.stopHint, /每轮/);
|
||
});
|
||
|
||
test("driver and passenger scopes and changed device values are not collapsed", () => {
|
||
const { context } = harness();
|
||
const view = context.modeDraftScheduleView({ schedule: schedule() });
|
||
assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
|
||
["空调温度", "28℃"], ["主驾座椅按摩", "放松模式"], ["副驾座椅加热", "开启 · 1档"],
|
||
]);
|
||
assert.deepEqual(Array.from(view.phases[1].rows, (row) => [row.label, row.value]), [
|
||
["空调温度", "24℃"], ["主驾座椅按摩", "关闭"], ["副驾座椅加热", "关闭"],
|
||
]);
|
||
});
|
||
|
||
test("phase previews never apply whole-instruction zero-gravity grouping", () => {
|
||
const { context } = harness();
|
||
const view = context.modeDraftScheduleView({ instruction: "副驾零重力", schedule: schedule({ phases: [{
|
||
duration_ms: 1500, operations: [
|
||
operation("seat.set_longitudinal_position", { position_pct: 80 }, "passenger"),
|
||
operation("seat.set_backrest_recline", { position_pct: 60 }, "passenger"),
|
||
],
|
||
}] }) });
|
||
assert.equal(view.phases[0].rows.length, 2);
|
||
assert.equal(view.phases[0].name, "阶段 1");
|
||
assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1.5秒");
|
||
});
|
||
|
||
test("per-zone climate targets are explicit even within the same phase", () => {
|
||
const { context } = harness();
|
||
const view = context.modeDraftScheduleView({ schedule: schedule({ phases: [{
|
||
duration_ms: 2000, operations: [
|
||
operation("climate.set_temperature", { temperature_c: 26 }, "front_left"),
|
||
operation("climate.set_temperature", { temperature_c: 24 }, "front_right"),
|
||
],
|
||
}] }) });
|
||
assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [
|
||
["主驾空调温度", "26℃"], ["副驾空调温度", "24℃"],
|
||
]);
|
||
});
|
||
|
||
for (const [name, value] of [
|
||
["absent", undefined], ["unknown schema", schedule({ schema: "other" })],
|
||
["zero repeat", schedule({ repeat_count: 0 })], ["unbounded repeat", schedule({ repeat_count: 21 })],
|
||
["fractional repeat", schedule({ repeat_count: 1.5 })], ["string repeat", schedule({ repeat_count: "2" })],
|
||
["empty phases", schedule({ phases: [] })], ["non-array phases", schedule({ phases: {} })],
|
||
["zero duration", schedule({ phases: [{ duration_ms: 0, operations: [] }] })],
|
||
["negative duration", schedule({ phases: [{ duration_ms: -1, operations: [] }] })],
|
||
["invalid duration", schedule({ phases: [{ duration_ms: Infinity, operations: [] }] })],
|
||
["missing operations", schedule({ phases: [{ duration_ms: 1000 }] })],
|
||
]) {
|
||
test(`schedule formatter safely rejects ${name}`, () => {
|
||
const { context } = harness();
|
||
assert.equal(context.modeDraftScheduleView({ schedule: value }), null);
|
||
assert.equal(context.modeScheduleView({ schedule: value }), null);
|
||
});
|
||
}
|
||
|
||
test("hover formatter prefers stage operations over an overlapping flat projection", () => {
|
||
const { context } = harness();
|
||
const mode = { schedule: schedule(), operations: [operation("climate.set_temperature", { temperature_c: 24 })] };
|
||
const rows = context.modeExecutionActionViews(mode);
|
||
assert.equal(rows.length, 6);
|
||
assert.equal(rows[0].phase, "阶段 1:快速预热");
|
||
assert.equal(rows[3].phase, "阶段 2:舒适保持");
|
||
assert.equal(rows[0].parameters, "目标温度:28 ℃");
|
||
assert.equal(rows[3].parameters, "目标温度:24 ℃");
|
||
assert.equal(rows[1].target, "主驾");
|
||
assert.equal(rows[2].target, "副驾");
|
||
assert.equal(context.modeScheduleView(mode).summary, context.modeDraftScheduleView(mode).summary);
|
||
assert.equal(context.isDynamicModeProfile({ ...mode, behavior: "static" }), true);
|
||
});
|
||
|
||
test("legacy static modes and seat ModeTimeline retain their original action semantics", () => {
|
||
const { context } = harness();
|
||
const operations = [operation("climate.set_temperature", { temperature_c: 24 })];
|
||
assert.equal(context.modeExecutionActionViews({ operations }).length, 1);
|
||
assert.equal(context.isDynamicModeProfile({ operations, behavior: "static" }), false);
|
||
const mode = { operations, timeline: {
|
||
entry: { targets: { backrest_recline: { position_pct: 40 } }, timeout_ms: 15000 },
|
||
phases: [{ name: "抬起", targets: { backrest_recline: { position_pct: 50 } }, duration_ms: 2000 }],
|
||
} };
|
||
const views = context.modeExecutionActionViews(mode);
|
||
assert.equal(views.length, 3);
|
||
assert.equal(views[1].action, "进入动态模式初始位置");
|
||
assert.match(views[1].parameters, /保护上限 15s/);
|
||
assert.equal(views[2].action, "循环阶段 1:抬起");
|
||
assert.match(views[2].parameters, /持续 2s/);
|
||
assert.equal(context.isDynamicModeProfile(mode), true);
|
||
});
|
||
|
||
test("assistant renders a stage card, preserving opaque save/trial request context", () => {
|
||
const { context, body, calls } = harness();
|
||
const message = { body: new Element("div"), article: new Element("article") };
|
||
body.append(message.body);
|
||
const draft = { name: "上车预热模式", source_request_id: "opaque-preview-id", schedule: schedule(), operation_count: 6 };
|
||
const flatActions = [];
|
||
context.appendModeDraftState(message, draft, flatActions);
|
||
const card = message.body.children[0];
|
||
assert.equal(card.querySelectorAll(".assistant-mode-schedule-phase").length, 2);
|
||
assert.equal(card.querySelectorAll(".assistant-mode-draft-row").length, 6);
|
||
assert.equal(card.querySelector(".assistant-mode-draft-badge").textContent, "6 项动作");
|
||
assert.match(textOf(card), /28℃/);
|
||
assert.match(textOf(card), /24℃/);
|
||
assert.match(textOf(card), /重复 2 次 · 总时长 3分/);
|
||
assert.match(textOf(card), /停止动态模式/);
|
||
card.querySelector(".assistant-mode-draft-save").listeners.click();
|
||
card.querySelector(".assistant-mode-draft-trial").listeners.click();
|
||
assert.equal(calls[0].kind, "save");
|
||
assert.equal(calls[0].args[2], draft);
|
||
assert.equal(calls[1].kind, "trial");
|
||
assert.equal(calls[1].args[1], draft);
|
||
assert.equal(calls[1].args[2], flatActions);
|
||
});
|
||
|
||
test("management schedule details are independently expandable without executing the card", () => {
|
||
const { context } = harness();
|
||
const card = context.createModeGalleryCard({ id: "custom-warm", name: "上车预热模式", origin: "user", schedule: schedule() });
|
||
const details = card.querySelector(".mode-gallery-schedule-details");
|
||
assert.ok(details);
|
||
assert.equal(details.querySelector("summary").textContent, "查看分阶段执行动作");
|
||
assert.equal(details.querySelectorAll("li").length, 2);
|
||
assert.equal(details.querySelectorAll("button").length, 0);
|
||
assert.match(textOf(details), /快速预热/);
|
||
assert.match(textOf(details), /舒适保持/);
|
||
assert.match(textOf(details), /主驾 · 设置主驾座椅按摩:动作:切换按摩模式,工作模式:放松/);
|
||
assert.match(textOf(card.querySelector(".mode-gallery-card-meta")), /重复 2 次 · 总时长 3分/);
|
||
});
|
||
|
||
test("popover presents stage timings and stop guidance without interpreting text as HTML", () => {
|
||
const { context } = harness();
|
||
const popover = new Element("aside");
|
||
for (const [tag, key] of [["strong", "modeActionTitle"], ["b", "modeActionCount"], ["ol", "modeActionList"]]) {
|
||
const item = new Element(tag);
|
||
item.dataset[key] = "true";
|
||
popover.append(item);
|
||
}
|
||
context.ensureModeActionPopover = () => popover;
|
||
const value = schedule();
|
||
value.phases[0].name = '<img src=x onerror="alert(1)">';
|
||
context.renderModeActionPopover({ name: "测试动态模式", schedule: value });
|
||
assert.equal(popover.querySelectorAll(".mode-chip-action-copy").length, 6);
|
||
assert.equal(popover.querySelectorAll(".mode-schedule-popover-phase").length, 6);
|
||
assert.match(textOf(popover), /阶段 1:<img/);
|
||
assert.match(textOf(popover), /停止动态模式/);
|
||
assert.match(textOf(popover), /\+1分 开始 · 持续 30秒/);
|
||
assert.equal(popover.querySelectorAll("img").length, 0);
|
||
});
|
||
|
||
test("execution distinguishes accepted schedule, virtual trial and stopped device state", () => {
|
||
const { context } = harness();
|
||
const running = context.modeScheduleExecutionView({ status: "running", schedule_execution: { status: "running" } });
|
||
assert.equal(running.state, "processing");
|
||
assert.equal(running.title, "动态时间轴已启动");
|
||
assert.doesNotMatch(running.title, /完成/);
|
||
const trial = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 90000 } });
|
||
assert.equal(trial.title, "动态时间轴模拟试运行通过");
|
||
assert.match(trial.detail, /未向车辆下发控制/);
|
||
const stop = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "cancelled" } });
|
||
assert.equal(stop.title, "动态时间轴已停止");
|
||
assert.match(stop.detail, /不会自动回滚/);
|
||
assert.equal(context.modeScheduleExecutionView({ status: "failed", schedule_execution: { status: "failed" } }), null);
|
||
});
|
||
|
||
test("virtual trial verifies temporal structure, not just a flattened action set", () => {
|
||
const { context } = harness();
|
||
const draft = { schedule: schedule() };
|
||
const response = () => ({ schedule: schedule(), schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 180000 } });
|
||
assert.equal(context.modeScheduleTrialMatches(draft, response()), true);
|
||
assert.equal(context.modeScheduleTrialMatches({}, {}), true, "legacy static drafts remain unchanged");
|
||
for (const mutate of [
|
||
(value) => { value.schedule.repeat_count = 1; },
|
||
(value) => { value.schedule.phases.reverse(); },
|
||
(value) => { value.schedule.phases[0].duration_ms = 45000; value.schedule.phases[1].duration_ms = 45000; },
|
||
(value) => { value.schedule.phases[0].operations[0].parameters.temperature_c = 26; },
|
||
(value) => { value.schedule.phases[0].operations[1].target_scope = "passenger"; },
|
||
(value) => { value.schedule_execution.virtual_elapsed_ms = 90000; },
|
||
(value) => { value.schedule_execution.completed = false; },
|
||
(value) => { value.schedule_execution.status = "running"; },
|
||
(value) => { delete value.schedule; },
|
||
]) {
|
||
const value = response();
|
||
mutate(value);
|
||
assert.equal(context.modeScheduleTrialMatches(draft, value), false);
|
||
}
|
||
});
|
||
|
||
function pollHarness(statuses = []) {
|
||
const { context } = harness();
|
||
const timers = new Map();
|
||
const requests = [];
|
||
const updates = [];
|
||
const modeUpdates = [];
|
||
const vehicle = { value: "denza_n7" };
|
||
let timerId = 0;
|
||
context.activeSchedulePoll = null;
|
||
context.AbortController = AbortController;
|
||
context.conversation = "conversation-1";
|
||
context.conversationId = () => context.conversation;
|
||
context.setActionStatus = (_message, copy, state) => updates.push({ copy, state });
|
||
const originalQuery = context.$;
|
||
context.$ = (selector, parent) => selector === "#vehicleSelect" ? vehicle : originalQuery(selector, parent);
|
||
context.window.setTimeout = (callback, delay) => {
|
||
assert.equal(delay, 2000);
|
||
timers.set(++timerId, callback);
|
||
return timerId;
|
||
};
|
||
context.window.clearTimeout = (id) => timers.delete(id);
|
||
context.window.CognitiveAgentConsole = { updateScheduleExecutionStatus: (...args) => modeUpdates.push(args) };
|
||
context.window.CognitiveAgentNetwork = { request: async (url, options) => {
|
||
requests.push({ url, options });
|
||
const next = statuses.shift();
|
||
if (next instanceof Error) throw next;
|
||
if (typeof next === "function") return next();
|
||
return { ok: true, status: 200, json: async () => next };
|
||
} };
|
||
vm.runInContext(["stopScheduleStatusPoll", "startScheduleStatusPoll"].map((name) => functionSource(assistant, name)).join("\n"), context);
|
||
const start = (session = "session-1") => context.startScheduleStatusPoll({
|
||
vehicle_id: "denza_n7", mode_profile: { id: "test-mode" }, schedule_execution: { session_id: session },
|
||
}, {});
|
||
const tick = async () => {
|
||
const [id, callback] = timers.entries().next().value || [];
|
||
assert.ok(callback, "poll timer must be scheduled");
|
||
timers.delete(id);
|
||
await callback();
|
||
};
|
||
return { context, timers, requests, updates, modeUpdates, vehicle, start, tick };
|
||
}
|
||
|
||
test("polls only the active vehicle using GET and updates the original status until completion", async () => {
|
||
const h = pollHarness([
|
||
{ status: "running", session_id: "session-1", vehicle_id: "denza_n7", phase_index: 1, repeat_index: 0 },
|
||
{ status: "completed", session_id: "session-1", completed: true },
|
||
]);
|
||
h.start();
|
||
assert.equal(h.requests.length, 0);
|
||
await h.tick();
|
||
assert.equal(h.requests.length, 1);
|
||
assert.equal(h.requests[0].url, "/api/modes/schedule/status?vehicle_id=denza_n7");
|
||
assert.equal(h.requests[0].options.method, "GET");
|
||
assert.equal(h.requests[0].options.body, undefined);
|
||
assert.match(h.updates[0].copy, /第 1 轮 · 阶段 2/);
|
||
assert.equal(h.updates[0].state, "working");
|
||
await h.tick();
|
||
assert.equal(h.timers.size, 0);
|
||
assert.equal(h.context.activeSchedulePoll, null);
|
||
assert.match(h.updates[1].copy, /时间轴已结束/);
|
||
assert.deepEqual(h.modeUpdates, [["test-mode", "completed"]]);
|
||
});
|
||
|
||
for (const status of ["failed", "cancelled", "stopped", "idle"]) {
|
||
test(`terminal ${status} ends polling without a new request or action`, async () => {
|
||
const h = pollHarness([{ status, session_id: "session-1", error: "阶段下发失败" }]);
|
||
h.start();
|
||
await h.tick();
|
||
assert.equal(h.requests.length, 1);
|
||
assert.equal(h.timers.size, 0);
|
||
assert.equal(h.updates[0].state, status === "failed" ? "failed" : "success");
|
||
if (status === "failed") assert.match(h.updates[0].copy, /阶段下发失败/);
|
||
if (["cancelled", "stopped"].includes(status)) assert.match(h.updates[0].copy, /已停止/);
|
||
});
|
||
}
|
||
|
||
test("a transient status failure is visible and retried, not reported as execution failure", async () => {
|
||
const h = pollHarness([new Error("offline"), { status: "completed", completed: true }]);
|
||
h.start();
|
||
await h.tick();
|
||
assert.match(h.updates[0].copy, /暂时无法获取时间轴状态/);
|
||
assert.equal(h.updates[0].state, "working");
|
||
assert.equal(h.timers.size, 1);
|
||
await h.tick();
|
||
assert.equal(h.timers.size, 0);
|
||
});
|
||
|
||
test("vehicle or conversation changes terminate observation before another request", async () => {
|
||
for (const kind of ["vehicle", "conversation"]) {
|
||
const h = pollHarness();
|
||
h.start();
|
||
if (kind === "vehicle") h.vehicle.value = "another-vehicle";
|
||
else h.context.conversation = "conversation-2";
|
||
await h.tick();
|
||
assert.equal(h.requests.length, 0);
|
||
assert.equal(h.context.activeSchedulePoll, null);
|
||
assert.equal(h.timers.size, 0);
|
||
}
|
||
});
|
||
|
||
test("replacement session does not apply a different schedule outcome to the old message", async () => {
|
||
const h = pollHarness([{ status: "failed", session_id: "another-session" }]);
|
||
h.start();
|
||
await h.tick();
|
||
assert.match(h.updates[0].copy, /已被新的运行任务替换/);
|
||
assert.doesNotMatch(h.updates[0].copy, /执行失败/);
|
||
assert.equal(h.timers.size, 0);
|
||
assert.equal(h.modeUpdates.length, 0);
|
||
});
|
||
|
||
test("stop aborts an in-flight status read and ignores its late completion", async () => {
|
||
let resolve;
|
||
const response = new Promise((done) => { resolve = done; });
|
||
const h = pollHarness([() => response]);
|
||
h.start();
|
||
const pending = h.tick();
|
||
assert.equal(h.requests.length, 1);
|
||
assert.equal(h.timers.size, 0, "an in-flight read cannot overlap another timer");
|
||
h.context.stopScheduleStatusPoll();
|
||
assert.equal(h.requests[0].options.signal.aborted, true);
|
||
resolve({ ok: true, status: 200, json: async () => ({ status: "completed", completed: true }) });
|
||
await pending;
|
||
assert.equal(h.updates.length, 0);
|
||
assert.equal(h.timers.size, 0);
|
||
});
|
||
|
||
test("cleanup is wired to new conversation, vehicle changes and page exit", () => {
|
||
assert.match(assistant, /if \(!target\?\.closest\("#newConversation"\)\) return;\s+stopScheduleStatusPoll\(\);/);
|
||
assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/);
|
||
assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/);
|
||
});
|