#!/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.");