fix(cctv): leave conflicting direction fields unresolved

Rebased onto current main with review follow-ups folded in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Rudy CelekliandClaude Sonnet 5.5 committed 2026-10-14 20:14:54 -04:00
1 parent 0265ad4c19
commit 0555d99ea4
4 files changed
+65 -16

No files matched your search

+2
View File
@@ -33,6 +33,8 @@
- Director timeline seeks cross the dateline along the short longitude arc.
- Conflicting CCTV direction tokens remain unknown within the highest matching tier, preserving travel-token precedence over street quadrants and bare words.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
+5
View File
@@ -1172,6 +1172,11 @@ a browser key. Preview is for local build verification, not a production server.
## CCTV and radio provider modules
CCTV direction text resolves travel tokens first, then intercardinals, then
bare cardinals when enabled. Only conflicting headings within the highest
matching tier remain unknown; street quadrants and lower-tier words do not
cancel an explicit travel heading.
CCTV catalog acquisition, source normalization and frame/media delivery now live
in separate modules. Each CCTV factory owns its catalog and health state; its
`sourceRoot` option resolves relative source files against the application root.
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { directionToHeading } from './directionText.js';
test('conflicting facing directions cannot manufacture one heading', () => {
for (const bare of [false, true]) {
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
assert.equal(directionToHeading('NB northbound', bare), 0);
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
}
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
assert.equal(directionToHeading('West / SR-24', true), 270);
});
test('travel tokens retain precedence over quadrant streets and bare words', () => {
for (const [text, heading] of [
['I-5 NB at NE 45th St', 0],
['SR 520 EB at NE 148th', 90],
['US 13 SB @ SW Front St', 180],
]) {
for (const bare of [false, true])
assert.equal(directionToHeading(text, bare), heading);
}
assert.equal(
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
270,
);
assert.equal(directionToHeading('NE near West St', true), 45);
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
});
+28 -16
View File
@@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) {
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45;
if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315;
if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135;
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
// Bare cardinals — dedicated direction fields only.
if (allowBare) {
if (/\bNORTH\b/.test(text)) return 0;
if (/\bSOUTH\b/.test(text)) return 180;
if (/\bEAST\b/.test(text)) return 90;
if (/\bWEST\b/.test(text)) return 270;
const tiers = [
[
[/\bNORTHBOUND\b|\bNB\b/, 0],
[/\bSOUTHBOUND\b|\bSB\b/, 180],
[/\bEASTBOUND\b|\bEB\b/, 90],
[/\bWESTBOUND\b|\bWB\b/, 270],
],
[
[/\bNORTHEAST\b|\bNE\b/, 45],
[/\bNORTHWEST\b|\bNW\b/, 315],
[/\bSOUTHEAST\b|\bSE\b/, 135],
[/\bSOUTHWEST\b|\bSW\b/, 225],
],
];
if (allowBare)
tiers.push([
[/\bNORTH\b/, 0],
[/\bSOUTH\b/, 180],
[/\bEAST\b/, 90],
[/\bWEST\b/, 270],
]);
for (const forms of tiers) {
const headings = new Set();
for (const [pattern, heading] of forms) {
if (pattern.test(text)) headings.add(heading);
}
if (headings.size)
return headings.size === 1 ? headings.values().next().value : NaN;
}
return NaN;
}