The ported overlay used the correct REST paths but lost the data handling
from the source dashboard's live-intersection view (HologramCross), so
signals rendered permanently red and vehicles often never appeared.
- Lamp status codes now follow the dashboard dictionary (11/21/22/23/31).
The previous 2/3 reading made every real push fall through to red. The
dictionary lives only in the overlay; the preview consumes normalized
{nodeKeys, color, countDown} entries so the two copies cannot drift.
- Bind V2X phases to native signal heads geometrically. The runtime
document has no phaseNo, so the old lookup fell back to signal.id and
never matched, leaving the dynamic assembly dark. Travel heading is
recovered as faceHeadingDegrees + 180, per the generator's
mast = travel - 90 / face = travel + 180. Verified 7/7 exact matches
against the fengshu-er-road runtime document.
- A phase now lights every approach it drives; the phase -> single entity
map silently overwrote all but the last.
- Drive the countdown assets from the push's countDown field.
- All three sockets heartbeat every 30s and reconnect with backoff,
replaying their subscription frame. Without this the service dropped
the connection and the scene emptied after about a minute.
- The OBU socket sends its bounds frame on connect and on camera move;
it previously sent nothing at all.
- Vehicles are swept when a push goes stale and their slots reused, so
they no longer accumulate as ghosts. Models follow the dashboard's
car_obu.glb / ${type}${subType}.glb naming.
- Parse vehicle pushes leniently, since the dashboard uses saferEval and
the payload is not guaranteed to be strict JSON. Failures are counted
and surfaced rather than dropped; no eval is introduced.
- Drop FlowTravelRatio/queryListWeek, which is not part of this view.
Also corrects a stale spec rule that required vehicleModelNames to be
empty. Live V2X vehicles need packaged models; the real invariant is no
generated routes or traffic simulation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
15 KiB
JavaScript
277 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const vm = require("vm");
|
|
const { gcj02ToWgs84: referenceGcj02ToWgs84 } = require("./lib/gaode-junction-reference");
|
|
|
|
const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8");
|
|
const context = {
|
|
window: { location: new URL("https://preview.example.test/areas/fengshu/") },
|
|
URL,
|
|
console,
|
|
};
|
|
vm.runInNewContext(source, context, { filename: "v2x-cesium-overlay.js" });
|
|
|
|
const {
|
|
gcj02ToWgs84, wgs84ToGcj02, joinUrl, md5, toWebSocketUrl,
|
|
normalizeObuVehicle, normalizeObuPush, normalizeTargetVehicles, normalizeTargetPush,
|
|
selectCrossCode, lampColorName, parseLoosePayload, isHeartbeat, signalEntriesFrom,
|
|
getAngle, angleDeltaDegrees, haversineMeters,
|
|
buildPhaseSignalMap, signalTravelHeading,
|
|
createVehicleRegistry, modelNameFor, resolvePushInterval,
|
|
createSocket, buildBoundsMessage, HEARTBEAT_MESSAGE, HEARTBEAT_INTERVAL_MS,
|
|
} = context.window.createV2xCesiumOverlay.utils;
|
|
|
|
// --- existing contracts -----------------------------------------------------
|
|
assert.equal(md5(""), "d41d8cd98f00b204e9800998ecf8427e");
|
|
assert.equal(md5("password"), "5f4dcc3b5aa765d61d8327deb882cf99");
|
|
assert.equal(joinUrl("/api/", "/facilities/api/sys/login"), "/api/facilities/api/sys/login");
|
|
assert.equal(joinUrl("/websocket", "network/ws/network/signal"), "/websocket/network/ws/network/signal");
|
|
assert.equal(toWebSocketUrl("/websocket/network/ws/network/signal", "opaque token"), "wss://preview.example.test/websocket/network/ws/network/signal?authorization=opaque+token");
|
|
|
|
const converted = gcj02ToWgs84([114.12864875054062, 30.460485279762146]);
|
|
const reference = referenceGcj02ToWgs84([114.12864875054062, 30.460485279762146]);
|
|
assert.ok(Math.abs(converted[0] - reference[0]) < 1e-12);
|
|
assert.ok(Math.abs(converted[1] - reference[1]) < 1e-12);
|
|
assert.deepEqual(gcj02ToWgs84([Infinity, 30]), [NaN, NaN]);
|
|
|
|
// wgs84ToGcj02 is the forward transform used for the OBU bounds frame. Both
|
|
// directions are single-step approximations, so the round trip is not exact:
|
|
// it closes to about 1.5 m. That is irrelevant for a viewport filter, and
|
|
// vehicle positions never use it -- they only ever go GCJ-02 -> WGS84 once.
|
|
const roundTrip = gcj02ToWgs84(wgs84ToGcj02([114.12864875054062, 30.460485279762146]));
|
|
assert.ok(haversineMeters([114.12864875054062, 30.460485279762146], roundTrip) < 2);
|
|
assert.deepEqual(wgs84ToGcj02([NaN, 30]), [NaN, NaN]);
|
|
|
|
assert.equal(selectCrossCode({ west: [{ code: "first" }, { code: "configured" }] }, [{ crossCode: "configured" }]), "configured");
|
|
assert.equal(selectCrossCode([{ code: "first" }], []), "first");
|
|
|
|
// --- AC2: lamp status dictionary -------------------------------------------
|
|
// Codes come from HologramCross/components/utils.ts lightStatusColorDict.
|
|
assert.equal(lampColorName(11), "off");
|
|
assert.equal(lampColorName(21), "red");
|
|
assert.equal(lampColorName(22), "yellow");
|
|
assert.equal(lampColorName(23), "green");
|
|
assert.equal(lampColorName(31), "other");
|
|
assert.equal(lampColorName("23"), "green", "string codes resolve too");
|
|
// Regression guard: the previous port read 2/3 as yellow/green, which made
|
|
// every real 21/22/23 push fall through to red.
|
|
assert.notEqual(lampColorName(2), "yellow");
|
|
assert.notEqual(lampColorName(3), "green");
|
|
assert.equal(lampColorName(2), "other");
|
|
assert.equal(lampColorName(3), "other");
|
|
assert.equal(lampColorName(undefined), "other", "unknown codes never default to red");
|
|
assert.equal(lampColorName(999), "other");
|
|
|
|
// --- AC7: loose payload parsing --------------------------------------------
|
|
assert.deepEqual(parseLoosePayload('{"a":1}'), { a: 1 });
|
|
assert.deepEqual(parseLoosePayload("{a:1,b:'x',}"), { a: 1, b: "x" });
|
|
assert.deepEqual(parseLoosePayload('{"a":NaN}'), { a: null });
|
|
assert.equal(parseLoosePayload("{bad"), null);
|
|
assert.equal(parseLoosePayload(null), null);
|
|
assert.ok(isHeartbeat('{"heartBeat":"pong"}'));
|
|
assert.equal(parseLoosePayload('{"heartBeat":"pong"}'), null, "heartbeats are not payloads");
|
|
|
|
// --- geometry helpers -------------------------------------------------------
|
|
assert.ok(Math.abs(angleDeltaDegrees(350, 10) - 20) < 1e-9);
|
|
assert.ok(Math.abs(angleDeltaDegrees(10, 350) - 20) < 1e-9);
|
|
assert.ok(Math.abs(angleDeltaDegrees(0, 180) - 180) < 1e-9);
|
|
assert.ok(Math.abs(getAngle([0, 0], [1, 0]) - 90) < 1e-6, "due east is 90");
|
|
assert.ok(Math.abs(haversineMeters([114, 30], [114, 30.001]) - 111.2) < 0.5);
|
|
assert.equal(haversineMeters([114, 30], [114, 30]), 0);
|
|
|
|
// --- AC3/AC4: phase to native signal mapping --------------------------------
|
|
const nativeSignals = JSON.parse(fs.readFileSync(
|
|
path.join(__dirname, "..", "outputs", "fengshu-er-road", "package", "runtime", "traffic-signals.json"), "utf8")).signals;
|
|
assert.ok(nativeSignals.length >= 4, "fixture needs several native signals");
|
|
|
|
// The generator emits mast = travel - 90 and face = travel + 180
|
|
// (scripts/lib/traffic-signals.js), so travel is recoverable from either.
|
|
nativeSignals.forEach((signal) => {
|
|
const fromFace = signalTravelHeading(signal);
|
|
const fromMast = signalTravelHeading({ mastHeadingDegrees: signal.mastHeadingDegrees });
|
|
assert.ok(angleDeltaDegrees(fromFace, fromMast) < 1e-6, "face and mast agree on travel heading");
|
|
});
|
|
|
|
// Build a V2X link whose last segment approaches the given native signal.
|
|
function linkForSignal(signal, id, phase) {
|
|
const stop = wgs84ToGcj02([signal.stopLongitude, signal.stopLatitude]);
|
|
const heading = signalTravelHeading(signal) * Math.PI / 180;
|
|
const step = 0.0002;
|
|
const previous = [stop[0] - Math.sin(heading) * step, stop[1] - Math.cos(heading) * step];
|
|
return { id, geom: JSON.stringify({ type: "LineString", coordinates: [previous, stop] }), phaseList: [{ phase }] };
|
|
}
|
|
|
|
const oneToOne = buildPhaseSignalMap(nativeSignals.map((signal, index) => linkForSignal(signal, `L${index}`, index + 1)), nativeSignals, {});
|
|
assert.equal(oneToOne.bound, nativeSignals.length, "every phase binds");
|
|
assert.equal(oneToOne.unbound.length, 0);
|
|
oneToOne.diagnostics.forEach((entry, index) => {
|
|
assert.deepEqual(entry.matchedNodeKeys, [nativeSignals[index].nodeKey],
|
|
`link ${entry.linkId} must bind only its own approach, got ${entry.matchedNodeKeys.join()}`);
|
|
assert.ok(entry.nearestDistanceMeters < 5, "stop points are metres apart, not kilometres");
|
|
});
|
|
|
|
// AC4: one phase shared by two approaches lights both.
|
|
const shared = buildPhaseSignalMap([linkForSignal(nativeSignals[0], "A", 7), linkForSignal(nativeSignals[3], "B", 7)], nativeSignals, {});
|
|
assert.deepEqual(shared.byPhase.get("7").slice().sort(), [nativeSignals[0].nodeKey, nativeSignals[3].nodeKey].sort());
|
|
|
|
// A link nowhere near the intersection is reported, not silently dropped.
|
|
const stray = { id: "far", geom: JSON.stringify({ type: "LineString", coordinates: [[113, 29], [113.001, 29.001]] }), phaseList: [{ phase: 9 }] };
|
|
const strayResult = buildPhaseSignalMap([stray], nativeSignals, {});
|
|
assert.equal(strayResult.bound, 0);
|
|
assert.equal(strayResult.unbound.length, 1);
|
|
assert.equal(strayResult.unbound[0].phaseNo, "9");
|
|
assert.ok(strayResult.unbound[0].reason);
|
|
|
|
// Config override is the escape hatch when geometry cannot bind.
|
|
const overridden = buildPhaseSignalMap([stray], nativeSignals, { phaseSignalMap: { 9: ["ts_forced"] } });
|
|
assert.deepEqual(overridden.byPhase.get("9"), ["ts_forced"]);
|
|
assert.equal(overridden.unbound.length, 0);
|
|
|
|
// Unusable geometry is reported rather than throwing.
|
|
const brokenGeom = buildPhaseSignalMap([{ id: "x", geom: "{not json", phaseList: [{ phase: 5 }] }], nativeSignals, {});
|
|
assert.equal(brokenGeom.unbound[0].reason, "link geometry unusable");
|
|
|
|
// --- signal entries handed to the preview -----------------------------------
|
|
const entries = signalEntriesFrom(
|
|
[{ phaseNo: 1, status: 23, countDown: 12 }, { phaseNo: 2, status: 21, countDown: 4 }],
|
|
new Map([["1", ["nodeA", "nodeB"]], ["2", ["nodeC"]]]),
|
|
);
|
|
assert.deepEqual(entries, [
|
|
{ phaseNo: "1", nodeKeys: ["nodeA", "nodeB"], color: "green", countDown: 12 },
|
|
{ phaseNo: "2", nodeKeys: ["nodeC"], color: "red", countDown: 4 },
|
|
]);
|
|
assert.deepEqual(signalEntriesFrom([{ phaseNo: 8, status: 22 }], new Map()),
|
|
[{ phaseNo: "8", nodeKeys: [], color: "yellow", countDown: NaN }],
|
|
"an unbound phase still reports its colour");
|
|
|
|
// --- vehicle normalization --------------------------------------------------
|
|
assert.deepEqual(normalizeObuVehicle(JSON.stringify({ carCode: "car-7", plateNumber: "A12345", lon: 114.12865, lat: 30.46049, angle: 90, speed: 12 })), {
|
|
id: "obu-car-7", label: "A12345", kind: "obu", longitude: 114.12865, latitude: 30.46049, angle: 90, speed: 12,
|
|
});
|
|
assert.equal(normalizeObuVehicle("{bad json"), null);
|
|
const obuPush = normalizeObuPush(JSON.stringify({ carCode: "c1", lon: 114.1, lat: 30.4, interval: 800 }));
|
|
assert.equal(obuPush.interval, 800);
|
|
assert.equal(obuPush.vehicle.id, "obu-c1");
|
|
|
|
// type/subType survive normalization because the model name is built from them.
|
|
assert.deepEqual(normalizeTargetVehicles(JSON.stringify({ data: { 8: [{ id: 9, longitude: 114.12866, latitude: 30.4605, type: 1, subType: 2, angle: 180, speed: 5 }] } })), [{
|
|
id: "8-9-12", label: "8-9", kind: "target", longitude: 114.12866, latitude: 30.4605, angle: 180, speed: 5, type: 1, subType: 2,
|
|
}]);
|
|
const targetPush = normalizeTargetPush(JSON.stringify({ interval: 0, data: { 8: [{ id: 9, longitude: 114.1, latitude: 30.4, type: 1 }] } }));
|
|
assert.equal(targetPush.vehicles[0].subType, 1, "type 1 without subType defaults to subType 1");
|
|
assert.equal(resolvePushInterval(targetPush.interval), 500, "interval 0 falls back to 500");
|
|
assert.equal(resolvePushInterval(undefined), 500);
|
|
assert.equal(resolvePushInterval(900), 900);
|
|
|
|
// --- AC8: model naming matches the dashboard --------------------------------
|
|
assert.equal(modelNameFor({ kind: "obu" }), "car_obu.glb");
|
|
assert.equal(modelNameFor({ kind: "target", type: 1, subType: 2 }), "12.glb");
|
|
assert.equal(modelNameFor({ kind: "target" }), "11.glb");
|
|
|
|
// --- AC5: vehicle lifecycle -------------------------------------------------
|
|
let clock = 1000;
|
|
const registry = createVehicleRegistry({ now: () => clock });
|
|
registry.ingest([{ id: "v1", kind: "target", type: 1, subType: 1 }], 1000);
|
|
assert.equal(registry.list().length, 1);
|
|
assert.equal(registry.list()[0].visible, true);
|
|
assert.equal(registry.list()[0].duration, 1000);
|
|
assert.deepEqual(registry.sweep(clock + 1499), [], "still fresh below interval * 1.5");
|
|
assert.deepEqual(registry.sweep(clock + 1500), ["v1"], "hidden at interval * 1.5");
|
|
assert.equal(registry.list()[0].visible, false);
|
|
assert.equal(registry.list().length, 1, "hidden vehicles are kept as reusable slots, not removed");
|
|
|
|
clock = 5000;
|
|
registry.ingest([{ id: "v2", kind: "target", type: 1, subType: 1 }], 1000);
|
|
assert.equal(registry.list().length, 1, "a hidden slot of the same model is reused");
|
|
assert.equal(registry.list()[0].id, "v2");
|
|
assert.equal(registry.list()[0].visible, true);
|
|
|
|
registry.ingest([{ id: "v3", kind: "obu" }], 1000);
|
|
assert.equal(registry.list().length, 2, "a different model never steals another model's slot");
|
|
registry.ingest([{ id: "v2", kind: "target", type: 1, subType: 1 }], 1000);
|
|
assert.equal(registry.list().length, 2, "re-ingesting a known id updates in place");
|
|
registry.clear();
|
|
assert.equal(registry.list().length, 0);
|
|
|
|
// --- AC1/AC6: socket heartbeat, subscription frames, reconnect --------------
|
|
assert.equal(HEARTBEAT_MESSAGE, '{"heartBeat":"ping"}');
|
|
assert.equal(HEARTBEAT_INTERVAL_MS, 30000);
|
|
|
|
function fakeClock() {
|
|
const intervals = [];
|
|
const timeouts = [];
|
|
return {
|
|
intervals, timeouts,
|
|
setIntervalFn: (fn, ms) => intervals.push({ fn, ms }) - 1,
|
|
clearIntervalFn: (handle) => { intervals[handle] = null; },
|
|
setTimeoutFn: (fn, ms) => timeouts.push({ fn, ms }) - 1,
|
|
clearTimeoutFn: (handle) => { timeouts[handle] = null; },
|
|
pendingIntervals: () => intervals.filter(Boolean),
|
|
pendingTimeouts: () => timeouts.filter(Boolean),
|
|
};
|
|
}
|
|
|
|
const clockStub = fakeClock();
|
|
const sent = [];
|
|
let built = 0;
|
|
let live = null;
|
|
const socket = createSocket({
|
|
url: () => "wss://preview.example.test/websocket/network/ws/network/signal",
|
|
setIntervalFn: clockStub.setIntervalFn,
|
|
clearIntervalFn: clockStub.clearIntervalFn,
|
|
setTimeoutFn: clockStub.setTimeoutFn,
|
|
clearTimeoutFn: clockStub.clearTimeoutFn,
|
|
socketFactory: () => { built += 1; live = { readyState: 1, send: (data) => sent.push(data), close() {} }; return live; },
|
|
onOpen: ({ send }) => send(JSON.stringify({ junctionId: "420100023333" })),
|
|
});
|
|
socket.open();
|
|
live.onopen();
|
|
assert.deepEqual(sent, ['{"junctionId":"420100023333"}'], "subscription frame is sent on connect");
|
|
assert.equal(clockStub.pendingIntervals()[0].ms, 30000, "heartbeat runs at the dashboard's 30s");
|
|
clockStub.pendingIntervals()[0].fn();
|
|
clockStub.pendingIntervals()[0].fn();
|
|
assert.deepEqual(sent.slice(1), [HEARTBEAT_MESSAGE, HEARTBEAT_MESSAGE]);
|
|
|
|
live.onclose({});
|
|
assert.equal(clockStub.pendingTimeouts().length, 1, "a drop schedules a reconnect");
|
|
assert.equal(clockStub.pendingTimeouts()[0].ms, 1000, "first backoff is 1s");
|
|
sent.length = 0;
|
|
clockStub.pendingTimeouts()[0].fn();
|
|
live.onopen();
|
|
assert.equal(built, 2, "the socket reconnected");
|
|
assert.deepEqual(sent, ['{"junctionId":"420100023333"}'], "the subscription is replayed after reconnect");
|
|
|
|
socket.dispose();
|
|
const timeoutsBeforeClose = clockStub.pendingTimeouts().length;
|
|
live.onclose({});
|
|
assert.equal(clockStub.pendingTimeouts().length, timeoutsBeforeClose, "a disposed socket never reconnects");
|
|
|
|
// Target subscription frames match useTargetCars.ts.
|
|
function subscriptionFrame(onOpen) {
|
|
const frames = [];
|
|
onOpen({ send: (data) => frames.push(data) });
|
|
return frames;
|
|
}
|
|
assert.deepEqual(subscriptionFrame(({ send }) => send(JSON.stringify({ deviceId: null }))), ['{"deviceId":null}']);
|
|
assert.deepEqual(subscriptionFrame(({ send }) => send(JSON.stringify({ deviceId: [1, 2].join(",") }))), ['{"deviceId":"1,2"}']);
|
|
|
|
// --- OBU bounds frame -------------------------------------------------------
|
|
const bounds = buildBoundsMessage({ west: 114.12, south: 30.46, east: 114.14, north: 30.47 });
|
|
const corners = bounds.split(";");
|
|
assert.equal(corners.length, 4, "NW, NE, SE, SW");
|
|
corners.forEach((corner) => assert.equal(corner.split(",").length, 2));
|
|
// Corners are converted to GCJ-02 because the service filters in that frame.
|
|
assert.notEqual(Number(corners[0].split(",")[0]), 114.12);
|
|
assert.equal(buildBoundsMessage(null), "");
|
|
assert.equal(buildBoundsMessage({ west: NaN, south: 30.46, east: 114.14, north: 30.47 }), "");
|
|
|
|
// --- the overlay stays opt-in ----------------------------------------------
|
|
assert.equal(context.window.createV2xCesiumOverlay({ config: {} }), null, "disabled by default");
|
|
assert.equal(context.window.createV2xCesiumOverlay({ config: { v2xPreview: { enabled: false } } }), null);
|
|
|
|
console.log("V2X Cesium overlay tests passed.");
|