199 lines
10 KiB
JavaScript
199 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
"use strict";
|
|
|
|
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const {
|
|
buildTrafficSignalFeatures,
|
|
buildTrafficSignalsFromFeatures,
|
|
validateTrafficSignalFeatures,
|
|
validateTrafficSignalSourceReferences,
|
|
} = require("../packages/road-compiler/src/traffic-signals");
|
|
const { SCHEMA, loadOrGenerate } = require("../packages/road-compiler/src/native-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 clusteredStops = { type: "FeatureCollection", features: [
|
|
rectangle(119.99995, 30.00005, 0.000006, 0.00003), rectangle(120.00010, 30.00025),
|
|
rectangle(120.00035, 30.00005, 0.000006, 0.00003), rectangle(120.00010, 29.99985),
|
|
].map((feature) => ({ ...feature, properties: { cluster_id: "generic-complex" } })) };
|
|
const polarityAgnosticControl = { ...control, arms: [{ ...arms[0], headingDegrees: 90 }, ...arms.slice(1)] };
|
|
const ordinaryWithReversedArm = buildTrafficSignalFeatures(stops, intersections, [polarityAgnosticControl]);
|
|
assert.ok(
|
|
!new Set(stops.features.map(stopCenterKey)).has(signalStopKey(ordinaryWithReversedArm.features.find((feature) => feature.properties.source_way_id === "east"))),
|
|
"ordinary candidates retain directed matching",
|
|
);
|
|
const clustered = buildTrafficSignalFeatures(clusteredStops, { type: "FeatureCollection", features: [] }, [polarityAgnosticControl]);
|
|
assert.equal(clustered.features.length, 4, "complex stop lines form a signal group without an ordinary intersection surface");
|
|
assert.deepEqual(
|
|
new Set(clustered.features.map(signalStopKey)),
|
|
new Set(clusteredStops.features.map(stopCenterKey)),
|
|
"complex OSM arms consume each stop-line candidate exactly once",
|
|
);
|
|
assert.ok(clustered.features.every((feature) => {
|
|
const stop = [feature.properties.stop_lon, feature.properties.stop_lat];
|
|
return metersBetweenForTest(feature.geometry.coordinates, stop) > 4.8 && metersBetweenForTest(feature.geometry.coordinates, stop) < 5.6;
|
|
}), "complex signal poles are positioned from their matched stop lines");
|
|
|
|
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.mast_heading_deg = 17;
|
|
first.properties.mast_reach_m = 8.5;
|
|
first.properties.face_heading_deg = 203;
|
|
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.equal(changed.mastHeadingDegrees, 17);
|
|
assert.equal(changed.faceHeadingDegrees, 203);
|
|
assert.equal(changed.mastReachMeters, 8.5);
|
|
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);
|
|
assert.ok(changed.pose.arm.to.latitude > changed.pose.arm.from.latitude, "mast direction controls the arm independently");
|
|
assert.equal(changed.pose.head.faceHeadingDegrees, 203, "face direction is independent from mast direction");
|
|
|
|
const legacy = structuredClone(cross);
|
|
legacy.features[0].properties.heading_deg = 42;
|
|
delete legacy.features[0].properties.mast_heading_deg;
|
|
delete legacy.features[0].properties.face_heading_deg;
|
|
const migrated = validateTrafficSignalFeatures(legacy).features[0].properties;
|
|
assert.equal(migrated.mast_heading_deg, 312, "legacy heading preserves the historic mast direction");
|
|
assert.equal(migrated.face_heading_deg, 222, "legacy heading preserves the historic face direction");
|
|
|
|
edited.features[1].properties.enabled = "0";
|
|
assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted");
|
|
|
|
function metersBetweenForTest(first, second) {
|
|
return Math.hypot((first[0] - second[0]) * 111320 * Math.cos(first[1] * Math.PI / 180), (first[1] - second[1]) * 111320);
|
|
}
|
|
|
|
function stopCenterKey(feature) {
|
|
const ring = feature.geometry.coordinates[0].slice(0, -1);
|
|
return `${ring.reduce((sum, point) => sum + point[0], 0) / ring.length},${ring.reduce((sum, point) => sum + point[1], 0) / ring.length}`;
|
|
}
|
|
|
|
function signalStopKey(feature) {
|
|
return `${feature.properties.stop_lon},${feature.properties.stop_lat}`;
|
|
}
|
|
|
|
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 ["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`,
|
|
);
|
|
}
|
|
const missingDirections = structuredClone(cross);
|
|
for (const key of ["heading_deg", "mast_heading_deg", "face_heading_deg"]) missingDirections.features[0].properties[key] = null;
|
|
assert.throws(() => validateTrafficSignalFeatures(missingDirections), /missing mast_heading_deg/);
|
|
|
|
const changedOsm = `
|
|
<osm>
|
|
<node id="control-1" lon="120.0001" lat="30.00005"><tag k="highway" v="traffic_signals" /></node>
|
|
<node id="w1" lon="119.9998" lat="30.00005" />
|
|
<node id="n1" lon="120.0001" lat="30.00035" />
|
|
<node id="e1" lon="120.0004" lat="30.00005" />
|
|
<way id="west"><nd ref="w1" /><nd ref="control-1" /><tag k="highway" v="primary" /></way>
|
|
<way id="north"><nd ref="control-1" /><nd ref="n1" /><tag k="highway" v="primary" /></way>
|
|
<way id="east"><nd ref="control-1" /><nd ref="e1" /><tag k="highway" v="primary" /></way>
|
|
</osm>`;
|
|
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "native-traffic-signals-"));
|
|
const generatedSignalPath = path.join(temporaryDirectory, "generated.json");
|
|
try {
|
|
fs.writeFileSync(generatedSignalPath, JSON.stringify({ schema: SCHEMA, provenance: "generated:osm-controls", assemblies: cross }));
|
|
const regenerated = loadOrGenerate(generatedSignalPath, changedOsm, stops, intersections);
|
|
assert.equal(regenerated.assemblies.features.length, 3, "stale OSM-generated signals must refresh after OSM approaches change");
|
|
|
|
fs.writeFileSync(generatedSignalPath, JSON.stringify({ schema: SCHEMA, provenance: "edited:workbench", assemblies: cross }));
|
|
assert.throws(
|
|
() => loadOrGenerate(generatedSignalPath, changedOsm, stops, intersections),
|
|
/approach_id .* is not present on OSM control/,
|
|
"manually maintained signals must not be silently replaced",
|
|
);
|
|
} finally {
|
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log("Traffic signal tests passed.");
|