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); });