fix: repair EM observation web charts
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user