#!/usr/bin/env node "use strict"; const assert = require("assert"); const { buildTrafficSignalFeatures, buildTrafficSignalsFromFeatures, validateTrafficSignalFeatures, validateTrafficSignalSourceReferences, } = require("./lib/traffic-signals"); function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) { return { type: "Feature", geometry: { type: "Polygon", coordinates: [[ [lon - dx, lat - dy], [lon + dx, lat - dy], [lon + dx, lat + dy], [lon - dx, lat + dy], [lon - dx, lat - dy], ]] }, properties: {} }; } const intersections = { type: "FeatureCollection", features: [rectangle(120.0001, 30.00005, 0.0003, 0.00025)] }; const stops = { type: "FeatureCollection", features: [ rectangle(119.99995, 30.00005), rectangle(120.00010, 30.00025), rectangle(120.00035, 30.00005), rectangle(120.00010, 29.99985), ] }; const arms = [ { headingDegrees: 270, wayId: "west", neighborNodeId: "w1" }, { headingDegrees: 0, wayId: "north", neighborNodeId: "n1" }, { headingDegrees: 90, wayId: "east", neighborNodeId: "e1" }, { headingDegrees: 180, wayId: "south", neighborNodeId: "s1" }, ]; const control = { id: "control-1", longitude: 120.0001, latitude: 30.00005, arms }; const cross = buildTrafficSignalFeatures(stops, intersections, [control]); assert.equal(cross.features.length, 4); assert.equal(new Set(cross.features.map((feature) => feature.properties.signal_uid)).size, 4); const t = buildTrafficSignalFeatures(stops, intersections, [{ ...control, arms: arms.slice(0, 3) }]); assert.equal(t.features.length, 3); assert.deepEqual( buildTrafficSignalFeatures(stops, intersections, [control]).features.map((feature) => feature.properties.signal_uid), cross.features.map((feature) => feature.properties.signal_uid), "technical ids are deterministic", ); const edited = structuredClone(cross); const first = edited.features[0]; const originalStop = [first.properties.stop_lon, first.properties.stop_lat]; first.geometry.coordinates[0] += 0.0001; first.properties.display_id = "A-01"; first.properties.heading_deg = 42; first.properties.z_offset_m = 1.25; const runtime = buildTrafficSignalsFromFeatures(edited); assert.equal(new Set(runtime.signals.map((signal) => signal.nodeKey)).size, runtime.signals.length); for (const signal of runtime.signals) { assert.match(signal.nodeKey, /^ts_[0-9a-f]{16}$/); assert.ok( `TrafficSignalDynamic_${signal.nodeKey}_countdown_19`.length <= 63, "dynamic node names must stay below Blender's name limit", ); } const changed = runtime.signals.find((signal) => signal.id === first.properties.signal_uid); assert.equal(changed.displayId, "A-01"); assert.equal(changed.longitude, first.geometry.coordinates[0]); assert.equal(changed.headingDegrees, 42); assert.deepEqual([changed.stopLongitude, changed.stopLatitude], originalStop, "moving a pole preserves the stop point"); assert.equal(changed.pose.pole.height, 1.25); assert.equal(changed.pose.arm.from.height, 7.5); edited.features[1].properties.enabled = "0"; assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted"); const duplicateUid = structuredClone(cross); duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.signal_uid; assert.throws(() => validateTrafficSignalFeatures(duplicateUid), /Duplicate signal_uid/); const duplicateDisplay = structuredClone(cross); duplicateDisplay.features[1].properties.display_id = duplicateDisplay.features[0].properties.display_id; assert.throws(() => validateTrafficSignalFeatures(duplicateDisplay), /Duplicate display_id/); const invalid = structuredClone(cross); invalid.features[0].properties.mast_reach_m = -1; assert.throws(() => validateTrafficSignalFeatures(invalid), /invalid mast_reach_m/); const invalidGeometry = structuredClone(cross); invalidGeometry.features[0].geometry = { type: "LineString", coordinates: [[120, 30], [121, 31]] }; assert.throws(() => validateTrafficSignalFeatures(invalidGeometry), /geometry must be a finite Point/); const mismatchedIdentity = structuredClone(cross); mismatchedIdentity.features[0].properties.approach_id = "other-way:w1"; assert.throws(() => validateTrafficSignalFeatures(mismatchedIdentity), /approach_id does not match source_way_id/); const invalidEnabled = structuredClone(cross); invalidEnabled.features[0].properties.enabled = "maybe"; assert.throws(() => validateTrafficSignalFeatures(invalidEnabled), /invalid enabled/); assert.doesNotThrow(() => validateTrafficSignalSourceReferences(cross, [control])); assert.throws( () => validateTrafficSignalSourceReferences(cross, [{ ...control, arms: arms.slice(1) }]), /approach_id .* is not present on OSM control/, ); assert.throws( () => validateTrafficSignalSourceReferences(cross, []), /control_id .* is not present in the current OSM/, ); for (const disabledValue of [false, 0, "0", "false", "no"]) { const disabled = structuredClone(cross); disabled.features[0].properties.enabled = disabledValue; assert.equal(buildTrafficSignalsFromFeatures(disabled).signals.length, 3); } for (const key of ["heading_deg", "phase_group", "stop_lon", "stop_lat", "z_offset_m"]) { const missingNumber = structuredClone(cross); missingNumber.features[0].properties[key] = null; assert.throws( () => validateTrafficSignalFeatures(missingNumber), new RegExp(`missing ${key}`), `${key} must not silently coerce null to zero`, ); } console.log("Traffic signal tests passed.");