Files
osmWorkflow/scripts/test-v2x-cesium-overlay.js

288 lines
16 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 { gaodeReference: { gcj02ToWgs84: referenceGcj02ToWgs84 } } = require("@osm-asset/road-compiler");
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,
Blob,
TextDecoder,
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('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221}]}}'), {
data: { 1519: [{ id: "53122", longitude: 114.12871535072304, latitude: 30.46061866463221 }] },
}, "targetPosition uses unquoted numeric device-id keys");
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,
}]);
assert.equal(normalizeTargetPush(JSON.stringify({ data: { 1519: [{ id: "50089", longitude: 114.12876415699589, latitude: 30.46060781353787, type: 1, subType: 1 }] }, interval: 1000 })).vehicles.length, 1,
"the live targetPosition payload uses numeric object keys and string target ids");
assert.equal(normalizeTargetPush('{data:{1519:[{id:"53122",longitude:114.12871535072304,latitude:30.46061866463221,type:1,subType:1}]},interval:1000}').vehicles.length, 1,
"unquoted numeric device-id keys are accepted from the live targetPosition stream");
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" }), "11.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.visibleSize, 1);
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.visibleSize, 0);
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.");