288 lines
16 KiB
JavaScript
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 { 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,
|
|
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.");
|