Files
ParkingRobot/ClumsyPilot/tests/TrajectoryPlanningVisualizationWebDom/dashboard.dom.test.mjs
T

334 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { JSDOM } from "jsdom";
const webDir = new URL("../../TrajectoryPlanningVisualization/Web/", import.meta.url);
const readWebFile = (name) => readFileSync(new URL(name, webDir), "utf8");
function makeStaticSnapshot({ empty = false } = {}) {
return {
sessionNameChinese: "合成轨迹规划观察",
worldBounds: { xMin: 0, xMax: 12, yMin: 0, yMax: 8 },
occupancyGrid: null,
staticPolylines: empty
? []
: [
{
id: "global",
legendChinese: "全局参考路径",
kind: "global",
lineStyle: 0,
points: [
{ x: 0, y: 1 },
{ x: 3, y: 1 },
{ x: 6, y: 2 },
{ x: 8, y: 4 },
{ x: 11, y: 6 }
]
},
{
id: "local-g2",
legendChinese: "完整 Local G2 路径",
kind: "local-g2",
lineStyle: 0,
points: [
{ x: 0.2, y: 1 },
{ x: 3.2, y: 1.1 },
{ x: 6.1, y: 2.1 },
{ x: 8.1, y: 4.1 },
{ x: 10.9, y: 5.9 }
]
}
],
staticMarkers: empty
? []
: [
{ id: "gear-1", kind: "gear-switch", labelChinese: "换向 1", position: { x: 6, y: 2 } },
{ id: "gear-1-end", kind: "gear-switch-end", labelChinese: "换向点 1 / s_end", position: { x: 6, y: 2 } },
{ id: "final-goal", kind: "final-goal", labelChinese: "终点 / s_end", position: { x: 11, y: 6 } }
],
directionSegments: empty
? []
: [
{
segmentIndex: 0,
direction: "forward",
startsAtGearSwitch: false,
endsAtGearSwitch: true,
points: [
{ x: 0, y: 1 },
{ x: 3, y: 1 },
{ x: 6, y: 2 }
]
},
{
segmentIndex: 1,
direction: "reverse",
startsAtGearSwitch: true,
endsAtGearSwitch: false,
points: [
{ x: 6, y: 2 },
{ x: 8, y: 4 },
{ x: 11, y: 6 }
]
}
],
configurationGroups: []
};
}
function chart(id, title, xAxis, yAxis, points, annotations = []) {
return {
id,
chineseTitle: title,
xAxisLabel: xAxis,
yAxisLabel: yAxis,
noteChinese: "",
series: [
{
id: id + "-current",
legend: "当前轨迹",
lineStyle: 0,
points: points.map(([x, y]) => ({ x, y }))
}
],
annotations
};
}
function makeDynamicSnapshot({ empty = false } = {}) {
if (empty) {
return {
sequence: 1,
observedAtUtc: "2026-08-06T00:00:00Z",
sessionStateChinese: "运行中",
activeSegmentIndex: 0,
activeDirection: "forward",
vehiclePose: null,
dynamicPolylines: [],
dynamicMarkers: [],
charts: [],
statusValues: [],
cycleSummary: {
cycleVersion: 1,
occurredAtUtc: "2026-08-06T00:00:00Z",
status: "成功",
published: true,
planningElapsedMilliseconds: 4.2,
segmentIndex: 0,
direction: "forward",
longitudinalMode: "full",
terminalType: "goal",
terminalVelocity: 0,
terminalAcceleration: 0,
failureReason: ""
}
};
}
return {
sequence: 1,
observedAtUtc: "2026-08-06T00:00:00Z",
sessionStateChinese: "合成数据运行中",
activeSegmentIndex: 0,
activeDirection: "forward",
vehiclePose: { x: 2, y: 1, headingRadians: 0 },
dynamicPolylines: [
{
id: "previous",
legendChinese: "上一轮轨迹",
kind: "previous",
lineStyle: 1,
points: [
{ x: 1, y: 1 },
{ x: 2.5, y: 1 },
{ x: 4, y: 1.3 }
]
},
{
id: "current",
legendChinese: "当前轨迹",
kind: "current",
lineStyle: 0,
points: [
{ x: 2, y: 1 },
{ x: 3.8, y: 1.2 },
{ x: 5.8, y: 1.9 }
]
}
],
dynamicMarkers: [
{ id: "vehicle", kind: "vehicle", labelChinese: "车辆", position: { x: 2, y: 1 } },
{ id: "plan-start", kind: "plan-start", labelChinese: "计划起点", position: { x: 2, y: 1 } },
{ id: "gear-switch-end", kind: "gear-switch-end", labelChinese: "换向点 1 / s_end", position: { x: 6, y: 2 } }
],
charts: [
chart("ls", "横向偏移", "ReferenceS (m)", "l (m)", [[0, 0.05], [2, 0.08], [4, 0.02]], [
{ id: "ls-s-end", kind: "s-end", labelChinese: "s_end", x: 4 }
]),
chart("st", "时空轨迹", "t (s)", "PathS (m)", [[0, 0], [1, 1.2], [2, 3]], [
{ id: "st-s-end", kind: "s-end", labelChinese: "s_end", x: 2 }
])
],
statusValues: [],
cycleSummary: {
cycleVersion: 1,
occurredAtUtc: "2026-08-06T00:00:00Z",
status: "成功",
published: true,
planningElapsedMilliseconds: 4.2,
segmentIndex: 0,
direction: "forward",
longitudinalMode: "full",
terminalType: "goal",
terminalVelocity: 0,
terminalAcceleration: 0,
failureReason: ""
}
};
}
function fakeContext() {
return {
setTransform() {},
clearRect() {},
fillRect() {}
};
}
function loadApp({ staticSnapshot = makeStaticSnapshot(), dynamicSnapshot = makeDynamicSnapshot() } = {}) {
const dom = new JSDOM(readWebFile("index.html"), {
runScripts: "outside-only",
pretendToBeVisual: true,
url: "http://127.0.0.1/observation?token=test"
});
const { window } = dom;
window.devicePixelRatio = 1;
window.__TRAJECTORY_VISUALIZATION_TEST__ = true;
window.__fetchCalled = false;
window.__eventSourceCreated = false;
window.fetch = async () => {
window.__fetchCalled = true;
throw new Error("test mode must not fetch");
};
window.EventSource = class {
constructor() {
window.__eventSourceCreated = true;
}
close() {}
};
const style = window.document.createElement("style");
style.textContent = readWebFile("app.css");
window.document.head.append(style);
window.HTMLCanvasElement.prototype.getContext = () => fakeContext();
window.eval(readWebFile("app.js"));
if (window.__trajectoryVisualizationTestHooks) {
const hooks = window.__trajectoryVisualizationTestHooks;
hooks.setBootstrap(staticSnapshot);
hooks.receiveFrame({ snapshot: dynamicSnapshot, history: [] });
}
return dom;
}
test("tab switching hides inactive sections despite CSS grid rules", () => {
const dom = loadApp();
const { document, getComputedStyle } = dom.window;
const overview = document.querySelector("#overview");
const lsSt = document.querySelector("#ls-st");
const kinematics = document.querySelector("#kinematics");
assert.equal(overview.hidden, false);
assert.notEqual(getComputedStyle(overview).display, "none");
document.querySelector('button[data-tab="ls-st"]').click();
assert.equal(overview.hidden, true);
assert.equal(getComputedStyle(overview).display, "none");
assert.equal(lsSt.hidden, false);
assert.notEqual(getComputedStyle(lsSt).display, "none");
assert.equal(kinematics.hidden, true);
assert.equal(getComputedStyle(kinematics).display, "none");
});
test("ST chart renders y ticks, units, and s_end annotation", () => {
const dom = loadApp();
const { document } = dom.window;
document.querySelector('button[data-tab="ls-st"]').click();
const yTicks = document.querySelectorAll("#st .axis-tick-y");
const xTicks = document.querySelectorAll("#st .axis-tick-x");
const yLabel = document.querySelector("#st .axis-label-y");
const xLabel = document.querySelector("#st .axis-label-x");
assert.ok(yTicks.length >= 4, "ST renders y tick text");
assert.ok(xTicks.length >= 4, "ST renders x tick text");
assert.ok([...yTicks].every(tick => tick.textContent.length > 0), "y ticks have readable values");
assert.equal(yLabel.textContent, "PathS (m)");
assert.equal(xLabel.textContent, "t (s)");
assert.ok(document.querySelector("#st .chart-annotation-label")?.textContent.includes("s_end"));
});
test("ST PathS polyline rises and semantic world layers are ordered", () => {
const dom = loadApp();
const { document } = dom.window;
document.querySelector('button[data-tab="ls-st"]').click();
const stPath = document.querySelector("#st path.chart-data");
assert.ok(stPath, "ST chart has a polyline");
const segments = stPath.getAttribute("d").split(/(?=[ML])/).filter(Boolean);
const yValues = segments.map(segment => Number(segment.slice(1).trim().split(/[\s,]+/)[1]));
assert.ok(yValues.length >= 3, "ST polyline has at least three rendered points");
assert.ok(yValues[1] < yValues[0] && yValues[2] < yValues[1],
"ST PathS increases upward in rendered pixel coordinates");
document.querySelector('button[data-tab="overview"]').click();
const overlay = document.querySelector("#world-overlay");
const paths = [...overlay.querySelectorAll("path")];
const layerClasses = ["world-coarse", "world-local-g2", "world-segment-active", "world-previous", "world-current"];
const indices = layerClasses.map(className => paths.findIndex(path => path.classList.contains(className)));
layerClasses.forEach((className, index) => assert.ok(indices[index] !== -1, className + " exists"));
for (let index = 1; index < indices.length; index += 1) {
assert.ok(indices[index - 1] < indices[index], "semantic world layer order is preserved");
}
});
test("vehicle, plan start, gear switch, and final goal use semantic markers", () => {
const dom = loadApp();
const { document } = dom.window;
const overlay = document.querySelector("#world-overlay");
for (const className of ["marker-vehicle", "marker-plan-start", "marker-gear-switch", "marker-final-goal"]) {
assert.ok(overlay.querySelector("." + className), className + " is rendered");
}
assert.equal(overlay.querySelectorAll(".marker-vehicle circle").length, 0,
"vehicle marker is not a fixed pixel circle");
});
test("empty overview names the missing Local G2 source", () => {
const dom = loadApp({
staticSnapshot: makeStaticSnapshot({ empty: true }),
dynamicSnapshot: makeDynamicSnapshot({ empty: true })
});
const emptyState = dom.window.document.querySelector("#world-overlay .world-empty-state");
assert.ok(emptyState, "empty overview renders a visible state");
assert.match(emptyState.textContent, /未收到.*Local G2 路径/);
});
test("test mode exposes frozen hooks without fetch or SSE", () => {
const dom = loadApp();
const { window } = dom;
assert.equal(window.__fetchCalled, false);
assert.equal(window.__eventSourceCreated, false);
assert.ok(window.__trajectoryVisualizationTestHooks, "test hooks are exposed");
assert.equal(Object.isFrozen(window.__trajectoryVisualizationTestHooks), true);
for (const name of ["setBootstrap", "receiveFrame", "renderActiveTab", "installTabs"]) {
assert.equal(typeof window.__trajectoryVisualizationTestHooks[name], "function", name + " hook exists");
}
assert.equal("state" in window.__trajectoryVisualizationTestHooks, false);
});