mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/gbfs-rate-limit
This commit is contained in:
commit
995c1f3edc
572 files changed
+89809
-2521
No files matched your search
@@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
.toLowerCase();
|
||||
const latitude = finiteNumber(aircraft?.lat);
|
||||
const longitude = finiteNumber(aircraft?.lon);
|
||||
if (!hex || latitude === null || longitude === null) return null;
|
||||
if (
|
||||
!hex ||
|
||||
latitude === null ||
|
||||
longitude === null ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
|
||||
const seenPosition = Math.max(
|
||||
0,
|
||||
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
|
||||
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
|
||||
);
|
||||
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
|
||||
const onGround = aircraft?.alt_baro === 'ground';
|
||||
@@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
hex,
|
||||
String(aircraft?.flight || aircraft?.r || '').trim() || null,
|
||||
null,
|
||||
Math.max(0, nowSeconds - seenPosition),
|
||||
Math.max(0, nowSeconds - seen),
|
||||
Number.isFinite(seenPosition)
|
||||
? Math.max(0, nowSeconds - seenPosition)
|
||||
: null,
|
||||
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
|
||||
longitude,
|
||||
latitude,
|
||||
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
|
||||
@@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) {
|
||||
: Math.floor(
|
||||
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
|
||||
);
|
||||
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
|
||||
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
|
||||
.filter(Boolean);
|
||||
return { time: nowSeconds, states };
|
||||
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
|
||||
const states = [];
|
||||
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
|
||||
// status travels alongside them, keyed only to admitted aircraft.
|
||||
const admitted = new Set();
|
||||
const statuses = [];
|
||||
for (const row of aircraft) {
|
||||
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
|
||||
if (!state) continue;
|
||||
states.push(state);
|
||||
// The live contract admits the first valid vector for each ICAO. Its
|
||||
// status must come from that same row, never an invalid or later duplicate.
|
||||
const id = state[0];
|
||||
if (admitted.has(id)) continue;
|
||||
admitted.add(id);
|
||||
if (typeof row.emergency === 'string')
|
||||
statuses.push([id, row.emergency.trim().toLowerCase()]);
|
||||
}
|
||||
const emergencyByIcao = Object.fromEntries(statuses);
|
||||
return {
|
||||
time: nowSeconds,
|
||||
states,
|
||||
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Transponder emergency status for one aircraft, from the Mode A squawk and
|
||||
* the ADS-B emergency/priority field.
|
||||
*
|
||||
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
|
||||
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
|
||||
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
|
||||
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
|
||||
* squawk only.
|
||||
*
|
||||
* The status is what the transponder broadcast in THIS poll, never a finding:
|
||||
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
|
||||
* broadcast, which does not confirm an incident or its resolution.
|
||||
*/
|
||||
|
||||
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
|
||||
export const EMERGENCY_TINT_CSS = '#FF4444';
|
||||
|
||||
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
||||
|
||||
const KINDS = {
|
||||
general: { label: 'General emergency', severity: 'emergency' },
|
||||
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
|
||||
nordo: { label: 'Radio failure', severity: 'emergency' },
|
||||
downed: { label: 'Downed aircraft', severity: 'emergency' },
|
||||
minfuel: { label: 'Minimum fuel', severity: 'priority' },
|
||||
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A Mode A code as its four octal digits, else null.
|
||||
* @param {unknown} value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeSquawk(value) {
|
||||
const code = String(value ?? '').trim();
|
||||
return /^[0-7]{4}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency status from one observation. Higher severity wins; an ADS-B
|
||||
* emergency wins a tie. A
|
||||
* 7x00 squawk counts even when the ADS-B field says `none`.
|
||||
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
|
||||
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
|
||||
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
|
||||
* 'squawk' when the Mode A code itself names the status (so it can be
|
||||
* quoted), else 'ads-b'.
|
||||
*/
|
||||
export function aircraftEmergency({ squawk, emergency } = {}) {
|
||||
const code = normalizeSquawk(squawk);
|
||||
const reported = String(emergency ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const fromAdsb = Object.hasOwn(KINDS, reported);
|
||||
const squawkKind = SQUAWK_KINDS[code] ?? null;
|
||||
const kind =
|
||||
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
|
||||
? reported
|
||||
: squawkKind;
|
||||
if (!kind) return null;
|
||||
return {
|
||||
kind,
|
||||
...KINDS[kind],
|
||||
squawk: code,
|
||||
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a summary may phrase the status without flattening it into an incident:
|
||||
* quote the code when the Mode A squawk itself names the status, otherwise
|
||||
* attribute it to the ADS-B emergency field.
|
||||
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
|
||||
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
|
||||
* or 'ADS-B reports general emergency'; null when there is no status.
|
||||
*/
|
||||
export function emergencyBroadcastPhrase(status) {
|
||||
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
|
||||
const label = KINDS[status.kind].label.toLowerCase();
|
||||
return status.source === 'squawk' && status.squawk
|
||||
? `broadcasting squawk ${status.squawk} (${label})`
|
||||
: `ADS-B reports ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the aircraft is broadcasting an emergency (not a priority status).
|
||||
* @param {{severity?: string}|null|undefined} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmergency(status) {
|
||||
return status?.severity === 'emergency';
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// src/data/aircraftEmergency.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
aircraftEmergency,
|
||||
emergencyBroadcastPhrase,
|
||||
isEmergency,
|
||||
normalizeSquawk,
|
||||
} from './aircraftEmergency.js';
|
||||
|
||||
test('the three ICAO emergency squawks map to their kinds', () => {
|
||||
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
|
||||
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
|
||||
const general = aircraftEmergency({ squawk: ' 7700 ' });
|
||||
assert.deepEqual(general, {
|
||||
kind: 'general',
|
||||
label: 'General emergency',
|
||||
severity: 'emergency',
|
||||
squawk: '7700',
|
||||
source: 'squawk',
|
||||
});
|
||||
});
|
||||
|
||||
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
|
||||
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
|
||||
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
|
||||
assert.equal(aircraftEmergency(), null);
|
||||
assert.equal(normalizeSquawk('7800'), null);
|
||||
assert.equal(normalizeSquawk(7700), '7700');
|
||||
assert.equal(normalizeSquawk('770'), null);
|
||||
});
|
||||
|
||||
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
|
||||
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
|
||||
assert.equal(minfuel.kind, 'minfuel');
|
||||
assert.equal(minfuel.severity, 'priority');
|
||||
assert.equal(minfuel.source, 'ads-b');
|
||||
assert.equal(minfuel.squawk, '4521');
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
|
||||
'squawk',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
|
||||
'squawk',
|
||||
'a code that names the status stays quotable',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
|
||||
'ads-b',
|
||||
);
|
||||
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
|
||||
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
|
||||
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
|
||||
});
|
||||
|
||||
test('priority statuses are reported but are not emergencies', () => {
|
||||
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
|
||||
assert.equal(
|
||||
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||
false,
|
||||
);
|
||||
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
|
||||
assert.equal(isEmergency(null), false);
|
||||
});
|
||||
|
||||
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
|
||||
'broadcasting squawk 7700 (general emergency)',
|
||||
);
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(
|
||||
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
|
||||
),
|
||||
'ADS-B reports general emergency',
|
||||
);
|
||||
assert.equal(
|
||||
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||
'ADS-B reports lifeguard (medical priority)',
|
||||
);
|
||||
assert.equal(emergencyBroadcastPhrase(null), null);
|
||||
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
|
||||
});
|
||||
|
||||
test('an emergency squawk outranks an ADS-B priority', () => {
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
|
||||
'general',
|
||||
);
|
||||
assert.equal(
|
||||
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
|
||||
'unlawful',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
|
||||
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
|
||||
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
|
||||
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
|
||||
import { aircraftEmergency } from './aircraftEmergency.js';
|
||||
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
|
||||
// and civil/military billboard, Cockpit and model tint regressions.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
|
||||
import { FlightRecords } from '../layers/flights/records.js';
|
||||
import { MilitaryFlightRecords } from '../layers/military/records.js';
|
||||
import { mapAnalystRecord as mapFlight } from './flights.js';
|
||||
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
|
||||
import { createAnalystEngine } from './analystEngine.js';
|
||||
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
|
||||
const NOW_MS = 1800000000000;
|
||||
|
||||
test('fallback status belongs to the first valid admitted aircraft row', () => {
|
||||
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
|
||||
for (const status of [undefined, 'general', 'none']) {
|
||||
const payload = normalizeAdsbLolPointResponse({
|
||||
now: NOW_MS,
|
||||
ac: [
|
||||
{ ...valid, lat: 100, emergency: 'general' },
|
||||
{ ...valid, emergency: status },
|
||||
{ ...valid, lat: null, emergency: 'general' },
|
||||
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
|
||||
],
|
||||
});
|
||||
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
|
||||
assert.equal(snapshot.records.length, 1);
|
||||
assert.equal(snapshot.records[0].emergency, status);
|
||||
assert.equal(snapshot.records[0].latitude, 30);
|
||||
}
|
||||
});
|
||||
|
||||
function civilStore() {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (value) => value,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
}
|
||||
const receiveContext = {
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
};
|
||||
|
||||
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
|
||||
const store = civilStore();
|
||||
const aircraft = {
|
||||
hex: 'a1b2c3',
|
||||
flight: 'TEST1',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
gs: 150,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
};
|
||||
for (const [status, expectedKind, flagged] of [
|
||||
['general', 'general', true],
|
||||
['minfuel', 'minfuel', false],
|
||||
['none', null, false],
|
||||
]) {
|
||||
const payload = normalizeAdsbLolPointResponse({
|
||||
now: NOW_MS,
|
||||
ac: [{ ...aircraft, emergency: status }],
|
||||
});
|
||||
assert.equal(
|
||||
payload.states[0].length,
|
||||
18,
|
||||
'fallback vectors retain the OpenSky shape',
|
||||
);
|
||||
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
|
||||
const result = mapFlight(
|
||||
aircraft.hex,
|
||||
store.receive(observation, receiveContext).meta,
|
||||
);
|
||||
assert.equal(result.emergency, flagged);
|
||||
assert.equal(result.emergencyKind, expectedKind);
|
||||
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
|
||||
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
|
||||
}
|
||||
});
|
||||
|
||||
test('changed status without contact time retains general contact, including voice projection', async () => {
|
||||
const store = civilStore();
|
||||
const [observation] = readsbSnapshot(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'a1b2c3',
|
||||
flight: 'TEST1',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
gs: 150,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observedAtMs: NOW_MS, now: NOW_MS },
|
||||
).records;
|
||||
store.receive(observation, receiveContext);
|
||||
const meta = store.receive(
|
||||
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
|
||||
receiveContext,
|
||||
).meta;
|
||||
const record = mapFlight(observation.id, meta);
|
||||
assert.equal(record.emergencyKind, 'nordo');
|
||||
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
|
||||
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
|
||||
globalThis.window = globalThis.window || {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
requestIdleCallback: null,
|
||||
};
|
||||
const flights = {
|
||||
id: 'flights',
|
||||
source: 'fixture',
|
||||
getStats: () => ({ count: 1 }),
|
||||
getAnalystRecords: () => [record],
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionCartographic: {
|
||||
height: 300000,
|
||||
latitude: 0.52,
|
||||
longitude: -1.71,
|
||||
},
|
||||
},
|
||||
},
|
||||
styleManager: {
|
||||
getContextModeState: () => ({ mode: null, active: false }),
|
||||
getCockpitState: () => ({ active: false }),
|
||||
},
|
||||
dataManager: {
|
||||
layers: new Map([['flights', { module: flights }]]),
|
||||
isEnabled: (id) => id === 'flights',
|
||||
getAll: () => [
|
||||
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runner('analyst_query', {
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'emergency', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.items[0].emergency, true);
|
||||
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
|
||||
assert.equal(result.items[0].emergencySource, 'squawk');
|
||||
assert.equal(
|
||||
result.items[0].emergencyBroadcast,
|
||||
'broadcasting squawk 7600 (radio failure)',
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result.items[0], 'time'),
|
||||
false,
|
||||
'general contact is not a generic incident timestamp',
|
||||
);
|
||||
} finally {
|
||||
runner.dispose?.();
|
||||
}
|
||||
});
|
||||
|
||||
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
|
||||
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
|
||||
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
|
||||
const OPENSKY = {
|
||||
time: NOW_MS / 1000,
|
||||
states: [
|
||||
[
|
||||
'a1b2c3',
|
||||
'SWA696 ',
|
||||
'United States',
|
||||
NOW_MS / 1000 - 6,
|
||||
NOW_MS / 1000 - 3,
|
||||
-97.67,
|
||||
30.19,
|
||||
3000,
|
||||
false,
|
||||
150,
|
||||
180,
|
||||
-5,
|
||||
null,
|
||||
3040,
|
||||
'7700',
|
||||
false,
|
||||
0,
|
||||
4,
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
|
||||
const ADSB_LOL = {
|
||||
ac: [
|
||||
{
|
||||
hex: 'ae01ce',
|
||||
flight: 'RCH123 ',
|
||||
t: 'C17',
|
||||
lat: 31.1,
|
||||
lon: -97.4,
|
||||
alt_baro: 18000,
|
||||
gs: 300,
|
||||
track: 90,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 1.5,
|
||||
seen_pos: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function civilAnalystRecord() {
|
||||
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
|
||||
const store = new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
const { meta } = store.receive(row, {
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
return mapFlight(row.id, meta);
|
||||
}
|
||||
|
||||
function militaryAnalystRecord() {
|
||||
const [row] = readsbSnapshot(ADSB_LOL, {
|
||||
observedAtMs: NOW_MS,
|
||||
now: NOW_MS,
|
||||
}).records;
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
});
|
||||
const { meta } = store.receive(row, {
|
||||
observedAtMs: NOW_MS,
|
||||
floorWarmPoints: [],
|
||||
modelOwnsVisual: false,
|
||||
});
|
||||
return mapMilitary(row.id, meta);
|
||||
}
|
||||
|
||||
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
|
||||
const civil = civilAnalystRecord();
|
||||
const military = militaryAnalystRecord();
|
||||
|
||||
assert.equal(civil.emergency, true);
|
||||
assert.equal(civil.emergencyKind, 'general');
|
||||
assert.equal(civil.squawk, '7700');
|
||||
assert.equal(civil.emergencySource, 'squawk');
|
||||
assert.equal(
|
||||
civil.emergencyBroadcast,
|
||||
'broadcasting squawk 7700 (general emergency)',
|
||||
);
|
||||
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
|
||||
|
||||
assert.equal(military.emergency, true);
|
||||
assert.equal(military.emergencyKind, 'general');
|
||||
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
|
||||
assert.equal(military.emergencySource, 'ads-b');
|
||||
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
|
||||
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
|
||||
});
|
||||
|
||||
test('the analyst can filter emergencies by source', async () => {
|
||||
const records = {
|
||||
flights: [civilAnalystRecord()],
|
||||
military: [militaryAnalystRecord()],
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => records[key] || [],
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
|
||||
});
|
||||
const query = (value) =>
|
||||
engine.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [
|
||||
{ field: 'emergency', op: 'eq', value: true },
|
||||
{ field: 'emergencySource', op: 'eq', value },
|
||||
],
|
||||
});
|
||||
const bySquawk = await query('squawk');
|
||||
assert.deepEqual(
|
||||
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
|
||||
);
|
||||
const byAdsb = await query('ads-b');
|
||||
assert.deepEqual(
|
||||
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||
[['ae01ce', 'ADS-B reports general emergency']],
|
||||
);
|
||||
});
|
||||
|
||||
test('military status changes retain only the latest known general contact', () => {
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (v) => v,
|
||||
});
|
||||
const [observation] = readsbSnapshot(ADSB_LOL, {
|
||||
observedAtMs: NOW_MS,
|
||||
now: NOW_MS,
|
||||
}).records;
|
||||
const context = {
|
||||
observedAtMs: NOW_MS,
|
||||
floorWarmPoints: [],
|
||||
modelOwnsVisual: false,
|
||||
};
|
||||
const unknown = mapMilitary(
|
||||
observation.id,
|
||||
store.receive({ ...observation, contactTimeMs: null }, context).meta,
|
||||
);
|
||||
assert.equal(unknown.lastContactAtMs, null);
|
||||
store.receive(observation, context);
|
||||
const changed = mapMilitary(
|
||||
observation.id,
|
||||
store.receive(
|
||||
{
|
||||
...observation,
|
||||
contactTimeMs: null,
|
||||
squawk: '7600',
|
||||
emergency: 'none',
|
||||
},
|
||||
context,
|
||||
).meta,
|
||||
);
|
||||
assert.equal(changed.emergencyKind, 'nordo');
|
||||
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
|
||||
});
|
||||
|
||||
test('fallback missing contact age retains known contact through the real converter', () => {
|
||||
const store = civilStore();
|
||||
const row = {
|
||||
hex: 'a1b2c3',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
seen_pos: 1,
|
||||
};
|
||||
const receive = (row) =>
|
||||
store.receive(
|
||||
openSkySnapshot(
|
||||
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
|
||||
{ now: NOW_MS },
|
||||
).records[0],
|
||||
receiveContext,
|
||||
).meta;
|
||||
const first = receive(row);
|
||||
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
|
||||
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
|
||||
});
|
||||
|
||||
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
|
||||
const store = civilStore();
|
||||
const row = {
|
||||
hex: 'a1b2c3',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
squawk: '1200',
|
||||
emergency: 'general',
|
||||
seen: 3,
|
||||
};
|
||||
const receive = (row) =>
|
||||
store.receive(
|
||||
openSkySnapshot(
|
||||
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
|
||||
{ now: NOW_MS },
|
||||
).records[0],
|
||||
receiveContext,
|
||||
).meta;
|
||||
assert.equal(receive(row).emergency.kind, 'general');
|
||||
assert.equal(
|
||||
receive({ ...row, emergency: undefined }).emergency?.kind,
|
||||
'general',
|
||||
);
|
||||
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
|
||||
receive(row);
|
||||
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
|
||||
receive(row);
|
||||
assert.equal(
|
||||
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
function renderHarness(factory) {
|
||||
const flightState = { records: { data: new Map() }, _trackedIcao: null };
|
||||
const rendering = factory({
|
||||
flightState,
|
||||
services: {
|
||||
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
|
||||
aircraftPresentation: {},
|
||||
camera: {},
|
||||
focus: {},
|
||||
recession: {},
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
return { flightState, rendering };
|
||||
}
|
||||
|
||||
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
|
||||
for (const factory of [civilRendering, militaryRendering]) {
|
||||
const { flightState, rendering } = renderHarness(factory);
|
||||
const normal = rendering._fleetBillboardColor('civil', {});
|
||||
assert.equal(
|
||||
rendering._fleetBillboardColor('civil', {
|
||||
emergency: aircraftEmergency({ emergency: 'minfuel' }),
|
||||
}),
|
||||
normal,
|
||||
);
|
||||
const red = rendering._fleetBillboardColor('civil', {
|
||||
emergency: aircraftEmergency({ squawk: '7700' }),
|
||||
});
|
||||
assert.ok(red.red > 0.9 && red.green < 0.5);
|
||||
flightState.records.data.set('civil', {
|
||||
emergency: aircraftEmergency({ squawk: '7700' }),
|
||||
});
|
||||
assert.equal(rendering._modelColor('civil'), red);
|
||||
if (rendering._cockpitContactColor)
|
||||
assert.equal(rendering._cockpitContactColor('civil'), red);
|
||||
flightState._trackedIcao = 'civil';
|
||||
assert.equal(
|
||||
rendering._modelColor('civil'),
|
||||
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
|
||||
);
|
||||
flightState._trackedIcao = null;
|
||||
flightState.records.data.set('civil', { emergency: null });
|
||||
assert.equal(rendering._modelColor('civil'), normal);
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
|
||||
const { flightState, rendering } = renderHarness(militaryRendering);
|
||||
const bb = {
|
||||
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
|
||||
};
|
||||
Object.assign(flightState, {
|
||||
_billboards: new Map([['abc123', bb]]),
|
||||
_models: new Map(),
|
||||
_positionHistory: new Map(),
|
||||
_cullPositions: new Map(),
|
||||
_groundSnap: { forget() {} },
|
||||
_cockpitContactMode: false,
|
||||
});
|
||||
const records = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (value) => value,
|
||||
});
|
||||
flightState.records = records;
|
||||
const apply = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
|
||||
meshFloor: { sampleMeshFloorCells() {} },
|
||||
militaryRegistry: { registerMilitaryIcaos() {} },
|
||||
rendering: {
|
||||
...rendering,
|
||||
_syncModelToClass() {},
|
||||
_applyFleetBillboardPresentation() {},
|
||||
},
|
||||
motion: {},
|
||||
tracking: {},
|
||||
queries: {},
|
||||
});
|
||||
for (const [emergency, red] of [
|
||||
['general', true],
|
||||
['none', false],
|
||||
['general', true],
|
||||
]) {
|
||||
const snapshot = readsbSnapshot(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'abc123',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
squawk: '1200',
|
||||
emergency,
|
||||
seen_pos: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observedAtMs: NOW_MS },
|
||||
);
|
||||
apply(snapshot, {});
|
||||
assert.equal(bb.color.green < 0.5, red);
|
||||
assert.equal(bb.color.alpha, 0.65);
|
||||
}
|
||||
});
|
||||
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the already-composed aircraft alpha to an ambient glTF model without
|
||||
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
* @param {object} input
|
||||
* @param {object} input.model
|
||||
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
|
||||
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
||||
if (
|
||||
Number.isFinite(model.color?.alpha) &&
|
||||
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
|
||||
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
|
||||
model.color.red === baseColor.red &&
|
||||
model.color.green === baseColor.green &&
|
||||
model.color.blue === baseColor.blue
|
||||
)
|
||||
return 0;
|
||||
model.color = baseColor.withAlpha(alpha);
|
||||
|
||||
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
|
||||
assert.equal(model.colorBlendAmount, 0.9);
|
||||
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
|
||||
});
|
||||
|
||||
|
||||
test('model hue changes at constant alpha, then clears without redundant writes', () => {
|
||||
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
|
||||
const model = {color:color(1,1,1).withAlpha(0.65)};
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
|
||||
assert.equal(model.color.green,0.2);
|
||||
assert.equal(model.color.alpha,0.65);
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
|
||||
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
|
||||
assert.equal(model.color.green,1);
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
+879
-144
File diff suppressed because it is too large.
Load diff
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
|
||||
|
||||
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
||||
const first = await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const r = await eng.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'GND1');
|
||||
assert.equal(r.coverage.followUp, true);
|
||||
assert.equal(r.scopeLabel, first.scopeLabel);
|
||||
assert.equal(r.display.scope, first.display.scope);
|
||||
assert.equal(r.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
|
||||
getLayerSnapshot: (key) => ({
|
||||
enabled: key === 'flights',
|
||||
feedState: key === 'flights' ? 'nominal' : 'off',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await eng.query({
|
||||
layers: ['flights', 'military'],
|
||||
scope: { kind: 'anywhere' },
|
||||
});
|
||||
assert.equal(first.partial, true);
|
||||
assert.deepEqual(first.unanswered, ['military']);
|
||||
|
||||
const followUp = await eng.query({ followUp: true, limit: 1 });
|
||||
assert.equal(followUp.partial, true);
|
||||
assert.deepEqual(followUp.unanswered, ['military']);
|
||||
assert.deepEqual(
|
||||
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
first.coverage.layersQueried.map(({ layerKey, status }) => [
|
||||
layerKey,
|
||||
status,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
let current = true;
|
||||
const stale = await eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
isCurrent: () => current,
|
||||
beforeCommit: async () => {
|
||||
current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
|
||||
});
|
||||
await assert.rejects(
|
||||
eng.query(
|
||||
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
|
||||
{
|
||||
beforeCommit: async () => {
|
||||
throw new Error('projection failed');
|
||||
},
|
||||
},
|
||||
),
|
||||
/projection failed/,
|
||||
);
|
||||
const followUp = await eng.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
@@ -200,6 +284,39 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
|
||||
assert.equal(r.coverage.scope, 'region:Texas:timeout');
|
||||
});
|
||||
|
||||
test('analyst: region lookup receives and obeys the owning action signal', async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal = null;
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (_name, signal) => {
|
||||
receivedSignal = signal;
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
getViewContext: () => ({
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
viewRadiusKm: 150,
|
||||
}),
|
||||
});
|
||||
const pending = eng.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
isCurrent: () => !controller.signal.aborted,
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -262,20 +379,132 @@ test('helpers: haversine sanity + scope radius', () => {
|
||||
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
||||
});
|
||||
|
||||
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
|
||||
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
|
||||
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
|
||||
getRecordCoverage: () => ({ total: 3000, truncated: true }),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
|
||||
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
|
||||
const layer = result.coverage.layersQueried[0];
|
||||
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
|
||||
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
|
||||
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
|
||||
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
|
||||
});
|
||||
|
||||
test('an uncapped cohort is complete and carries no caveat', async () => {
|
||||
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
|
||||
assert.equal(result.display.caveat, undefined);
|
||||
assert.equal(result.display.scope, 'anywhere in the loaded data');
|
||||
});
|
||||
|
||||
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
|
||||
const engine = makeEngine();
|
||||
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
|
||||
assert.equal(field.ok, false);
|
||||
assert.equal(field.code, 'UNKNOWN_FIELD');
|
||||
assert.ok(field.allowed.includes('altitudeM'));
|
||||
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
|
||||
assert.equal(op.code, 'BAD_OPERATOR');
|
||||
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
|
||||
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
|
||||
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
|
||||
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
|
||||
assert.equal(num.code, 'BAD_VALUE');
|
||||
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
|
||||
assert.equal(scope.code, 'UNKNOWN_SCOPE');
|
||||
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
|
||||
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
|
||||
assert.equal(sort.code, 'UNKNOWN_FIELD');
|
||||
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
|
||||
});
|
||||
|
||||
test('a typed boolean filter still matches exactly', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
|
||||
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
|
||||
});
|
||||
|
||||
test('distance honours sortDir: desc is farthest first', async () => {
|
||||
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
|
||||
assert.equal(far.items[0].id, 'N123');
|
||||
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
|
||||
assert.equal(near.items[0].id, 'SWA1');
|
||||
});
|
||||
|
||||
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
|
||||
const r = await makeEngine().query({ followUp: true, filters: [] });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'NO_RESULT_CONTEXT');
|
||||
});
|
||||
|
||||
test('follow-ups never rewrite the remembered rows', async () => {
|
||||
const engine = makeEngine();
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
await engine.query({ followUp: true, sortBy: 'distance' });
|
||||
const again = await engine.query({ followUp: true });
|
||||
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
|
||||
assert.ok(again.items.every((item) => item.distanceKm === undefined));
|
||||
});
|
||||
|
||||
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
|
||||
let releaseSlow;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
|
||||
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
let generation = 0;
|
||||
const slowGeneration = ++generation;
|
||||
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
|
||||
const newGeneration = ++generation;
|
||||
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
|
||||
assert.equal(fresh.ok, true);
|
||||
releaseSlow();
|
||||
const stale = await slow;
|
||||
assert.equal(stale.code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
|
||||
});
|
||||
|
||||
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
|
||||
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getLayerSnapshot: () => snapshot,
|
||||
isWarming: () => warming,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
|
||||
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
|
||||
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
|
||||
assert.equal(zero.ok, true);
|
||||
assert.equal(zero.count, 0);
|
||||
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
|
||||
});
|
||||
|
||||
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
|
||||
const now = Date.parse('2026-09-23T12:00:00Z');
|
||||
const engine = createAnalystEngine({
|
||||
now: () => now,
|
||||
getRecords: () => [
|
||||
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
|
||||
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
|
||||
],
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
|
||||
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
|
||||
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
|
||||
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
|
||||
assert.equal(age.items[0].ageHours, 2);
|
||||
assert.equal(age.display.window, 'last 24 h');
|
||||
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
|
||||
});
|
||||
|
||||
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
|
||||
let state = 'stale';
|
||||
@@ -293,3 +522,141 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
|
||||
});
|
||||
|
||||
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
|
||||
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => rows,
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
assert.equal(result.count, 200_000);
|
||||
assert.equal(result.items[0].frp, 199_999);
|
||||
assert.equal(result.summary.frpMin, 0);
|
||||
});
|
||||
|
||||
test('an enabled but empty layer says why on screen', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [],
|
||||
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
|
||||
getEmptyNote: () => 'Fly below 400 km to a covered region',
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['transit'] });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 0);
|
||||
assert.equal(result.coverage.layersQueried[0].status, 'empty');
|
||||
assert.match(result.display.caveat, /transit: Fly below 400 km/);
|
||||
});
|
||||
|
||||
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
|
||||
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'BAD_SCOPE');
|
||||
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
|
||||
assert.equal(ok.ok, true);
|
||||
});
|
||||
|
||||
test('a field copied with its hinted unit still resolves', async () => {
|
||||
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
|
||||
});
|
||||
|
||||
test('a written unit converts to the field unit or is refused', async () => {
|
||||
const engine = makeEngine();
|
||||
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
|
||||
assert.equal(feet.ok, true);
|
||||
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
|
||||
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
|
||||
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
|
||||
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
|
||||
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
|
||||
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
|
||||
assert.equal(wrong.code, 'BAD_UNIT');
|
||||
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
|
||||
assert.equal(unitless.code, 'BAD_UNIT');
|
||||
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
|
||||
assert.equal(hinted.ok, true);
|
||||
});
|
||||
|
||||
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
|
||||
const engine = makeEngine();
|
||||
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
|
||||
assert.equal(text.ok, true);
|
||||
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
|
||||
for (const value of ['', ' ', true, 'high', '12k'])
|
||||
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
|
||||
});
|
||||
|
||||
test('any supplied centre must be a real coordinate', async () => {
|
||||
const engine = makeEngine();
|
||||
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
|
||||
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
|
||||
});
|
||||
|
||||
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
|
||||
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
|
||||
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
|
||||
assert.equal(none.ok, false);
|
||||
assert.equal(none.code, 'NOT_READY');
|
||||
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
|
||||
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
|
||||
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
|
||||
assert.equal(down.code, 'FEED_UNAVAILABLE');
|
||||
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
|
||||
assert.equal(partial.ok, true);
|
||||
assert.equal(partial.partial, true);
|
||||
assert.deepEqual(partial.unanswered, ['earthquakes']);
|
||||
assert.match(partial.display.caveat, /earthquakes error/);
|
||||
});
|
||||
|
||||
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
|
||||
const engine = makeEngine();
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
|
||||
for (const item of first.items) {
|
||||
item.altitudeM = -1;
|
||||
item.id = 'MUTATED';
|
||||
}
|
||||
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
|
||||
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
|
||||
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
|
||||
});
|
||||
|
||||
test('ranking 250k rows stays within a budget and yields to the page', async () => {
|
||||
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
|
||||
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 0);
|
||||
const started = performance.now();
|
||||
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
|
||||
const elapsed = performance.now() - started;
|
||||
clearInterval(timer);
|
||||
assert.equal(result.count, 250_000);
|
||||
assert.equal(result.items[0].frp, 100_002);
|
||||
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
|
||||
assert.ok(ticks > 0, 'the scan yielded between slices');
|
||||
let current = true;
|
||||
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
|
||||
current = false;
|
||||
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
|
||||
});
|
||||
|
||||
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async () => null,
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
|
||||
const r = await engine.query({ layers: ['local-firms'], followUp: true });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
|
||||
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Camera range with breathing room in both viewport dimensions. */
|
||||
export function rangeForBoundingSphere(viewer, radius) {
|
||||
const frustum = viewer.camera?.frustum;
|
||||
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
|
||||
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
|
||||
const limitingFov = Math.min(verticalFov, horizontalFov);
|
||||
// Target 57% sphere occupancy so perspective still leaves at least 40%
|
||||
// measured roof-to-base breathing room in ordinary oblique building views.
|
||||
const occupiedViewportFraction = 0.57;
|
||||
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
|
||||
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
parseCataloniaXml,
|
||||
normalizeCataloniaImageUrl,
|
||||
cataloniaCameraName,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<?xml'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One `<gml:featureMember>` block matching the real feed's shape. */
|
||||
function cataloniaFeature({
|
||||
fid,
|
||||
lon,
|
||||
lat,
|
||||
carretera = '',
|
||||
municipi = '',
|
||||
pk = '',
|
||||
link = '',
|
||||
font = '',
|
||||
}) {
|
||||
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
|
||||
return (
|
||||
`<gml:featureMember><cite:cameres fid="${fid}">` +
|
||||
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
|
||||
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
|
||||
`</gml:Point></cite:geom>` +
|
||||
`<cite:carretera>${carretera}</cite:carretera>` +
|
||||
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
|
||||
`<cite:link>${link}</cite:link>` +
|
||||
`<cite:font>${font}</cite:font>` +
|
||||
`</cite:cameres></gml:featureMember>`
|
||||
);
|
||||
}
|
||||
|
||||
function cataloniaFeed(features) {
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
|
||||
features.join('') +
|
||||
'</wfs:FeatureCollection>'
|
||||
);
|
||||
}
|
||||
|
||||
test('parseCataloniaXml extracts every field from a feature block', () => {
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
const rows = parseCataloniaXml(xml);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: 2.1849528,
|
||||
lat: 41.45989301,
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
|
||||
// RenderService always 302s to a hardcoded http:// Location (which
|
||||
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
|
||||
// setup fails the handshake with Node's fetch regardless — TransitCamera
|
||||
// over plain http is the only transport that actually works here, so we
|
||||
// build that URL from RenderService's sctidcam id instead of registering
|
||||
// the redirector.
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
|
||||
'an already-https RenderService link is rewritten the same way',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
|
||||
'',
|
||||
'a RenderService link with no sctidcam id is rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
|
||||
'https://www.bcn.cat/transit/imatges/a.gif',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
),
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
|
||||
'',
|
||||
'a look-alike host is rejected',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
|
||||
'',
|
||||
'any host outside the allowlist is rejected',
|
||||
);
|
||||
assert.equal(normalizeCataloniaImageUrl(''), '');
|
||||
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
|
||||
// The hostname matches the allowlist, so only an explicit userinfo check
|
||||
// stops catalog data injecting credentials into the frame request.
|
||||
for (const link of [
|
||||
'https://user:[email protected]/transit/imatges/a.gif',
|
||||
'http://[email protected]/transit/imatges/a.gif',
|
||||
'https://:[email protected]/it_terrassa/cam02.jpeg',
|
||||
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
|
||||
// An allowlisted hostname alone would let catalog data aim the frame
|
||||
// request at another service or path on that host.
|
||||
for (const link of [
|
||||
'http://www.bcn.cat:8080/transit/imatges/a.gif',
|
||||
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
|
||||
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
'http://www.bcn.cat/admin/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/',
|
||||
'http://www.bcn.cat/transit/imatges/sub/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
|
||||
'https://emap.terrassa.cat/other/cam02.jpeg',
|
||||
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
|
||||
for (const camId of [
|
||||
'../../etc/passwd',
|
||||
'..%2F..%2Fetc%2Fpasswd',
|
||||
'nc87.png',
|
||||
'nc87',
|
||||
'nc87.gif/../x.gif',
|
||||
'nc87.gif&nom=other.gif',
|
||||
'nc-87.gif',
|
||||
]) {
|
||||
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
|
||||
);
|
||||
});
|
||||
|
||||
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
assert.equal(decodeNumericEntities('Lluís 🚦'), 'Lluís 🚦');
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
for (const raw of [
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
]) {
|
||||
assert.equal(decodeNumericEntities(raw), raw, raw);
|
||||
}
|
||||
});
|
||||
|
||||
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
|
||||
assert.equal(
|
||||
cataloniaCameraName('C-58', 'Nus Trinitat'),
|
||||
'C-58 (Nus Trinitat)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
|
||||
'Plaça Urquinaona (Barcelona)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
|
||||
'Barcelona Ronda Litoral',
|
||||
);
|
||||
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
|
||||
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
|
||||
});
|
||||
|
||||
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-2',
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-3',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
carretera: 'Cam02',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
// Rejected: link host not in the allowlist.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-4',
|
||||
lon: '2.0',
|
||||
lat: '41.5',
|
||||
link: 'https://example.com/fake.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
// Rejected: outside the Catalonia bounding box.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-5',
|
||||
lon: '-3.7',
|
||||
lat: '40.4',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadCataloniaSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.ok(
|
||||
CATALONIA_CAMERAS_URL.startsWith('https://'),
|
||||
'the catalog is fetched over https',
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id).sort(),
|
||||
[
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
],
|
||||
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
|
||||
);
|
||||
|
||||
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
|
||||
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
|
||||
assert.equal(sct.provider, 'Servei Català de Trànsit');
|
||||
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
|
||||
assert.equal(
|
||||
sct.url,
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
|
||||
);
|
||||
assert.equal(sct.groundElevationM, 80);
|
||||
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
|
||||
|
||||
const bcn = cameras.find(
|
||||
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
);
|
||||
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
|
||||
assert.equal(bcn.city, 'Barcelona');
|
||||
assert.equal(bcn.groundElevationM, 20);
|
||||
|
||||
const terrassa = cameras.find(
|
||||
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
);
|
||||
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
|
||||
assert.equal(terrassa.groundElevationM, 270);
|
||||
});
|
||||
|
||||
test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => {
|
||||
quiet(t);
|
||||
let respond;
|
||||
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered or
|
||||
// downgraded. Its body is a live stream, so the test also proves the
|
||||
// refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// An HTTP error body is released too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||
|
||||
const validFeed = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
|
||||
// A body that declares a size over the cap is refused rather than buffered,
|
||||
// even though its actual content would parse.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(validFeed, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body with no declared size that only turns out too long while
|
||||
// streaming — a valid feed padded past the cap.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized =
|
||||
validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_CATALONIA_ENABLED: undefined,
|
||||
CCTV_CATALONIA_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
if (String(url) !== CATALONIA_CAMERAS_URL) {
|
||||
return new Response('unavailable', { status: 503 });
|
||||
}
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, { status: 200 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['cat-sct-nc87'],
|
||||
);
|
||||
|
||||
process.env.CCTV_CATALONIA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
|
||||
* under one upstream run's `fid` prefix. */
|
||||
function snapshotFeed(fidPrefix) {
|
||||
return cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_386f`,
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_b48`,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c2`,
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c3`,
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 ',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadFromFeed(t, xml) {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
return await loadCataloniaSourcesFromOpenData();
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
}
|
||||
|
||||
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The fid embeds the upstream server-start stamp: the PR fixture's
|
||||
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
|
||||
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
|
||||
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
|
||||
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
|
||||
assert.deepEqual(ids(before), [
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
|
||||
'cat-sct-nc85',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
]);
|
||||
assert.deepEqual(ids(after), ids(before));
|
||||
});
|
||||
|
||||
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
|
||||
// different fids.
|
||||
const row = (fid, camId) =>
|
||||
cataloniaFeature({
|
||||
fid,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
|
||||
font: 'SCT',
|
||||
});
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
row('cameres.fid-x_1', 'nc85.gif'),
|
||||
row('cameres.fid-x_2', 'cb5.gif'),
|
||||
row('cameres.fid-x_3', 'nc85.gif'),
|
||||
row('cameres.fid-x_4', 'cb5.gif'),
|
||||
row('cameres.fid-x_5', 'cb10.gif'),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'cat-sct-cb10',
|
||||
'cat-sct-cb5',
|
||||
'cat-sct-nc85',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.17',
|
||||
lat: '41.39',
|
||||
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_3',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_4',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => [camera.id, camera.url]),
|
||||
[
|
||||
[
|
||||
'cat-sct-nc87',
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31 �',
|
||||
municipi: 'Bad�',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'Plaça Catalunya',
|
||||
municipi: 'Llinars del Vallès',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(cameras.length, 2);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc85').name,
|
||||
'C-31 � (Bad�)',
|
||||
'an invalid reference is kept as written',
|
||||
);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc87').name,
|
||||
'Plaça Catalunya (Llinars del Vallès)',
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
DEFAULT_CATALONIA_MAX_SOURCES,
|
||||
DRIVEBC_WEBCAMS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
@@ -221,7 +222,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_KING_COUNTY_MAX_SOURCES,
|
||||
KING_COUNTY_CAMERAS_URL,
|
||||
KING_COUNTY_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** A FeatureServer GeoJSON feature carrying the fields the loader reads. */
|
||||
function kingCountyFeature(id, lat, lon, overrides = {}) {
|
||||
const { properties = {}, geometry } = overrides;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry:
|
||||
geometry === undefined
|
||||
? { type: 'Point', coordinates: [lon, lat] }
|
||||
: geometry,
|
||||
properties: {
|
||||
AssetID: id,
|
||||
Location: `Road ${id} at Test Ave`,
|
||||
ImageURL: `http://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam${id}.jpg`,
|
||||
Live: 1,
|
||||
Model: '3950',
|
||||
Manufacturer: 'Cohu',
|
||||
Owner: 'King County',
|
||||
CurrentStatus: 'Active',
|
||||
...properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('King County loader keeps live county cameras and upgrades frame URLs to HTTPS', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: {
|
||||
Location: 'NE Novelty Hill Road at Trilogy Parkway NE',
|
||||
// The live layer carries stray whitespace in model strings.
|
||||
Model: 'Q6135-LE\n',
|
||||
Manufacturer: 'Axis',
|
||||
},
|
||||
}),
|
||||
// City-owned camera the county hosts: kept, owner becomes the credit.
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Owner: 'City of Newcastle', Model: null },
|
||||
}),
|
||||
// WSDOT-owned row: its frames are WSDOT-hosted, so it is skipped.
|
||||
kingCountyFeature(70, 47.6, -122.3, { properties: { Owner: 'WSDOT' } }),
|
||||
// Off-host frame, not live, retired, bad id, bad geometry, out of county.
|
||||
kingCountyFeature(71, 47.6, -122.3, {
|
||||
properties: { ImageURL: 'https://example.com/cam71.jpg' },
|
||||
}),
|
||||
kingCountyFeature(72, 47.6, -122.3, { properties: { Live: 0 } }),
|
||||
kingCountyFeature(73, 47.6, -122.3, {
|
||||
properties: { CurrentStatus: 'Retired' },
|
||||
}),
|
||||
kingCountyFeature('74', 47.6, -122.3),
|
||||
kingCountyFeature(75, 47.6, -122.3, { geometry: null }),
|
||||
kingCountyFeature(76, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [KING_COUNTY_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
|
||||
const novelty = cameras.find((camera) => camera.id === 'kingcounty-56');
|
||||
assert.equal(novelty.name, 'NE Novelty Hill Road at Trilogy Parkway NE');
|
||||
assert.equal(novelty.provider, 'King County Road Services');
|
||||
assert.equal(
|
||||
novelty.url,
|
||||
'https://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam56.jpg',
|
||||
);
|
||||
assert.equal(novelty.snapshotUrl, novelty.url);
|
||||
assert.equal(novelty.feedType, 'image');
|
||||
// Published hardware model rides along, whitespace-collapsed.
|
||||
assert.equal(novelty.model, 'Q6135-LE');
|
||||
assert.equal(novelty.manufacturer, 'Axis');
|
||||
assert.equal(novelty.credit, '');
|
||||
// No facing in the layer: id-hash fallback at low confidence.
|
||||
assert.equal(novelty.headingConfidence, 'low');
|
||||
assert.equal(novelty.fovDeg, 44);
|
||||
assert.ok(novelty.headingDeg >= 0 && novelty.headingDeg < 360);
|
||||
|
||||
const newcastle = cameras.find((camera) => camera.id === 'kingcounty-90');
|
||||
assert.equal(newcastle.credit, 'City of Newcastle');
|
||||
assert.equal(newcastle.model, undefined);
|
||||
// A manufacturer without a model identifies nothing, so it is dropped too.
|
||||
assert.equal(newcastle.manufacturer, undefined);
|
||||
});
|
||||
|
||||
test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => {
|
||||
const withModel = normalizeSourceItem({
|
||||
id: 'kingcounty-56',
|
||||
lat: 47.7,
|
||||
lon: -122.0,
|
||||
model: ' Q6135-LE ',
|
||||
manufacturer: ' Axis ',
|
||||
});
|
||||
assert.equal(withModel.model, 'Q6135-LE');
|
||||
assert.equal(withModel.manufacturer, 'Axis');
|
||||
|
||||
const withoutModel = normalizeSourceItem({
|
||||
id: 'austin-1',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
});
|
||||
assert.equal(withoutModel.model, undefined);
|
||||
assert.equal(withoutModel.manufacturer, undefined);
|
||||
assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined);
|
||||
});
|
||||
|
||||
/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */
|
||||
async function requestSources(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url: '/sources', method: 'GET' }, res);
|
||||
assert.equal(res.status, 200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
/** The browser catalog with the pose math stubbed out: only field
|
||||
* passthrough is under test here. */
|
||||
function browserCatalog() {
|
||||
const model = {
|
||||
safeNumber: (value, fallback = NaN) =>
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback,
|
||||
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
|
||||
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
|
||||
headingFromId: () => 0,
|
||||
normalizeFeedType: (type) => type,
|
||||
ensureCameraPose: () => {},
|
||||
};
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: {} } },
|
||||
parts: { model },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// No file/env catalog, so the live packs load; every pack but King County
|
||||
// sees an upstream failure and contributes nothing.
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_KINGCOUNTY_ENABLED: undefined,
|
||||
CCTV_KINGCOUNTY_MAX_SOURCES: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) !== KING_COUNTY_CAMERAS_URL)
|
||||
return new Response('', { status: 503 });
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' },
|
||||
}),
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Model: null, Manufacturer: 'Cohu' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: root }));
|
||||
const served = new Map(body.sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...served.keys()].sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
assert.equal(served.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(served.get('kingcounty-56').manufacturer, 'Axis');
|
||||
// Absent hardware is an explicit empty string on the wire, like
|
||||
// headingConfidence, so consumers never have to probe for the key.
|
||||
assert.equal(served.get('kingcounty-90').model, '');
|
||||
assert.equal(served.get('kingcounty-90').manufacturer, '');
|
||||
|
||||
const cameras = browserCatalog().buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis');
|
||||
assert.equal(byId.get('kingcounty-90').model, '');
|
||||
assert.equal(byId.get('kingcounty-90').manufacturer, '');
|
||||
});
|
||||
|
||||
test('King County loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(
|
||||
kingCountyFeature(100 + i, 47.2 + i * 0.02, -122.4 + i * 0.02),
|
||||
);
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('King County loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_KING_COUNTY_MAX_SOURCES, 125);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader does not follow a redirected catalog', async (t) => {
|
||||
quiet(t);
|
||||
let init;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
init = options;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [kingCountyFeature(56, 47.70059, -122.02443)],
|
||||
}),
|
||||
{ status: 302, headers: { Location: 'https://example.com/cams' } },
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
assert.equal(init.redirect, 'manual');
|
||||
});
|
||||
|
||||
test('King County loader rejects an oversized catalog', async (t) => {
|
||||
quiet(t);
|
||||
const features = [kingCountyFeature(56, 47.70059, -122.02443)];
|
||||
const body = JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
padding: 'x'.repeat(KING_COUNTY_MAX_CATALOG_BYTES),
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response(body));
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader drops duplicate ids before the per-pack cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
// Repeats of the camera nearest the anchor would fill an 8-camera cap
|
||||
// before the second, farther camera if duplicates reached prioritization.
|
||||
const features = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
features.push(kingCountyFeature(56, 47.4829, -122.2171));
|
||||
}
|
||||
features.push(kingCountyFeature(57, 47.70059, -122.02443));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-57',
|
||||
]);
|
||||
});
|
||||
|
||||
test('King County loader omits vendor-generic model strings', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(61, 47.5, -122.2, {
|
||||
properties: { Model: 'PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(62, 47.51, -122.2, {
|
||||
properties: { Model: 'PTZ IP', Manufacturer: 'Cohu' },
|
||||
}),
|
||||
kingCountyFeature(63, 47.52, -122.2, {
|
||||
properties: { Model: 'BOSCH/PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(64, 47.53, -122.2, {
|
||||
properties: { Model: 'AUTODOME IP 5000', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(
|
||||
(await loadKingCountySourcesFromOpenData()).map((camera) => [
|
||||
camera.id,
|
||||
camera,
|
||||
]),
|
||||
);
|
||||
for (const id of ['kingcounty-61', 'kingcounty-62', 'kingcounty-63']) {
|
||||
assert.equal(byId.get(id).model, undefined, id);
|
||||
assert.equal(byId.get(id).manufacturer, undefined, id);
|
||||
}
|
||||
assert.equal(byId.get('kingcounty-64').model, 'AUTODOME IP 5000');
|
||||
assert.equal(byId.get('kingcounty-64').manufacturer, 'Bosch');
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lithuaniaCameraToSource,
|
||||
loadLithuaniaSources,
|
||||
LITHUANIA_CAMERAS_URL,
|
||||
LITHUANIA_LOCATIONS_URL,
|
||||
LITHUANIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/lithuania.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const row = {
|
||||
id: 72,
|
||||
name: 'Vilnius A1 10,04',
|
||||
x: 576154,
|
||||
y: 6056867,
|
||||
image:
|
||||
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
};
|
||||
const feature = {
|
||||
id: '72',
|
||||
points: [{ point: [54.642550940833566, 25.17981587877114] }],
|
||||
};
|
||||
const layers = [{ layer: 'VKR', features: [feature] }];
|
||||
|
||||
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
|
||||
const camera = lithuaniaCameraToSource(row, feature);
|
||||
assert.equal(camera.id, 'lithuania-72');
|
||||
assert.equal(camera.lat, 54.642550940833566);
|
||||
assert.equal(camera.lon, 25.17981587877114);
|
||||
assert.equal(camera.name, 'Vilnius A1 10,04');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.url, row.image);
|
||||
assert.match(camera.credit, /Via Lietuva/);
|
||||
});
|
||||
|
||||
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...feature, id: '8' },
|
||||
{ ...feature, points: [] },
|
||||
{ ...feature, points: [{ point: [25.18, 54.64] }] },
|
||||
{ ...feature, points: [{ point: [576154, 6056867] }] },
|
||||
{ ...feature, points: [{ point: [null, 25] }] },
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource(row, invalid), null);
|
||||
}
|
||||
for (const image of [
|
||||
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
|
||||
row.image.replace('https://', 'https://user:pass@'),
|
||||
row.image.replace('id=72', 'id=8'),
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
|
||||
}
|
||||
assert.equal(
|
||||
lithuaniaCameraToSource(
|
||||
{ ...row, image: row.image + '&extra=ignored' },
|
||||
feature,
|
||||
).url,
|
||||
row.image,
|
||||
);
|
||||
assert.equal(lithuaniaCameraToSource(null, feature), null);
|
||||
});
|
||||
|
||||
function mockCatalogs(t) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return Response.json([row, row, { ...row, id: 999 }]);
|
||||
}
|
||||
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
|
||||
const requested = mockCatalogs(t);
|
||||
assert.deepEqual(
|
||||
(await loadLithuaniaSources()).map((c) => c.id),
|
||||
['lithuania-72'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requested.sort(),
|
||||
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
/** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
|
||||
* (undefined leaves it unset), restoring the environment afterwards. */
|
||||
async function runCatalogWithLithuaniaFlag(t, flag) {
|
||||
const requested = mockCatalogs(t);
|
||||
const saved = process.env.CCTV_LITHUANIA_ENABLED;
|
||||
try {
|
||||
if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = saved;
|
||||
}
|
||||
}
|
||||
|
||||
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
|
||||
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'lithuania-72')?.cityId,
|
||||
'lithuania',
|
||||
);
|
||||
});
|
||||
|
||||
// The camera endpoints are the eismoinfo.lt website backend, not a service the
|
||||
// open-data statement lists, so an install that has not opted in must never
|
||||
// contact them.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_LITHUANIA_ENABLED', undefined],
|
||||
['an empty CCTV_LITHUANIA_ENABLED', ''],
|
||||
['CCTV_LITHUANIA_ENABLED=0', '0'],
|
||||
['CCTV_LITHUANIA_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no Lithuania request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('eismoinfo.lt')),
|
||||
false,
|
||||
'the pack never reaches its upstream',
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let siblingSignal;
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
siblingSignal = options.signal;
|
||||
// Never settles on its own; only an abort ends it.
|
||||
return new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () =>
|
||||
reject(options.signal.reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.equal(siblingSignal?.aborted, true);
|
||||
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
|
||||
});
|
||||
|
||||
test('a redirected catalog is reported as not followed', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
|
||||
});
|
||||
|
||||
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const makeResponse of [
|
||||
() => new Response('', { status: 503 }),
|
||||
() =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
() => new Response('invalid json'),
|
||||
() => Response.json({ unexpected: true }),
|
||||
() =>
|
||||
new Response('[]', {
|
||||
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
|
||||
}),
|
||||
]) {
|
||||
t.mock.method(globalThis, 'fetch', async () => makeResponse());
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
}
|
||||
});
|
||||
@@ -38,6 +38,10 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
// Measured empirically (2026-09-15): polling the same SCT/Terrassa frame
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -641,6 +641,10 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// /api/cctv/media is the live-feed relay. A still camera used to be relayed
|
||||
// through it as well, by an ordinary redirect-following fetch with no content
|
||||
// type check and no body cap, which side-stepped everything /api/cctv/frame
|
||||
// enforces for the same URL. These pin that a still is refused there and only
|
||||
// reachable through /frame, while live feeds keep their relay.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const STILL = {
|
||||
id: 'still-fixture',
|
||||
name: 'Still fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'image',
|
||||
url: 'https://cams.upstream.invalid/still.jpg',
|
||||
};
|
||||
// 'jpg' normalizes to 'image'; the alias must not reopen the relay.
|
||||
const STILL_ALIAS = {
|
||||
...STILL,
|
||||
id: 'still-alias',
|
||||
feedType: 'jpg',
|
||||
url: 'https://cams.upstream.invalid/alias.jpg',
|
||||
};
|
||||
const VIDEO = {
|
||||
id: 'video-fixture',
|
||||
name: 'Video fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://cams.upstream.invalid/live.mp4',
|
||||
};
|
||||
const MJPEG = {
|
||||
id: 'mjpeg-fixture',
|
||||
name: 'MJPEG fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'mjpeg',
|
||||
url: 'https://cams.upstream.invalid/live.mjpg',
|
||||
};
|
||||
|
||||
/** A minimal upstream Response with a streaming body. */
|
||||
function upstreamResponse({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const store = new Map(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
|
||||
body: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects what a handler wrote, the way a Node ServerResponse would. */
|
||||
function recordingResponse() {
|
||||
const chunks = [];
|
||||
let finished;
|
||||
const done = new Promise((resolve) => {
|
||||
finished = resolve;
|
||||
});
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
writableEnded: false,
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
res.writableEnded = true;
|
||||
res.body = Buffer.concat(chunks).toString('utf8');
|
||||
finished();
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
emit() {},
|
||||
destroy() {},
|
||||
};
|
||||
return { res, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the real provider over the fixture cameras and answer every upstream
|
||||
* request from the supplied function.
|
||||
*/
|
||||
function mount(t, respond) {
|
||||
const before = {
|
||||
json: process.env.CCTV_SOURCES_JSON,
|
||||
file: process.env.CCTV_SOURCES_FILE,
|
||||
austin: process.env.CCTV_FORCE_AUSTIN,
|
||||
serverKey: process.env.GOOGLE_MAPS_SERVER_API_KEY,
|
||||
browserKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
};
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify([
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
process.env.CCTV_FORCE_AUSTIN = '0';
|
||||
// No Street View key: a /frame miss lands on the synthetic billboard rather
|
||||
// than a network call this file does not model.
|
||||
delete process.env.GOOGLE_MAPS_SERVER_API_KEY;
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
const nativeFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url: String(url), redirect: init.redirect });
|
||||
return respond({ url: String(url), init });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch;
|
||||
for (const [name, value] of [
|
||||
['CCTV_SOURCES_JSON', before.json],
|
||||
['CCTV_SOURCES_FILE', before.file],
|
||||
['CCTV_FORCE_AUSTIN', before.austin],
|
||||
['GOOGLE_MAPS_SERVER_API_KEY', before.serverKey],
|
||||
['GOOGLE_MAPS_API_KEY', before.browserKey],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let handler = null;
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use: (_route, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
const health = async (id) => {
|
||||
const res = await call('/health');
|
||||
return JSON.parse(res.body).cameras.find((entry) => entry.id === id);
|
||||
};
|
||||
return { call, health, requests };
|
||||
}
|
||||
|
||||
test('a still camera is refused by /media without any upstream request', async (t) => {
|
||||
// Everything the old relay would have done with this camera: redirect to a
|
||||
// private address, then answer with HTML.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.includes('169.254.169.254')
|
||||
? upstreamResponse({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: '<html>metadata</html>',
|
||||
})
|
||||
: upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
}),
|
||||
);
|
||||
for (const camera of [STILL, STILL_ALIAS]) {
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 404, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'No live media for this camera; use the frame endpoint',
|
||||
});
|
||||
assert.doesNotMatch(res.body, /metadata/);
|
||||
// Being asked on the wrong route is not a camera fault.
|
||||
assert.equal(await app.health(camera.id), undefined);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a still camera reached its upstream');
|
||||
});
|
||||
|
||||
test('an unregistered camera id is refused by /media the same way', async (t) => {
|
||||
const app = mount(t, () => {
|
||||
throw new Error('an unknown camera must not fetch');
|
||||
});
|
||||
const res = await app.call('/media/not-a-camera');
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.equal(app.requests.length, 0);
|
||||
});
|
||||
|
||||
test('a video camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4', 'content-length': '5' },
|
||||
body: 'video',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'video/mp4');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'live-media');
|
||||
assert.equal(res.body, 'video');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[VIDEO.url],
|
||||
);
|
||||
assert.equal((await app.health(VIDEO.id)).status, 'ok');
|
||||
});
|
||||
|
||||
test('an MJPEG camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'multipart/x-mixed-replace; boundary=frame' },
|
||||
body: '--frame',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${MJPEG.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers['Content-Type'], /^multipart\/x-mixed-replace/);
|
||||
assert.equal(res.body, '--frame');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[MJPEG.url],
|
||||
);
|
||||
});
|
||||
|
||||
test('a still camera is still served through /frame with its bounded fetch', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'jpeg-bytes',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(res.body, 'jpeg-bytes');
|
||||
assert.deepEqual(app.requests, [{ url: STILL.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
// The protection /media used to bypass, shown on the route that keeps it.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === STILL.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'private',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.doesNotMatch(res.body, /private/);
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify([row()]);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseRescuCameraList,
|
||||
torontoCameraToSource,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('j'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One RESCU row, shaped like the live `tmcearthcameras.json` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
Number: '8048',
|
||||
Name: 'BATHURST ST & BLOOR ST W',
|
||||
Latitude: '43.665249',
|
||||
Longitude: '-79.41114',
|
||||
D1: 'n',
|
||||
D2: 'e',
|
||||
D3: 's',
|
||||
D4: 'w',
|
||||
Group: 'Arterial',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** The city wraps the list in a JSONP callback; this is that envelope. */
|
||||
const jsonp = (rows) =>
|
||||
`jsonTMCEarthCamerasCallback(${JSON.stringify({ Data: rows })});`;
|
||||
|
||||
test('a RESCU row maps to a source on the constructed frame URL', () => {
|
||||
const source = torontoCameraToSource(row());
|
||||
assert.equal(source.id, 'toronto-8048');
|
||||
assert.equal(source.name, 'BATHURST ST & BLOOR ST W');
|
||||
assert.equal(source.city, 'Toronto');
|
||||
assert.equal(source.cityId, 'toronto');
|
||||
assert.equal(source.provider, 'City of Toronto');
|
||||
assert.equal(source.lat, 43.665249);
|
||||
assert.equal(source.lon, -79.41114);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'toronto-rescu-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Contains information licensed under the Open Government Licence – Toronto',
|
||||
);
|
||||
assert.equal(
|
||||
source.url,
|
||||
`${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`,
|
||||
'the documented loc####.jpg convention',
|
||||
);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(TORONTO_RESCU_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('the frame URL is built from the camera number, never read from the payload', () => {
|
||||
// The list carries no URL field at all, so an added one must stay inert: the
|
||||
// frame proxy is pinned to the city origin by construction.
|
||||
const planted = torontoCameraToSource(
|
||||
row({
|
||||
url: 'https://evil.example/x.jpg',
|
||||
snapshotUrl: 'https://evil.example/x.jpg',
|
||||
Url: 'https://evil.example/x.jpg',
|
||||
}),
|
||||
);
|
||||
assert.equal(planted.url, `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`);
|
||||
assert.equal(planted.snapshotUrl, planted.url);
|
||||
|
||||
// A number that is not plain digits cannot reach the URL builder: path
|
||||
// traversal and absolute URLs are refused outright rather than escaped.
|
||||
for (const Number_ of [
|
||||
'../../etc/passwd',
|
||||
'8048/../../x',
|
||||
'https://evil.example/1',
|
||||
'80 48',
|
||||
'8048.jpg',
|
||||
'-8048',
|
||||
'',
|
||||
' ',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Number: Number_ })),
|
||||
null,
|
||||
`refused: ${String(Number_)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the comparison directions never become a camera heading', () => {
|
||||
// D1-D4 are the city's *comparison image* directions (static 2016 reference
|
||||
// stills served from ComparisonImages/), not a facing for the live frame:
|
||||
// these cameras pan. The letters read like a compass and map trivially onto
|
||||
// one (n -> 0, w -> 270), so the temptation to wire them up is real — and a
|
||||
// heading derived from them would be confidently wrong for every camera in
|
||||
// the city. Nobody may "fix" this.
|
||||
const base = torontoCameraToSource(row());
|
||||
for (const dirs of [
|
||||
{ D1: 'n', D2: 'e', D3: 's', D4: 'w' },
|
||||
{ D1: '', D2: 'e', D3: '', D4: 'w' },
|
||||
{ D1: '', D2: '', D3: '', D4: '' },
|
||||
{ D1: 's', D2: '', D3: '', D4: '' },
|
||||
]) {
|
||||
const source = torontoCameraToSource(row(dirs));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
// Same id, same fallback heading, whatever the directions say.
|
||||
assert.equal(source.headingDeg, base.headingDeg);
|
||||
}
|
||||
});
|
||||
|
||||
test('the two cameras at one intersection are kept apart', () => {
|
||||
// 8047 and 8048 are both "BATHURST ST & BLOOR ST W", five metres apart. They
|
||||
// are distinct units, so both survive, and the id-hash fallback fans their
|
||||
// gizmos apart instead of stacking them on one bearing.
|
||||
const a = torontoCameraToSource(
|
||||
row({ Number: '8047', Latitude: '43.665223', Longitude: '-79.411083' }),
|
||||
);
|
||||
const b = torontoCameraToSource(row());
|
||||
assert.equal(a.id, 'toronto-8047');
|
||||
assert.equal(b.id, 'toronto-8048');
|
||||
assert.notEqual(a.url, b.url);
|
||||
assert.notEqual(a.headingDeg, b.headingDeg);
|
||||
});
|
||||
|
||||
test('rows outside Toronto and unusable geometry are dropped', () => {
|
||||
// Calgary: a plausible lat/lon, but not a Toronto camera.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '51.0453', Longitude: '-114.1413' })),
|
||||
null,
|
||||
);
|
||||
// Null island, and the empty strings that would become it.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '0', Longitude: '0' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '', Longitude: '' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: 'north', Longitude: 'west' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(torontoCameraToSource({}), null);
|
||||
assert.equal(torontoCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
const source = torontoCameraToSource(row({ Name: ' ' }));
|
||||
assert.equal(source.name, 'Toronto Camera 8048');
|
||||
});
|
||||
|
||||
test('the JSONP envelope is unwrapped, and only that envelope', () => {
|
||||
assert.deepEqual(parseRescuCameraList(jsonp([row()])), [row()]);
|
||||
// Whitespace and a missing trailing semicolon are both tolerated.
|
||||
assert.equal(
|
||||
parseRescuCameraList(`\n ${jsonp([row()]).replace(/;$/, '')} \n`).length,
|
||||
1,
|
||||
);
|
||||
// Should the city ever drop the wrapper, the bare document still parses.
|
||||
assert.equal(
|
||||
parseRescuCameraList(JSON.stringify({ Data: [row()] })).length,
|
||||
1,
|
||||
);
|
||||
|
||||
// Anything else yields no cameras rather than a thrown loader.
|
||||
for (const text of [
|
||||
'otherCallback({"Data":[]})',
|
||||
'jsonTMCEarthCamerasCallback(',
|
||||
'jsonTMCEarthCamerasCallback({"Data":',
|
||||
'{"Data":"not-an-array"}',
|
||||
'{"nope":[]}',
|
||||
'<html>503</html>',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.deepEqual(parseRescuCameraList(text), [], `rejected: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the loader reads the keyless list and collapses duplicate numbers', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
jsonp([
|
||||
row(),
|
||||
// Same camera number twice: one camera, not two.
|
||||
row({ Name: 'BATHURST ST & BLOOR ST W (duplicate)' }),
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row({ Number: '../evil' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
const cameras = await loadTorontoSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [TORONTO_RESCU_LIST_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest downtown first: York & Bremner is at the core, Bathurst & Bloor
|
||||
// is three kilometres out.
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
);
|
||||
assert.equal(cameras[1].name, 'BATHURST ST & BLOOR ST W');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A 503 can still arrive with a streaming body; returning without cancelling
|
||||
// it would hold the connection until the socket times out.
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the list fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered. Its body is
|
||||
// a live stream, so the test also proves the refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[TORONTO_RESCU_LIST_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// A body over the cap is refused rather than buffered.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(jsonp([row()]), {
|
||||
headers: {
|
||||
'content-length': String(TORONTO_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only declares its size once it is already too long.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(TORONTO_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(torontoCameraToSource(row()).code, 'BATHURST ST & BLOOR ST W');
|
||||
const long = torontoCameraToSource(
|
||||
row({
|
||||
Name: 'BAYVIEW AVE & POTTERY RD / PRIVATE ACCESS @ 620m SOUTH OF - 550 BAYVIEW AVE (EVERGREEN)',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BAYVIEW AVE & POTTERY'));
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping Toronto', () => {
|
||||
// Toronto merges last in LIVE_PACKS, so a positional slice would delete it
|
||||
// outright. The round-robin allocation must give it its share.
|
||||
const lane = (name, count) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
|
||||
});
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[lane('austin', 250), lane('calgary', 215), lane('toronto', 336)],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['toronto', 10],
|
||||
],
|
||||
);
|
||||
// Toronto contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('toronto-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `toronto-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the RESCU list to the Toronto endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === TORONTO_RESCU_LIST_URL) {
|
||||
return new Response(
|
||||
jsonp([
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the pack is registered, so its cameras reach the served catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_TORONTO_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
'the registered lane reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto').map((s) => s.id),
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
'Toronto cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_TORONTO_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
|
||||
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties: {
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
cameraId: '3000063_1',
|
||||
roadNumber: 'F614',
|
||||
'status.stillImageAvailability': 'videoOrImagesAvailable',
|
||||
orientationDescription: 'Svelgen',
|
||||
description: 'Langesi',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
|
||||
const source = vegvesenCameraToSource(feature());
|
||||
assert.equal(source.id, 'no-vegvesen-3000063_1');
|
||||
assert.equal(source.name, 'F614 Langesi → Svelgen');
|
||||
assert.equal(source.city, 'Norway');
|
||||
assert.equal(source.cityId, 'norway');
|
||||
assert.equal(source.provider, 'Statens vegvesen');
|
||||
assert.equal(source.lat, 61.832706);
|
||||
assert.equal(source.lon, 5.459189);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.sourceKind, 'vegvesen-datex');
|
||||
assert.equal(source.code, 'LANGESI');
|
||||
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('cameras that publish HLS get live video with the still as fallback', (t) => {
|
||||
const video = {
|
||||
videoServiceLevel: 1,
|
||||
videoEncodingStandard: 'hls',
|
||||
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
|
||||
};
|
||||
const source = vegvesenCameraToSource(feature(video));
|
||||
assert.equal(source.feedType, 'hls');
|
||||
assert.equal(source.url, video.videoUrl);
|
||||
assert.equal(
|
||||
source.snapshotUrl,
|
||||
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
);
|
||||
// A manifest anywhere but the camera's own path stays a still.
|
||||
const offPath = vegvesenCameraToSource(
|
||||
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
|
||||
);
|
||||
assert.equal(offPath.feedType, 'image');
|
||||
assert.equal(offPath.url, offPath.snapshotUrl);
|
||||
// The kill switch keeps every camera on stills.
|
||||
const saved = process.env.CCTV_VEGVESEN_VIDEO;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
|
||||
else process.env.CCTV_VEGVESEN_VIDEO = saved;
|
||||
});
|
||||
process.env.CCTV_VEGVESEN_VIDEO = '0';
|
||||
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
|
||||
});
|
||||
|
||||
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
'status.stillImageAvailability':
|
||||
'videoOrImagesUnavailableDueToCameraFault',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame URL must be exactly the camera's own image path.
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
|
||||
// Copenhagen: a plausible coordinate, but outside the Norway box.
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
|
||||
assert.equal(vegvesenCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature(),
|
||||
feature(
|
||||
{
|
||||
cameraId: '0629001_1',
|
||||
stillImageUrl:
|
||||
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
|
||||
description: 'Oslo S',
|
||||
},
|
||||
[10.752, 59.911],
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature()],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_VEGVESEN_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the Vegvesen lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
|
||||
['no-vegvesen-3000063_1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_WSDOT_MAX_SOURCES,
|
||||
WSDOT_CAMERAS_URL,
|
||||
WSDOT_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadWsdotSourcesFromOpenData,
|
||||
webMercatorToWgs84,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */
|
||||
function mercator(lat, lon) {
|
||||
const R = 6378137;
|
||||
return {
|
||||
x: (lon * Math.PI * R) / 180,
|
||||
y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A `Cameras.json` point feature carrying the fields the loader reads. */
|
||||
function wsdotFeature(id, lat, lon, overrides = {}) {
|
||||
const { attributes = {}, geometry } = overrides;
|
||||
return {
|
||||
attributes: {
|
||||
CameraID: id,
|
||||
CameraTitle: `Camera ${id}`,
|
||||
CompassDirection: 'N',
|
||||
ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`,
|
||||
...attributes,
|
||||
},
|
||||
geometry: geometry === undefined ? mercator(lat, lon) : geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */
|
||||
function cp1252Response(payload) {
|
||||
const text = JSON.stringify(payload);
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
// U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships.
|
||||
bytes[i] = code === 0x2013 ? 0x96 : code;
|
||||
}
|
||||
return new Response(bytes, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => {
|
||||
// Real feature from the live layer: I-5 at the Interstate Bridge.
|
||||
const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545);
|
||||
assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`);
|
||||
assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`);
|
||||
});
|
||||
|
||||
test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return cp1252Response({
|
||||
features: [
|
||||
wsdotFeature(1001, 47.6097, -122.3331, {
|
||||
attributes: {
|
||||
// The 0x2013 below is serialized as the feed's 0x96 byte.
|
||||
CameraTitle: 'I-5 – Seattle, looking south',
|
||||
CompassDirection: 'S',
|
||||
},
|
||||
}),
|
||||
wsdotFeature(9059, 47.66, -117.43, {
|
||||
attributes: { CompassDirection: 'B' },
|
||||
}),
|
||||
// Partner-hosted frame (Oregon DOT): skipped, never proxied.
|
||||
wsdotFeature(1002, 45.62, -122.675, {
|
||||
attributes: {
|
||||
ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg',
|
||||
},
|
||||
}),
|
||||
// Bad rows: non-integer id, missing geometry, out-of-state point.
|
||||
wsdotFeature('1003', 47.6, -122.3),
|
||||
wsdotFeature(1004, 47.6, -122.3, { geometry: null }),
|
||||
wsdotFeature(1005, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [WSDOT_CAMERAS_URL]);
|
||||
// Exactly the two valid official-host cameras survive; the partner-hosted
|
||||
// frame and the malformed rows are dropped.
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'wsdot-1001',
|
||||
'wsdot-9059',
|
||||
]);
|
||||
|
||||
const seattle = cameras.find((camera) => camera.id === 'wsdot-1001');
|
||||
assert.equal(seattle.name, 'I-5 – Seattle, looking south');
|
||||
assert.equal(seattle.provider, 'WSDOT');
|
||||
assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg');
|
||||
assert.equal(seattle.snapshotUrl, seattle.url);
|
||||
assert.equal(seattle.feedType, 'image');
|
||||
assert.equal(seattle.headingDeg, 180);
|
||||
assert.equal(seattle.headingConfidence, 'high');
|
||||
assert.equal(seattle.fovDeg, 56);
|
||||
assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001);
|
||||
assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001);
|
||||
|
||||
// `B` (both directions) carries no single facing: id-hash fallback at low
|
||||
// confidence, with the low-confidence pose personality.
|
||||
const spokane = cameras.find((camera) => camera.id === 'wsdot-9059');
|
||||
assert.equal(spokane.headingConfidence, 'low');
|
||||
assert.equal(spokane.fovDeg, 44);
|
||||
assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360);
|
||||
});
|
||||
|
||||
test('WSDOT loader maps every CompassDirection code', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const codes = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
// Lower-case and padded codes are the same code.
|
||||
' nw ': 315,
|
||||
};
|
||||
// The live feed's no-facing codes, plus an unknown one.
|
||||
const noFacing = ['B', 'O', null, 'BW'];
|
||||
const entries = [...Object.keys(codes), ...noFacing];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
cp1252Response({
|
||||
features: entries.map((code, i) =>
|
||||
wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, {
|
||||
attributes: { CompassDirection: code },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
entries.forEach((code, i) => {
|
||||
const camera = byId.get(`wsdot-${3000 + i}`);
|
||||
if (code in codes) {
|
||||
assert.equal(camera.headingDeg, codes[code], `heading for ${code}`);
|
||||
assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 56, `pose personality for ${code}`);
|
||||
} else {
|
||||
assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 44, `pose personality for ${code}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('WSDOT loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05));
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features }));
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('WSDOT loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a redirect and cancels its body', async (t) => {
|
||||
quiet(t);
|
||||
// A redirect is never followed, and its still-open body is cancelled.
|
||||
const seen = [];
|
||||
const state = { cancelled: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/Cameras.json' },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[WSDOT_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(state.cancelled, true, 'the redirect body is cancelled');
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a body over the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
// A valid catalog padded past the cap is refused rather than buffered.
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const text = JSON.stringify({
|
||||
features: [wsdotFeature(1001, 47.6097, -122.3331)],
|
||||
});
|
||||
const padded = text + ' '.repeat(WSDOT_MAX_CATALOG_BYTES);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(padded));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV_WSDOT_ENABLED=0 skips the WSDOT request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_WSDOT_ENABLED: undefined,
|
||||
CCTV_WSDOT_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only WSDOT answers.
|
||||
return String(url) === WSDOT_CAMERAS_URL
|
||||
? cp1252Response({ features: [wsdotFeature(1001, 47.6097, -122.3331)] })
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['wsdot-1001'],
|
||||
);
|
||||
|
||||
process.env.CCTV_WSDOT_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.includes(WSDOT_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
@@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat
|
||||
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
|
||||
});
|
||||
|
||||
test('advisories published before their nominal time are accepted, far-future ones are not', () => {
|
||||
// NHC wrote the 03:00Z advisory at 02:34Z.
|
||||
const early = Date.UTC(2026, 8, 16, 2, 34);
|
||||
assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
|
||||
/invalid_cyclone_data/,
|
||||
);
|
||||
});
|
||||
|
||||
test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
|
||||
const MINUTE = 60_000;
|
||||
const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
|
||||
const fields = {
|
||||
issuedAt: (value) =>
|
||||
status({
|
||||
forecastAdvisory: {
|
||||
advNum: '010',
|
||||
issuance: value,
|
||||
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
},
|
||||
}),
|
||||
positionAt: (value) => status({ lastUpdate: value }),
|
||||
};
|
||||
for (const [field, payload] of Object.entries(fields)) {
|
||||
for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
|
||||
const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
|
||||
assert.equal(storm[field], at(offset), `${field} ${offset}`);
|
||||
}
|
||||
for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(payload(at(offset)), NOW),
|
||||
/invalid_cyclone_data/,
|
||||
`${field} ${offset}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
|
||||
const storms = parseCycloneStatus(status(), NOW);
|
||||
const parts = collections();
|
||||
|
||||
+94
-2
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -104,7 +111,7 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'adsblol',
|
||||
html:
|
||||
'Military flights, aircraft traces & bounded regional flight fallback: ' +
|
||||
'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' +
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
@@ -162,6 +169,13 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'eccc-aqhi',
|
||||
html:
|
||||
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
|
||||
'(<a href="https://eccc-msc.github.io/open-data/" target="_blank" rel="noopener">eccc-msc.github.io/open-data</a>) ' +
|
||||
'· <a href="https://eccc-msc.github.io/open-data/licence/readme_en/" target="_blank" rel="noopener">ECCC Data Servers End-use Licence</a>',
|
||||
},
|
||||
{
|
||||
key: 'overture-military-names',
|
||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||
@@ -234,15 +248,83 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'lithuania-cctv',
|
||||
html:
|
||||
// Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
|
||||
// among the services Via Lietuva's open-data statement lists (its
|
||||
// conditions: attribution, reuse on the same terms).
|
||||
'Road-camera stills (Lithuania, when enabled): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Catalonia): ' +
|
||||
'<a href="https://transit.gencat.cat/" target="_blank" rel="noopener">Servei Català de Trànsit</a> ' +
|
||||
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
|
||||
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
|
||||
},
|
||||
{
|
||||
key: 'kingcounty-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (King County, WA): King County Road Services — ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/local-services/roads" target="_blank" rel="noopener">kingcounty.gov</a>, ' +
|
||||
'per the ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
|
||||
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'toronto-cctv',
|
||||
html:
|
||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
html:
|
||||
'City bikes (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
|
||||
},
|
||||
{
|
||||
key: 'entur-mobility-gbfs',
|
||||
html:
|
||||
'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
|
||||
},
|
||||
{
|
||||
key: 'jcdecaux-gbfs',
|
||||
html:
|
||||
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
key: 'osrm-routing',
|
||||
@@ -321,6 +403,16 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
|
||||
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'wsdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Washington State): ' +
|
||||
'<a href="https://wsdot.wa.gov/" target="_blank" rel="noopener">Washington State Department of Transportation</a> ' +
|
||||
'(<a href="https://wsdot.com/travel/real-time/cameras" target="_blank" rel="noopener">Travel Center</a>), ' +
|
||||
'used per the ' +
|
||||
'<a href="https://wsdot.wa.gov/about/policies/travel-information-disclaimer" target="_blank" rel="noopener">WSDOT travel information disclaimer</a> ' +
|
||||
'(low-volume public feed; catalog cached, frames proxied on demand)',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
+28
-16
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
/**
|
||||
* @module directions
|
||||
* @description Service bundle for the Directions layer.
|
||||
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
|
||||
});
|
||||
|
||||
/** Construct one Directions layer over the application scene owners. */
|
||||
export function createApplicationDirectionsLayer() {
|
||||
return createDirectionsLayer({ services: directionsServices });
|
||||
export function createApplicationDirectionsLayer({
|
||||
source = createDirectionsSource(),
|
||||
} = {}) {
|
||||
return createDirectionsLayer({ services: directionsServices, source });
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { acquisitionMsUtc } from './firmsCsv.js';
|
||||
test('FIRMS acquisition dates cannot roll into a different calendar day', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16junk', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
['2026-07-16', null],
|
||||
['2026-07-16', ''],
|
||||
]) {
|
||||
assert.ok(Number.isNaN(acquisitionMsUtc(date, time)), `${date}:${time}`);
|
||||
}
|
||||
for (const [date, time, expected] of [
|
||||
['2024-02-29', '45', Date.UTC(2024, 1, 29, 0, 45)],
|
||||
['2026-07-16', '0', Date.UTC(2026, 6, 16)],
|
||||
]) {
|
||||
assert.equal(acquisitionMsUtc(date, time), expected);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseFirmsCsv } from './firmsCsv.js';
|
||||
import { adaptFirmsRecords } from './firmsAdapt.js';
|
||||
test('missing positions do not become fire detections at zero coordinates', () => {
|
||||
const csv =
|
||||
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
|
||||
assert.deepEqual(
|
||||
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
|
||||
[
|
||||
[0, 0],
|
||||
[-90, -180],
|
||||
],
|
||||
);
|
||||
const records = [
|
||||
{ lat: null, lon: 1 },
|
||||
{ lat: '', lon: 1 },
|
||||
{ lat: false, lon: 1 },
|
||||
{ lat: 95, lon: 1 },
|
||||
{ lat: 1, lon: 181 },
|
||||
{ lat: '0', lon: 0 },
|
||||
{ lat: -90, lon: -180 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
|
||||
[
|
||||
[0, 0, 0],
|
||||
[1, -90, -180],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('both axes keep their exact bounds and drop values just past them', () => {
|
||||
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
|
||||
const csv = [
|
||||
'latitude,longitude,acq_date,acq_time,confidence,frp',
|
||||
row(90, 180),
|
||||
row(-90, -180),
|
||||
row(90.000001, 0),
|
||||
row(-90.000001, 0),
|
||||
row(0, 180.000001),
|
||||
row(0, -180.000001),
|
||||
row(' ', 5),
|
||||
row(5, '\t'),
|
||||
].join('\n');
|
||||
assert.deepEqual(
|
||||
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
|
||||
[
|
||||
[90, 180],
|
||||
[-90, -180],
|
||||
],
|
||||
);
|
||||
const kept = adaptFirmsRecords([
|
||||
{ lat: 90, lon: 180 },
|
||||
{ lat: '-90', lon: '-180' },
|
||||
{ lat: -90.000001, lon: 0 },
|
||||
{ lat: 0, lon: -180.000001 },
|
||||
{ lat: 5, lon: ' ' },
|
||||
{ lat: 5, lon: undefined },
|
||||
{ lat: [5], lon: 5 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
kept.map((r) => [r.lat, r.lon]),
|
||||
[
|
||||
[90, 180],
|
||||
[-90, -180],
|
||||
],
|
||||
);
|
||||
});
|
||||
+19
-3
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map proxy fire records into internal fire records. Records with
|
||||
* non-finite coordinates are skipped; `index` is the post-skip position
|
||||
* Map proxy fire records into internal fire records. Records with missing,
|
||||
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
|
||||
* (it keys pick ids and context-store ids, so it must stay sequential).
|
||||
* `contextEntity`/`position` start null and are lazily filled by the layer.
|
||||
* @param {?Array<Object>} records - /api/firms `fires` array.
|
||||
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
|
||||
for (const record of records) {
|
||||
const lat = Number(record?.lat);
|
||||
const lon = Number(record?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (
|
||||
!hasCoordinateValue(record?.lat) ||
|
||||
!hasCoordinateValue(record?.lon) ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
)
|
||||
continue;
|
||||
fires.push({
|
||||
index: fires.length,
|
||||
lat,
|
||||
@@ -110,3 +118,11 @@ function finiteNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
|
||||
function hasCoordinateValue(value) {
|
||||
return (
|
||||
typeof value === 'number' ||
|
||||
(typeof value === 'string' && value.trim() !== '')
|
||||
);
|
||||
}
|
||||
+18
-2
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
|
||||
if (parts.length < header.length) continue; // malformed row — skip
|
||||
const lat = Number(parts[iLat]);
|
||||
const lon = Number(parts[iLon]);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Blank or out-of-range positions are dropped, never read as 0 (see
|
||||
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
|
||||
if (
|
||||
!parts[iLat].trim() ||
|
||||
!parts[iLon].trim() ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
)
|
||||
continue;
|
||||
|
||||
records.push({
|
||||
lat,
|
||||
@@ -153,7 +163,13 @@ export function acquisitionMsUtc(acqDate, acqTime) {
|
||||
minutes > 59
|
||||
)
|
||||
return NaN;
|
||||
return Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
return parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ test('firms analyst record: full record maps every contract field', () => {
|
||||
frp: 1520.4,
|
||||
confidence: 0.9,
|
||||
satellite: 'N21',
|
||||
sensor: 'VIIRS',
|
||||
acqTime: 1_753_600_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
+1075
-236
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
GBFS_CLIENT_IDENTIFIER,
|
||||
GBFS_MAX_BODY_BYTES,
|
||||
fetchGbfsUpstream,
|
||||
} from '../../server/providers/gbfs.js';
|
||||
@@ -56,6 +57,41 @@ test('GBFS upstream fetch passes a direct 200 through unchanged', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('GBFS upstream fetch names the app in the Client-Identifier header', async () => {
|
||||
let observedHeaders = null;
|
||||
await fetchGbfsUpstream(
|
||||
'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json',
|
||||
{
|
||||
fetchImpl: async (_url, options) => {
|
||||
observedHeaders = options.headers;
|
||||
return new Response(STATION_BODY, { status: 200 });
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view');
|
||||
assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
|
||||
});
|
||||
|
||||
test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => {
|
||||
const headersFor = async (url) => {
|
||||
let observed = null;
|
||||
await fetchGbfsUpstream(url, {
|
||||
fetchImpl: async (_url, options) => {
|
||||
observed = options.headers;
|
||||
return new Response(STATION_BODY, { status: 200 });
|
||||
},
|
||||
});
|
||||
return observed;
|
||||
};
|
||||
const entur = await headersFor(
|
||||
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
|
||||
);
|
||||
assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare');
|
||||
assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
|
||||
const lyft = await headersFor(STATION_URL);
|
||||
assert.equal(lyft['ET-Client-Name'], undefined);
|
||||
});
|
||||
|
||||
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
|
||||
const result = await fetchGbfsUpstream(STATION_URL, {
|
||||
fetchImpl: async () => new Response(null, { status: 404 }),
|
||||
|
||||
+57
-5
@@ -4,10 +4,35 @@ const GBFS_ALLOWED_HOSTS = new Set([
|
||||
'gbfs.bluebikes.com',
|
||||
'gbfs.bcycle.com',
|
||||
'gbfs.biketownpdx.com',
|
||||
'gbfs.cogobikeshare.com',
|
||||
'reddybikeshare.socialbicycles.com',
|
||||
'austin.publicbikesystem.net',
|
||||
'hon.publicbikesystem.net',
|
||||
'chat.publicbikesystem.net',
|
||||
'gbfs.urbansharing.com',
|
||||
'api.entur.io',
|
||||
'api.cyclocity.fr',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hosts that serve more than GBFS keep the proxy to their GBFS tree: Entur's
|
||||
* api.entur.io fronts every Entur API, and api.cyclocity.fr every JCDecaux
|
||||
* contract endpoint.
|
||||
*/
|
||||
const GBFS_HOST_PATH_PREFIXES = new Map([
|
||||
['api.entur.io', '/mobility/v2/gbfs/'],
|
||||
['api.cyclocity.fr', '/contracts/'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Identifying headers an operator asks every GBFS client to send. Entur rate
|
||||
* limits or blocks consumers that do not send ET-Client-Name, in the form
|
||||
* "company-application".
|
||||
*/
|
||||
const GBFS_HOST_HEADERS = new Map([
|
||||
[
|
||||
'api.entur.io',
|
||||
Object.freeze({ 'ET-Client-Name': 'gods-eye-view-bikeshare' }),
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -28,13 +53,40 @@ export function isAllowedGbfsHost(hostname) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow station_information.json and station_status.json endpoints.
|
||||
* Only allow station_information and station_status endpoints. The `.json`
|
||||
* suffix is optional because Entur serves its feeds without one. When a
|
||||
* hostname is given and that host serves more than GBFS, the path must also
|
||||
* sit under the host's GBFS prefix.
|
||||
*
|
||||
* @param {string} pathname
|
||||
* @param {string} [hostname]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowedGbfsPath(pathname) {
|
||||
return /\/station_(information|status)\.json$/i.test(String(pathname || ''));
|
||||
export function isAllowedGbfsPath(pathname, hostname = '') {
|
||||
const path = String(pathname || '');
|
||||
if (!/\/station_(information|status)(\.json)?$/i.test(path)) return false;
|
||||
const prefix = GBFS_HOST_PATH_PREFIXES.get(
|
||||
String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
return !prefix || path.startsWith(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra request headers an upstream GBFS host asks clients to send.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function gbfsUpstreamHeaders(hostname) {
|
||||
return (
|
||||
GBFS_HOST_HEADERS.get(
|
||||
String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
) || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +99,7 @@ export function isAllowedGbfsPath(pathname) {
|
||||
* @returns {string} Cache-Control header value.
|
||||
*/
|
||||
export function gbfsCacheControl(pathname) {
|
||||
if (/\/station_information\.json$/i.test(String(pathname || ''))) {
|
||||
if (/\/station_information(\.json)?$/i.test(String(pathname || ''))) {
|
||||
return 'public, max-age=300';
|
||||
}
|
||||
return 'no-store';
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
||||
// Inspect encoded headers before allowing the browser to decode local images.
|
||||
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_SIDE = 8192;
|
||||
|
||||
/** Width and height from a supported raster header; no image is decoded. */
|
||||
export function importImageSize(bytes) {
|
||||
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const ascii = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
|
||||
const u24 = (at) => bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
|
||||
if (
|
||||
bytes.length >= 24 &&
|
||||
v.getUint32(0) === 0x89504e47 &&
|
||||
ascii(12, 4) === 'IHDR'
|
||||
)
|
||||
return [v.getUint32(16), v.getUint32(20)];
|
||||
if (bytes.length >= 10 && /^GIF8[79]a$/.test(ascii(0, 6)))
|
||||
return [v.getUint16(6, true), v.getUint16(8, true)];
|
||||
if (bytes.length >= 26 && ascii(0, 2) === 'BM') {
|
||||
if (v.getUint32(14, true) === 12)
|
||||
return [v.getUint16(18, true), v.getUint16(20, true)];
|
||||
return [Math.abs(v.getInt32(18, true)), Math.abs(v.getInt32(22, true))];
|
||||
}
|
||||
if (bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
|
||||
const kind = ascii(12, 4);
|
||||
if (kind === 'VP8X') return [1 + u24(24), 1 + u24(27)];
|
||||
if (kind === 'VP8 ' && u24(23) === 0x2a019d)
|
||||
return [v.getUint16(26, true) & 0x3fff, v.getUint16(28, true) & 0x3fff];
|
||||
if (kind === 'VP8L' && bytes[20] === 0x2f) {
|
||||
const bits = v.getUint32(21, true);
|
||||
return [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1];
|
||||
}
|
||||
}
|
||||
if (bytes.length >= 4 && v.getUint16(0) === 0xffd8) {
|
||||
for (let at = 2; at + 4 <= bytes.length;) {
|
||||
if (bytes[at++] !== 0xff) break;
|
||||
while (bytes[at] === 0xff) at++;
|
||||
const marker = bytes[at++];
|
||||
if (marker === 0xd9 || marker === 0xda) break;
|
||||
if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
||||
const length = v.getUint16(at);
|
||||
if (length < 2 || at + length > bytes.length) break;
|
||||
if (
|
||||
[
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd,
|
||||
0xce, 0xcf,
|
||||
].includes(marker) &&
|
||||
length >= 8
|
||||
)
|
||||
return [v.getUint16(at + 5), v.getUint16(at + 3)];
|
||||
at += length;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Charge each distinct encoded image once against the file's decoded budget. */
|
||||
export function createImportImageBudget() {
|
||||
const seen = new Set();
|
||||
let pixels = 0;
|
||||
return (key, bytes) => {
|
||||
if (seen.has(key)) return;
|
||||
const size = importImageSize(bytes);
|
||||
if (
|
||||
!size ||
|
||||
size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_SIDE) ||
|
||||
pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
|
||||
)
|
||||
throw new Error(
|
||||
'Local images exceed the decoded image limit or have an unreadable header.',
|
||||
);
|
||||
pixels += size[0] * size[1];
|
||||
seen.add(key);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const STYLE = [
|
||||
'image',
|
||||
'scale',
|
||||
'rotation',
|
||||
'alignedAxis',
|
||||
'horizontalOrigin',
|
||||
'verticalOrigin',
|
||||
'color',
|
||||
'eyeOffset',
|
||||
'pixelOffset',
|
||||
'width',
|
||||
'height',
|
||||
'scaleByDistance',
|
||||
'translucencyByDistance',
|
||||
'pixelOffsetScaleByDistance',
|
||||
'distanceDisplayCondition',
|
||||
'sizeInMeters',
|
||||
'disableDepthTestDistance',
|
||||
'imageSubRegion',
|
||||
];
|
||||
|
||||
class ImportedBillboards extends Cesium.BillboardCollection {
|
||||
update(frameState) {
|
||||
// Height queries should find the map, never another imported marker.
|
||||
// Ordinary click picks and rendered screenshots still include the pins.
|
||||
if (frameState.passes.pick && frameState.passes.offscreen) return;
|
||||
super.update(frameState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Own static imported pins without a per-frame Entity visualizer walk. */
|
||||
export function createImportedPins(dataSource, viewer) {
|
||||
const all = dataSource.entities.values.slice();
|
||||
const now = Cesium.JulianDate.now();
|
||||
const pins = all.filter((entity) => entity.billboard && entity.position);
|
||||
if (!pins.length) return { mount() {}, sync() {}, destroy() {} };
|
||||
const collection = new ImportedBillboards({ scene: viewer.scene });
|
||||
const billboards = [];
|
||||
try {
|
||||
for (const entity of pins) {
|
||||
const position = entity.position.getValue(now);
|
||||
if (
|
||||
!position ||
|
||||
![position.x, position.y, position.z].every(Number.isFinite)
|
||||
)
|
||||
throw new Error('Invalid imported point position.');
|
||||
const options = {
|
||||
id: entity,
|
||||
position,
|
||||
show:
|
||||
entity.isShowing && entity.billboard.show?.getValue(now) !== false,
|
||||
heightReference: Cesium.HeightReference.NONE,
|
||||
};
|
||||
for (const key of STYLE) {
|
||||
const value = entity.billboard[key]?.getValue(now);
|
||||
if (value !== undefined) options[key] = value;
|
||||
}
|
||||
billboards.push(collection.add(options));
|
||||
}
|
||||
} catch (error) {
|
||||
collection.destroy();
|
||||
throw error;
|
||||
}
|
||||
// Rebuild once before the source is added to DataSourceDisplay. Removing
|
||||
// one entity at a time would itself make this transition quadratic.
|
||||
dataSource.entities.suspendEvents();
|
||||
try {
|
||||
dataSource.entities.removeAll();
|
||||
for (const entity of all)
|
||||
if (!entity.billboard || !entity.position)
|
||||
dataSource.entities.add(entity);
|
||||
} finally {
|
||||
dataSource.entities.resumeEvents();
|
||||
}
|
||||
Object.defineProperty(dataSource, 'importedEntities', {
|
||||
value: all,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(dataSource, 'importedBillboards', {
|
||||
value: collection,
|
||||
configurable: true,
|
||||
});
|
||||
let show = dataSource.show,
|
||||
mounted = false,
|
||||
destroyed = false;
|
||||
Object.defineProperty(dataSource, 'show', {
|
||||
configurable: true,
|
||||
get: () => show,
|
||||
set(value) {
|
||||
show = Boolean(value);
|
||||
dataSource.entities.show = show;
|
||||
collection.show = show;
|
||||
},
|
||||
});
|
||||
collection.show = show;
|
||||
return {
|
||||
mount() {
|
||||
if (destroyed || mounted) return;
|
||||
viewer.scene.primitives?.add(collection);
|
||||
mounted = true;
|
||||
},
|
||||
sync() {
|
||||
if (destroyed) return;
|
||||
for (let i = 0; i < pins.length; i++) {
|
||||
billboards[i].position = pins[i].position.getValue(now);
|
||||
billboards[i].show =
|
||||
pins[i].isShowing && pins[i].billboard.show?.getValue(now) !== false;
|
||||
}
|
||||
viewer.scene.requestRender?.();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (viewer.trackedEntity && pins.includes(viewer.trackedEntity))
|
||||
viewer.trackedEntity = undefined;
|
||||
if (viewer.selectedEntity && pins.includes(viewer.selectedEntity))
|
||||
viewer.selectedEntity = undefined;
|
||||
if (mounted && !viewer.scene.primitives?.isDestroyed?.())
|
||||
viewer.scene.primitives?.remove(collection);
|
||||
if (!collection.isDestroyed()) collection.destroy();
|
||||
all.length = 0;
|
||||
pins.length = 0;
|
||||
billboards.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
gnssIntegrityAnchor,
|
||||
gnssIntegrityProxy,
|
||||
gnssRetryCooldownMs,
|
||||
} from '../../server/providers/gnssIntegrity.js';
|
||||
|
||||
const snapshot = {
|
||||
now: 4_000_000_000_000,
|
||||
ac: [
|
||||
{
|
||||
hex: '4ca7b5',
|
||||
type: 'adsb_icao',
|
||||
version: 2,
|
||||
alt_baro: 36000,
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 0,
|
||||
nac_p: 0,
|
||||
seen_pos: 1,
|
||||
flight: 'RYR1AB ',
|
||||
r: 'EI-ABC',
|
||||
},
|
||||
{ hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
function install(options = {}, hook = 'configureServer') {
|
||||
let handler;
|
||||
const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options });
|
||||
assert.equal(plugin.name, 'gnss-integrity');
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(path, callback) {
|
||||
assert.equal(path, '/api/gnss-integrity');
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = JSON.parse(body);
|
||||
},
|
||||
};
|
||||
await handler({ url, method, socket: { remoteAddress: peer } }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
test('anchors snap to whole degrees and reject missing or out-of-range values', () => {
|
||||
assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), {
|
||||
lat: 50,
|
||||
lon: 20,
|
||||
});
|
||||
assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null);
|
||||
assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null);
|
||||
assert.equal(gnssIntegrityAnchor('/?lat=0'), null);
|
||||
assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null);
|
||||
});
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => {
|
||||
const calls = [];
|
||||
const request = install(
|
||||
{
|
||||
now: () => 1234,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(url);
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
assert.equal(options.redirect, 'error');
|
||||
return Response.json(snapshot);
|
||||
},
|
||||
},
|
||||
hook,
|
||||
);
|
||||
const res = await request(
|
||||
'/?lat=50.2&lon=19.8&url=https://invalid.example',
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
|
||||
assert.deepEqual(res.body, {
|
||||
fetchedAt: 1234,
|
||||
ageMs: 0,
|
||||
anchor: { lat: 50, lon: 20 },
|
||||
radiusNm: 250,
|
||||
classifier: 'gev-nic-nacp-v2',
|
||||
rows: [
|
||||
{
|
||||
hex: '4ca7b5',
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 0,
|
||||
nacp: 0,
|
||||
gpsLost: false,
|
||||
degraded: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(res.headers['Cache-Control'], 'no-store');
|
||||
});
|
||||
}
|
||||
|
||||
test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => {
|
||||
let clock = 0;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(snapshot);
|
||||
},
|
||||
});
|
||||
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
|
||||
assert.equal(calls, 1);
|
||||
clock = 59_000;
|
||||
const cached = await request();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(cached.body.fetchedAt, 0);
|
||||
assert.equal(
|
||||
cached.body.ageMs,
|
||||
59_000,
|
||||
'a cached answer reports its age on the server clock',
|
||||
);
|
||||
clock = 61_000;
|
||||
await request();
|
||||
assert.equal(calls, 2);
|
||||
await request('/?lat=10&lon=10');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('an upstream failure serves the last snapshot as stale, else 502', async () => {
|
||||
let clock = 0;
|
||||
let fail = false;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () =>
|
||||
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
|
||||
});
|
||||
await request();
|
||||
fail = true;
|
||||
clock = 120_000;
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(
|
||||
stale.body.fetchedAt,
|
||||
0,
|
||||
'a stale answer keeps the time it was observed',
|
||||
);
|
||||
assert.equal(stale.body.ageMs, 120_000);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
const missing = await request('/?lat=-10&lon=-10');
|
||||
assert.equal(missing.status, 502);
|
||||
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
|
||||
});
|
||||
|
||||
test('a stale snapshot older than the layer window is no longer served', async () => {
|
||||
let clock = 0;
|
||||
let fail = false;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () =>
|
||||
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
|
||||
});
|
||||
await request();
|
||||
fail = true;
|
||||
clock = 30 * 60_000;
|
||||
assert.equal((await request()).status, 200);
|
||||
clock = 30 * 60_000 + 1;
|
||||
const expired = await request();
|
||||
assert.equal(expired.status, 502);
|
||||
assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' });
|
||||
});
|
||||
|
||||
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
|
||||
let clock = 0;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'retry-after': '90' },
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.equal(first.status, 429);
|
||||
assert.equal(first.headers['Retry-After'], '90');
|
||||
clock = 30_500;
|
||||
const cooling = await request('/?lat=1&lon=1');
|
||||
assert.equal(cooling.status, 429);
|
||||
// The browser is told the remaining shared cooldown, rounded up.
|
||||
assert.equal(cooling.headers['Retry-After'], '60');
|
||||
assert.equal(calls, 1);
|
||||
clock = 91_000;
|
||||
await request('/?lat=2&lon=2');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => {
|
||||
const nowMs = Date.parse('2026-09-29T12:00:00Z');
|
||||
assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000);
|
||||
assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000);
|
||||
assert.equal(gnssRetryCooldownMs('3600', nowMs), 3_600_000);
|
||||
assert.equal(
|
||||
gnssRetryCooldownMs('Tue, 29 Sep 2026 13:00:00 GMT', nowMs),
|
||||
3_600_000,
|
||||
);
|
||||
assert.equal(gnssRetryCooldownMs('1e309', nowMs), 60_000);
|
||||
assert.equal(
|
||||
gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs),
|
||||
45_000,
|
||||
);
|
||||
// Garbage, a past date or no header fall back to the default minute.
|
||||
assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000);
|
||||
assert.equal(
|
||||
gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs),
|
||||
60_000,
|
||||
);
|
||||
assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000);
|
||||
});
|
||||
|
||||
test('overlapping 429s preserve the longest shared deadline', async () => {
|
||||
let clock = 0,
|
||||
calls = 0;
|
||||
const pending = [];
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: () => {
|
||||
calls += 1;
|
||||
return new Promise((resolve) => pending.push(resolve));
|
||||
},
|
||||
});
|
||||
const long = request('/?lat=1&lon=0'),
|
||||
short = request('/?lat=2&lon=0');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
pending[0](
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '3600' } }),
|
||||
);
|
||||
assert.equal((await long).headers['Retry-After'], '3600');
|
||||
clock = 1000;
|
||||
pending[1](
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '5' } }),
|
||||
);
|
||||
assert.equal((await short).headers['Retry-After'], '3599');
|
||||
clock = 7000;
|
||||
assert.equal((await request('/?lat=3&lon=0')).status, 429);
|
||||
assert.equal(calls, 2);
|
||||
clock = 3_600_001;
|
||||
const resumed = request('/?lat=3&lon=0');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
pending[2](Response.json(snapshot));
|
||||
assert.equal((await resumed).status, 200);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => {
|
||||
let clock = 0;
|
||||
let limited = false;
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return limited
|
||||
? new Response('slow down', { status: 429 })
|
||||
: Response.json(snapshot);
|
||||
},
|
||||
});
|
||||
await request();
|
||||
limited = true;
|
||||
clock = 61_000;
|
||||
// Another anchor trips the shared cooldown…
|
||||
assert.equal((await request('/?lat=5&lon=5')).status, 429);
|
||||
clock = 62_000;
|
||||
// …and the cached anchor is answered from cache without an upstream call.
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.rows.length, 1);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('an oversized upstream body is refused as 502 without being parsed', async () => {
|
||||
const request = install({
|
||||
fetchImpl: async () =>
|
||||
new Response('{}', {
|
||||
headers: { 'content-length': String(9 * 1024 * 1024) },
|
||||
}),
|
||||
});
|
||||
const res = await request();
|
||||
assert.equal(res.status, 502);
|
||||
assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' });
|
||||
});
|
||||
|
||||
test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
|
||||
const request = install({
|
||||
fetchImpl: async () => Response.json({ nope: true }),
|
||||
});
|
||||
assert.equal((await request('/', 'POST')).status, 405);
|
||||
assert.equal((await request('/other?lat=1&lon=1')).status, 404);
|
||||
assert.equal((await request('/?lat=1')).status, 400);
|
||||
assert.equal((await request('/?lat=1&lon=1')).status, 502);
|
||||
const flood = install({ fetchImpl: async () => Response.json(snapshot) });
|
||||
let limited = 0;
|
||||
for (let i = 0; i < 40; i += 1)
|
||||
if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429)
|
||||
limited += 1;
|
||||
assert.ok(limited > 0);
|
||||
});
|
||||
|
||||
test('GERMAN: pathological retry deadlines are bounded', () => {
|
||||
const at = Date.parse('2026-10-14T12:00:00Z');
|
||||
assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000);
|
||||
assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000);
|
||||
assert.equal(
|
||||
gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at),
|
||||
3600000,
|
||||
);
|
||||
assert.equal(gnssRetryCooldownMs('0x10', at), 60000);
|
||||
});
|
||||
|
||||
test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => {
|
||||
for (const fail of [
|
||||
async () => new Response('', { status: 503 }),
|
||||
async () => {
|
||||
throw new DOMException('timeout', 'TimeoutError');
|
||||
},
|
||||
async () => new Response('bad JSON'),
|
||||
]) {
|
||||
let clock = 0,
|
||||
calls = 0;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return fail();
|
||||
},
|
||||
});
|
||||
assert.equal((await request()).status, 502);
|
||||
clock = 14000;
|
||||
assert.equal((await request('/?lat=1&lon=1')).status, 502);
|
||||
assert.equal(calls, 1);
|
||||
clock = 15001;
|
||||
await request('/?lat=2&lon=2');
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => {
|
||||
const now = 1800000000000;
|
||||
const request = install({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
Response.json({ ...snapshot, now: (now - 120000) / 1000 }),
|
||||
});
|
||||
const res = await request();
|
||||
assert.equal(res.body.ageMs, 120000);
|
||||
assert.equal(res.body.stale, true);
|
||||
for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) {
|
||||
const missing = install({
|
||||
now: () => now,
|
||||
fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }),
|
||||
});
|
||||
assert.equal((await missing()).status, 502);
|
||||
}
|
||||
});
|
||||
|
||||
test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => {
|
||||
let wall = 1800000000000,
|
||||
mono = 0,
|
||||
calls = 0;
|
||||
const request = install({
|
||||
now: () => wall,
|
||||
monotonicNow: () => mono,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return calls === 1
|
||||
? Response.json({ ...snapshot, now: wall / 1000 })
|
||||
: new Response('', { status: 429, headers: { 'Retry-After': '60' } });
|
||||
},
|
||||
});
|
||||
await request();
|
||||
wall -= 3600000;
|
||||
mono = 61000;
|
||||
const stale = await request();
|
||||
assert.equal(stale.body.ageMs, 61000);
|
||||
assert.equal(stale.body.stale, true);
|
||||
mono += 1000;
|
||||
assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
now: () => 1800000000000,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(snapshot);
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200);
|
||||
assert.equal((await request('/?lat=1&lon=1')).status, 200);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('GERMAN: distinct concurrent upstream reads are bounded', async () => {
|
||||
const pending = [];
|
||||
const request = install({
|
||||
fetchImpl: () => new Promise((resolve) => pending.push(resolve)),
|
||||
});
|
||||
const jobs = Array.from({ length: 30 }, (_, i) =>
|
||||
request(`/?lat=${i}&lon=1`),
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(pending.length <= 8);
|
||||
pending.forEach((resolve) => resolve(Response.json(snapshot)));
|
||||
const results = await Promise.all(jobs);
|
||||
assert.ok(results.some((result) => result.status === 429));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const MAX_SWITCHES_LENGTH = 256;
|
||||
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
|
||||
|
||||
/** Canonical, bounded boolean switches keyed by stable slugs. */
|
||||
export function normalizeIdSwitches(value, reservedIds = []) {
|
||||
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
|
||||
return null;
|
||||
if (!value) return '';
|
||||
const entries = new Map();
|
||||
for (const field of value.split('*')) {
|
||||
const match = ENTRY.exec(field);
|
||||
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
|
||||
return null;
|
||||
entries.set(match[1], match[2] === '1');
|
||||
}
|
||||
return [...entries]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
|
||||
.join('*');
|
||||
}
|
||||
|
||||
/** Encode by stable IDs rather than registration order or generated tokens. */
|
||||
export function encodeIdSwitches(switches) {
|
||||
const value = [...switches]
|
||||
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
|
||||
.join('*');
|
||||
if (value.length > MAX_SWITCHES_LENGTH)
|
||||
throw new RangeError(
|
||||
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
|
||||
);
|
||||
const normalized = normalizeIdSwitches(value);
|
||||
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** A Map keeps switch IDs separate from object prototype properties. */
|
||||
export function decodeIdSwitches(value) {
|
||||
const normalized = normalizeIdSwitches(value);
|
||||
return new Map(
|
||||
normalized
|
||||
? normalized.split('*').map((entry) => {
|
||||
const [, id, on] = ENTRY.exec(entry);
|
||||
return [id, on === '1'];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
|
||||
export function patchRequestedIdSwitches(previous, applied, requested) {
|
||||
const current = decodeIdSwitches(applied);
|
||||
const next = new Map(
|
||||
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
|
||||
);
|
||||
let changed = false;
|
||||
for (const [id, on] of current) {
|
||||
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
|
||||
next.set(id, on);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? encodeIdSwitches(next) : undefined;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
validMilitaryInstallationBox,
|
||||
writeMilitaryInstallationDisk,
|
||||
} from '../../vite.config.js';
|
||||
import { resolveOverpassUpstreamTag } from '../../server/providers/overpass/constants.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
test('installation failure reasons disclose no raw upstream error and do not guess overload', () => {
|
||||
@@ -274,3 +275,30 @@ test('an unwritable cache directory degrades to a miss instead of throwing', asy
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an installation entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => {
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
});
|
||||
const dir = tempCacheDir();
|
||||
const key = '30.250,-97.750,30.300,-97.700';
|
||||
try {
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://a.example/api/interpreter,https://b.example/api/interpreter';
|
||||
const tag = resolveOverpassUpstreamTag();
|
||||
await writeMilitaryInstallationDisk(key, { ...entry(Date.now()), upstreams: tag }, dir);
|
||||
assert.ok(await readMilitaryInstallationDisk(key, TTL_MS, dir), 'its own entry is a hit');
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://b.example/api/interpreter,https://a.example/api/interpreter';
|
||||
assert.equal(resolveOverpassUpstreamTag(), tag, 'reordering the same instances keeps the tag');
|
||||
process.env.OVERPASS_UPSTREAMS = 'https://c.example/api/interpreter';
|
||||
assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null, 'fresh read refused');
|
||||
assert.equal(await readMilitaryInstallationDisk(key, Infinity, dir), null, 'stale read refused');
|
||||
await writeMilitaryInstallationDisk('legacy', entry(Date.now()), dir);
|
||||
assert.equal(await readMilitaryInstallationDisk('legacy', Infinity, dir), null, 'untagged entry refused');
|
||||
delete process.env.OVERPASS_UPSTREAMS;
|
||||
assert.ok(await readMilitaryInstallationDisk(key, Infinity, dir), 'nothing configured accepts any entry');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+57
-4
@@ -1,3 +1,4 @@
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
@@ -295,6 +296,24 @@ function imageryPinOption(key, token) {
|
||||
});
|
||||
}
|
||||
|
||||
function satelliteFilterOption() {
|
||||
const percentage = boundedIntegerOption('infrared', 'i', 100, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
});
|
||||
return Object.freeze({
|
||||
...percentage,
|
||||
normalize: (value) =>
|
||||
value === 'filtered'
|
||||
? 100
|
||||
: value === 'full'
|
||||
? 0
|
||||
: percentage.normalize(value),
|
||||
decode: (value) =>
|
||||
value === 'f' ? 100 : value === 'a' ? 0 : percentage.decode(value),
|
||||
});
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
traffic: Object.freeze([
|
||||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||||
@@ -316,10 +335,7 @@ const OPTION_GROUPS = Object.freeze({
|
||||
}),
|
||||
]),
|
||||
'weather-satellite': Object.freeze([
|
||||
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
|
||||
filtered: 'f',
|
||||
full: 'a',
|
||||
}),
|
||||
satelliteFilterOption(),
|
||||
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
|
||||
light: 'l',
|
||||
strong: 's',
|
||||
@@ -410,6 +426,27 @@ const OPTION_GROUPS = Object.freeze({
|
||||
),
|
||||
booleanOption('viirs', 'v', false),
|
||||
]),
|
||||
'street-level': Object.freeze([
|
||||
// Preserve the published Mapillary token; custom switches use stable IDs.
|
||||
booleanOption('mapillary', 'm', true),
|
||||
Object.freeze({
|
||||
key: 'providerSwitches',
|
||||
patchRequested: patchRequestedIdSwitches,
|
||||
token: 'r',
|
||||
defaultValue: '',
|
||||
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||
encode: normalizeIdSwitches,
|
||||
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||
}),
|
||||
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||
all: 'a',
|
||||
pano: 'p',
|
||||
flat: 'f',
|
||||
}),
|
||||
// Relative days, so a link means the same next year. The max matches
|
||||
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
|
||||
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||||
]),
|
||||
radio: Object.freeze([
|
||||
Object.freeze({
|
||||
key: 'filter',
|
||||
@@ -545,6 +582,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS =
|
||||
* owns stable URL ordering.
|
||||
*/
|
||||
export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'air-quality',
|
||||
token: '4',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ais-live-vessels',
|
||||
token: 'a',
|
||||
@@ -585,6 +627,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'flights',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'gnss-integrity',
|
||||
token: '3',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'local-datacenters',
|
||||
@@ -631,6 +678,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'satellites',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
token: '0',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'street-level',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'telegeography-submarine-cables',
|
||||
token: 'u',
|
||||
|
||||
+251
-35
@@ -1,3 +1,4 @@
|
||||
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
||||
import radioLayer from './radio.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
|
||||
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -199,8 +202,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 28);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 31);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -239,15 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '0');
|
||||
assert.equal(nextLayerStateToken(), '5');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
|
||||
'4',
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '5',
|
||||
bravo: '6',
|
||||
}),
|
||||
'7',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'03456789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
@@ -274,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
[
|
||||
...'0123456789',
|
||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
|
||||
[...LAYER_STATE_TOKEN_ALPHABET].map(
|
||||
(second) => `${first}${second}`,
|
||||
),
|
||||
),
|
||||
].map((token, index) => [`occupied-${index}`, token]),
|
||||
),
|
||||
@@ -296,42 +305,46 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' },
|
||||
),
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '5',
|
||||
next: '6',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 0/,
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 5/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
beforeLastDigit,
|
||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
||||
),
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
futurePair: '00',
|
||||
futureDigit: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
digitsExhausted,
|
||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
||||
),
|
||||
validateLayerStateAllocations(digitsExhausted, {
|
||||
...digitsExhausted,
|
||||
pairB: '01',
|
||||
pairA: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
|
||||
),
|
||||
/next free token 3/,
|
||||
/next free token 6/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
@@ -2409,23 +2422,74 @@ test('wind appearance shares round trip while old links retain weather defaults'
|
||||
assert.deepEqual(invalid.options.wind, defaults);
|
||||
});
|
||||
|
||||
test('observed weather round trips product and opacity without persisting historical playback', () => {
|
||||
test('observed weather round trips product and presets without persisting historical playback', () => {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-radar', 'weather-satellite'],
|
||||
options: {
|
||||
'weather-radar': { opacity: 'light', play: true },
|
||||
'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 },
|
||||
'weather-satellite': {
|
||||
product: 'clouds',
|
||||
opacity: 'light',
|
||||
infrared: 'filtered',
|
||||
step: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const params = new URLSearchParams(encode(state));
|
||||
const decoded = decodeLayerStateParams(params);
|
||||
assert.deepEqual(decoded, state);
|
||||
assert.equal(state.options['weather-satellite'].product, 'clouds');
|
||||
assert.equal(state.options['weather-satellite'].opacity, 'light');
|
||||
assert.equal(state.options['weather-satellite'].infrared, 100);
|
||||
assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false);
|
||||
});
|
||||
|
||||
test('satellite infrared display mode round trips and invalid or absent values use filtered', () => {
|
||||
for (const infrared of ['full', 'filtered', undefined, 'invalid']) {
|
||||
test('satellite filter strength percentages round-trip and legacy mode codes remain readable', () => {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: { 'weather-satellite': { infrared: 53, opacity: 'light' } },
|
||||
});
|
||||
const params = encodeLayerStateParams(
|
||||
new URLSearchParams([['v', '2']]),
|
||||
state,
|
||||
);
|
||||
assert.deepEqual(decodeLayerStateParams(params), state);
|
||||
assert.equal(state.options['weather-satellite'].infrared, 53);
|
||||
assert.equal(state.options['weather-satellite'].opacity, 'light');
|
||||
for (const [legacy, expected] of [
|
||||
['f', 100],
|
||||
['a', 0],
|
||||
]) {
|
||||
const decoded = decodeLayerStateParams(
|
||||
new URLSearchParams(`v=2&l=o&lo=o.i.${legacy}`),
|
||||
);
|
||||
assert.equal(decoded.options['weather-satellite'].infrared, expected);
|
||||
}
|
||||
for (const [legacy, expected] of [
|
||||
['filtered', 100],
|
||||
['full', 0],
|
||||
]) {
|
||||
const normalized = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: { 'weather-satellite': { infrared: legacy } },
|
||||
});
|
||||
assert.equal(normalized.options['weather-satellite'].infrared, expected);
|
||||
}
|
||||
const invalid = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: { 'weather-satellite': { infrared: 101 } },
|
||||
});
|
||||
assert.equal(invalid.options['weather-satellite'].infrared, 100);
|
||||
});
|
||||
|
||||
test('satellite cloud filter round trips and invalid or absent values default to Clouds only', () => {
|
||||
for (const [infrared, expected] of [
|
||||
['full', 0],
|
||||
['filtered', 100],
|
||||
[53, 53],
|
||||
[undefined, 100],
|
||||
['invalid', 100],
|
||||
]) {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['weather-satellite'],
|
||||
options: {
|
||||
@@ -2441,10 +2505,7 @@ test('satellite infrared display mode round trips and invalid or absent values u
|
||||
decodeLayerStateParams(new URLSearchParams(encode(state))),
|
||||
state,
|
||||
);
|
||||
assert.equal(
|
||||
state.options['weather-satellite'].infrared,
|
||||
infrared === 'full' ? 'full' : 'filtered',
|
||||
);
|
||||
assert.equal(state.options['weather-satellite'].infrared, expected);
|
||||
assert.equal(
|
||||
Object.hasOwn(state.options['weather-satellite'], 'step'),
|
||||
false,
|
||||
@@ -2626,6 +2687,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
);
|
||||
});
|
||||
|
||||
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
'recent-imagery',
|
||||
]);
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token,
|
||||
'3',
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
|
||||
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
@@ -2642,3 +2720,141 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
|
||||
/** Street Level options decoded from a v2 link with these `lo` assignments. */
|
||||
function streetLevelOptions(lo) {
|
||||
const params = new URLSearchParams([
|
||||
['v', '2'],
|
||||
['l', '0'],
|
||||
]);
|
||||
if (lo) params.set('lo', lo);
|
||||
return decodeLayerStateParams(params).options['street-level'];
|
||||
}
|
||||
|
||||
test('Street Level: every option round-trips through a share link and stored state', () => {
|
||||
const state = createDefaultLayerState();
|
||||
state.enabledLayerIds = ['street-level'];
|
||||
state.options['street-level'] = {
|
||||
mapillary: false,
|
||||
providerSwitches: '',
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
};
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||
assert.deepEqual(decoded.options['street-level'], {
|
||||
mapillary: false,
|
||||
providerSwitches: '',
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseStoredLayerState(serializeStoredLayerState(state)).options[
|
||||
'street-level'
|
||||
],
|
||||
state.options['street-level'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
|
||||
// A window past MAX_SINCE_DAYS reads as "any date".
|
||||
assert.equal(
|
||||
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
|
||||
MAX_SINCE_DAYS,
|
||||
);
|
||||
assert.equal(streetLevelOptions(`0.s.${MAX_SINCE_DAYS + 1}`).sinceDays, 0);
|
||||
// Every SINCE slider stop survives, the LAST 10 YEARS one included.
|
||||
for (const { days } of SINCE_STOPS)
|
||||
assert.equal(streetLevelOptions(`0.s.${days}`).sinceDays, days);
|
||||
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||
});
|
||||
|
||||
test('Street Level: a link without the provider switches keeps Mapillary on and Street View off', () => {
|
||||
assert.deepEqual(streetLevelOptions(''), {
|
||||
mapillary: true,
|
||||
providerSwitches: '',
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
// Street View rides the shared provider-switch field, off unless lit.
|
||||
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
|
||||
assert.equal(
|
||||
createDefaultLayerState().options['street-level'].providerSwitches,
|
||||
'',
|
||||
);
|
||||
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
|
||||
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
|
||||
assert.equal(
|
||||
createDefaultLayerState().options['street-level'].mapillary,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
|
||||
const manager = new DataLayerManager({});
|
||||
const providers = new Map([
|
||||
['mapillary', true],
|
||||
['alpha', true],
|
||||
['beta', true],
|
||||
]);
|
||||
const filter = { pano: 'all', sinceDays: 0 };
|
||||
for (const id of REGISTERED_LAYER_IDS) {
|
||||
manager.register(
|
||||
id === 'street-level'
|
||||
? {
|
||||
...fakeLayer(id),
|
||||
getParams: () => encodeParams({ providers, filter }),
|
||||
setParams(params) {
|
||||
const decoded = decodeParams(params, {
|
||||
providerIds: providers.keys(),
|
||||
filter,
|
||||
});
|
||||
for (const [id, on] of decoded.providers) providers.set(id, on);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
: fakeLayer(id),
|
||||
);
|
||||
}
|
||||
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||
const storage = memoryStorage();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
storage,
|
||||
});
|
||||
try {
|
||||
providers.set('beta', false); // Passive state does not belong to the next action.
|
||||
assert.equal(
|
||||
manager.setLayerParams(
|
||||
'street-level',
|
||||
{ alpha: false },
|
||||
{ origin: 'user' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
|
||||
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
|
||||
const restored = decodeParams(shared.options['street-level'], {
|
||||
providerIds: ['beta', 'alpha'],
|
||||
filter,
|
||||
});
|
||||
assert.deepEqual([...restored.providers], [['alpha', false]]);
|
||||
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
|
||||
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||
assert.equal(
|
||||
saved.options['street-level'].providerSwitches,
|
||||
'alpha-0*beta-1',
|
||||
);
|
||||
manager.setLayerParams(
|
||||
'street-level',
|
||||
{ alpha: true },
|
||||
{ origin: 'local-restore' },
|
||||
);
|
||||
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||
assert.equal(
|
||||
saved.options['street-level'].providerSwitches,
|
||||
'alpha-0*beta-1',
|
||||
);
|
||||
} finally {
|
||||
coordinator.destroy();
|
||||
}
|
||||
});
|
||||
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
|
||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||
const implicitTrackingSync =
|
||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
||||
const value = spec.normalize(change.params[spec.key]);
|
||||
// Packed options can persist only the subkeys owned by this request.
|
||||
const requestedValue =
|
||||
explicitlyRequested || implicitTrackingSync
|
||||
? change.params[spec.key]
|
||||
: spec.patchRequested?.(
|
||||
nextOwnerOptions[spec.key],
|
||||
change.params[spec.key],
|
||||
requestedParams,
|
||||
);
|
||||
if (
|
||||
!explicitlyRequested &&
|
||||
!implicitTrackingSync &&
|
||||
requestedValue === undefined
|
||||
)
|
||||
continue;
|
||||
const value = spec.normalize(requestedValue);
|
||||
if (value === null) {
|
||||
if (implicitTrackingSync) {
|
||||
nextOwnerOptions[spec.key] = null;
|
||||
|
||||
@@ -98,6 +98,12 @@ function withPublishedBase(
|
||||
}
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
writeFileSync(
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
@@ -122,13 +128,23 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{ ...LEGACY_LAYER_STATE_TOKENS },
|
||||
{
|
||||
...LEGACY_LAYER_STATE_TOKENS,
|
||||
'street-level': '0',
|
||||
'gnss-integrity': '3',
|
||||
'air-quality': '4',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
|
||||
() =>
|
||||
parseLayerStateTokenReservations(
|
||||
// Drop a legacy row by id; the first row sorts as air-quality, which
|
||||
// is not a legacy reservation.
|
||||
reservationRows.filter(([id]) => id !== 'ais-live-vessels'),
|
||||
),
|
||||
/Missing or changed legacy/,
|
||||
);
|
||||
assert.throws(
|
||||
@@ -145,9 +161,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
|
||||
.future,
|
||||
'0',
|
||||
'5',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -165,14 +181,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '0']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '5']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '3',
|
||||
newcomer: '6',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -180,15 +196,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '0',
|
||||
competing: '5',
|
||||
}),
|
||||
/next free token 3/,
|
||||
/next free token 6/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '0',
|
||||
newcomer: '5',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -196,8 +212,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -218,8 +234,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '0']],
|
||||
[{ id: 'pr-b', token: '0' }],
|
||||
[...reservationRows, ['pr-b', '5']],
|
||||
[{ id: 'pr-b', token: '5' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -227,10 +243,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
|
||||
[
|
||||
{ id: 'pr-a', token: '0' },
|
||||
{ id: 'pr-b', token: '0' },
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '5' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -238,16 +254,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '3');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 3/);
|
||||
assert.match(helper.stdout, /pr-b: 6/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -255,20 +271,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
|
||||
[
|
||||
{ id: 'pr-a', token: '0' },
|
||||
{ id: 'pr-b', token: '3' },
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /29 published, 1 new/);
|
||||
assert.match(corrected.stdout, /32 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -320,7 +336,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
|
||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||
try {
|
||||
const priorDigits = [...'03456789'].map((digit) => [
|
||||
const priorDigits = [...'56789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -357,7 +373,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
codec.encodeLayerStateParams(params, state);
|
||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||
assert.equal(params.get('l').length, 67);
|
||||
assert.equal(params.get('l').length, 73);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -375,7 +391,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 29);
|
||||
assert.equal(Object.keys(published).length, 32);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
["air-quality", "4"],
|
||||
["ais-live-vessels", "a"],
|
||||
["alpr-cameras", "p"],
|
||||
["bhote-koshi-2026", "h"],
|
||||
@@ -9,6 +10,7 @@
|
||||
["earthquakes", "e"],
|
||||
["fire-perimeters", "2"],
|
||||
["flights", "f"],
|
||||
["gnss-integrity", "3"],
|
||||
["local-dams", "q"],
|
||||
["local-datacenters", "d"],
|
||||
["local-firms", "w"],
|
||||
@@ -19,6 +21,7 @@
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["satellites", "s"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
["traffic", "t"],
|
||||
["transit", "j"],
|
||||
|
||||
+23
-1
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
*/
|
||||
export class LayerLifecycle {
|
||||
constructor(viewer, { allowQaRegistration = false } = {}) {
|
||||
constructor(
|
||||
viewer,
|
||||
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||
) {
|
||||
this._catalogSourceAvailability = getSourceAvailability;
|
||||
this.viewer = viewer;
|
||||
this._activityListeners = new Set();
|
||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||
@@ -228,6 +232,7 @@ export class LayerLifecycle {
|
||||
|
||||
_normalizedStats(entry) {
|
||||
const moduleStats = this._moduleStats(entry);
|
||||
const availability = this._sourceAvailability(entry.module.id);
|
||||
const lifecycleLoading =
|
||||
entry.lifecycleState === 'enabling' ||
|
||||
entry.lifecycleState === 'disabling';
|
||||
@@ -235,6 +240,13 @@ export class LayerLifecycle {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
...moduleStats,
|
||||
...(availability?.available === false
|
||||
? {
|
||||
status: 'unavailable',
|
||||
sourceUnavailable: true,
|
||||
error: availability.reason,
|
||||
}
|
||||
: {}),
|
||||
loading: lifecycleLoading || moduleStats.loading === true,
|
||||
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
||||
managerRefreshError: entry.managerRefreshError,
|
||||
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
_sourceAvailability(layerId) {
|
||||
return (
|
||||
this._catalogSourceAvailability?.(layerId) ??
|
||||
this.layers.get(layerId)?.module.getSourceAvailability?.()
|
||||
);
|
||||
}
|
||||
|
||||
async _visibilityBlockReason(change) {
|
||||
const availability = this._sourceAvailability(change.layerId);
|
||||
if (change.enabled && availability?.available === false)
|
||||
return availability.reason || 'Data source unavailable';
|
||||
for (const callback of this._visibilityGuards) {
|
||||
try {
|
||||
const result = await callback(change);
|
||||
|
||||
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
|
||||
removeGuard();
|
||||
// The newest intent must be able to abort the hung compensation and run.
|
||||
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
|
||||
let giveUpTimer;
|
||||
const finalOn = await Promise.race([
|
||||
pendingFinalOn,
|
||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
||||
]);
|
||||
new Promise((resolve) => {
|
||||
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(giveUpTimer));
|
||||
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
|
||||
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
|
||||
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
|
||||
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
|
||||
releaseFirstDisable();
|
||||
|
||||
const blockedOn = await pendingBlockedOn;
|
||||
let giveUpTimer;
|
||||
const reentrant = await Promise.race([
|
||||
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
|
||||
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
|
||||
]);
|
||||
new Promise((resolve) => {
|
||||
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(giveUpTimer));
|
||||
await pendingOff;
|
||||
await mgr.waitForLayerSettled('flights');
|
||||
unsubscribe();
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
formatAwarenessLabel,
|
||||
getAwarenessNavigationTargets,
|
||||
} from './militaryAwarenessEngine.js';
|
||||
import { formatAwarenessCount } from '../layers/awareness/panel.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
|
||||
'military-installations',
|
||||
];
|
||||
|
||||
test('Contacts labels a capped cohort as a lower bound', () => {
|
||||
assert.equal(
|
||||
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
|
||||
'At least 20,000',
|
||||
);
|
||||
assert.equal(
|
||||
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
|
||||
'19,999',
|
||||
);
|
||||
assert.equal(formatAwarenessCount({ count: null }), '?');
|
||||
});
|
||||
|
||||
function installAwarenessRuntime({
|
||||
isEnabled = () => true,
|
||||
isEffectivelyEnabled = isEnabled,
|
||||
@@ -2554,6 +2567,7 @@ test('the contacts window reports exactly the counts the panel renders', () => {
|
||||
assert.equal(window.flights, 42);
|
||||
assert.equal(window.military, 13);
|
||||
assert.equal(window.aircraft, 55);
|
||||
assert.equal(window.complete, true);
|
||||
assert.equal(window.centeredOn, 'ASA635');
|
||||
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
|
||||
// A feed that cannot answer says so rather than reporting a confident zero.
|
||||
@@ -2561,6 +2575,57 @@ test('the contacts window reports exactly the counts the panel renders', () => {
|
||||
assert.equal(window.vessels, 'unknown');
|
||||
});
|
||||
|
||||
test('the voice query snapshot is a detached copy of the rendered aircraft cohort', async () => {
|
||||
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
|
||||
const nearbyPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1_500);
|
||||
const restoreCollections = stubAwarenessCollections({
|
||||
flights: [
|
||||
{
|
||||
icao24: 'subject',
|
||||
id: 'SUBJECT',
|
||||
position: subjectPosition,
|
||||
distance: 0,
|
||||
},
|
||||
{
|
||||
icao24: 'nearby',
|
||||
id: 'NEARBY',
|
||||
position: nearbyPosition,
|
||||
distance: 5_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtime = installAwarenessRuntime();
|
||||
try {
|
||||
runtime.dispatch(
|
||||
'gev:awareness-subject-selected',
|
||||
awarenessSubject('flights', 'subject', subjectPosition),
|
||||
);
|
||||
await nextTurn();
|
||||
const first = militaryAwarenessLayer.getAircraftQuerySnapshot();
|
||||
assert.equal(first.cohorts.flights.count, 1);
|
||||
assert.equal(first.cohorts.flights.complete, true);
|
||||
assert.equal(first.cohorts.flights.provenance.id, 'flights');
|
||||
assert.equal(first.cohorts.flights.provenance.enabled, true);
|
||||
assert.equal(first.cohorts.flights.items[0].icao24, 'nearby');
|
||||
first.cohorts.flights.items[0].icao24 = 'mutated';
|
||||
first.cohorts.flights.items[0].position.x = 99;
|
||||
first.cohorts.flights.provenance.source = 'mutated';
|
||||
first.cohorts.flights.provenance.stats.coverage = { radiusM: 1 };
|
||||
first.subject.position.x = 77;
|
||||
const second = militaryAwarenessLayer.getAircraftQuerySnapshot();
|
||||
assert.equal(second.cohorts.flights.items[0].icao24, 'nearby');
|
||||
assert.equal(second.cohorts.flights.items[0].position.x, nearbyPosition.x);
|
||||
assert.notEqual(second.cohorts.flights.provenance.source, 'mutated');
|
||||
assert.notDeepEqual(second.cohorts.flights.provenance.stats.coverage, {
|
||||
radiusM: 1,
|
||||
});
|
||||
assert.equal(second.subject.position.x, subjectPosition.x);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
restoreCollections();
|
||||
}
|
||||
});
|
||||
|
||||
test('there is no contacts window without a subject', () => {
|
||||
assert.equal(contactsWindowFromSnapshot(null), null);
|
||||
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), null);
|
||||
|
||||
@@ -71,6 +71,12 @@ test('military analyst record: full record maps every contract field', () => {
|
||||
aircraftClass: 'widebody',
|
||||
originCountry: null,
|
||||
operator: 'United States Air Force',
|
||||
squawk: null,
|
||||
emergency: false,
|
||||
emergencyKind: null,
|
||||
emergencySource: null,
|
||||
emergencyBroadcast: null,
|
||||
lastContactAtMs: null,
|
||||
routeOrigin: null,
|
||||
routeDestination: null,
|
||||
});
|
||||
|
||||
@@ -65,6 +65,23 @@ export function unregisterPickOwner(layerId) {
|
||||
_owners.delete(layerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered layer that owns a picked id, or null.
|
||||
* @param {string} pickedId - Canonical pick id from {@link resolvePickId}.
|
||||
* @returns {string|null} Owning layer id.
|
||||
*/
|
||||
export function findPickOwner(pickedId) {
|
||||
if (!pickedId) return null;
|
||||
for (const [ownerId, predicate] of _owners) {
|
||||
try {
|
||||
if (predicate(pickedId)) return ownerId;
|
||||
} catch {
|
||||
// a broken predicate must never break picking
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when some OTHER registered layer owns the picked id.
|
||||
* @param {string} layerId - The asking layer's id (excluded from the scan).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getContextStore, isContextRecordActive } from './contextStore.js';
|
||||
import { findPickOwner, resolvePickId } from './pickRegistry.js';
|
||||
|
||||
/**
|
||||
* Resolve a `scene.pick()` result to the identity a person would name: which
|
||||
* layer, which record, and its display label.
|
||||
*
|
||||
* Two lanes, in order:
|
||||
* 1. Context-store carriers. Entity-backed layers (infrastructure GeoJSON,
|
||||
* selected vessels, fires, installations) stamp `__gevContextId` on the
|
||||
* picked object, and the store record already carries the label and
|
||||
* position voice uses everywhere else.
|
||||
* 2. Pick owners. Primitive-backed layers (aircraft, satellites, vessels)
|
||||
* register an ownership predicate in `pickRegistry`; the owning layer's
|
||||
* `findByQuery` turns the pick id into a live descriptor.
|
||||
*
|
||||
* Trails claim their pick ids only to make clicks inert, so they never
|
||||
* resolve. Disabled layers never resolve: what the user cannot see is not
|
||||
* what they are pointing at.
|
||||
*/
|
||||
|
||||
const KIND_BY_LAYER = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
military: 'aircraft',
|
||||
'local-adsb': 'aircraft',
|
||||
'ais-live-vessels': 'vessel',
|
||||
satellites: 'satellite',
|
||||
'local-firms': 'fire',
|
||||
});
|
||||
|
||||
const IGNORED_OWNERS = new Set(['trails']);
|
||||
|
||||
function text(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function finite(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
/** Kind of thing a layer holds, for deixis ("this aircraft", "this place"). */
|
||||
export function pickedKindForLayer(layerId) {
|
||||
return KIND_BY_LAYER[layerId] || 'feature';
|
||||
}
|
||||
|
||||
function contextCarrierRecord(picked, dataManager) {
|
||||
const store = getContextStore();
|
||||
for (const carrier of [
|
||||
picked?.id,
|
||||
picked?.primitive?.id,
|
||||
picked?.primitive,
|
||||
]) {
|
||||
const contextId =
|
||||
carrier && typeof carrier === 'object' ? carrier.__gevContextId : null;
|
||||
if (!contextId) continue;
|
||||
const record = store.entities.get(contextId);
|
||||
if (record && isContextRecordActive(record, dataManager)) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fromRecord(record) {
|
||||
const layerId = record.layerId || null;
|
||||
const id =
|
||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
||||
? String(record.properties.mmsi)
|
||||
: String(record.id);
|
||||
return {
|
||||
layerId,
|
||||
id,
|
||||
label:
|
||||
text(record.label) ||
|
||||
text(record.properties?.name) ||
|
||||
text(record.layerName) ||
|
||||
id,
|
||||
kind: pickedKindForLayer(layerId),
|
||||
lat: finite(record.latitude),
|
||||
lon: finite(record.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
function descriptorLabel(found, fallback) {
|
||||
return (
|
||||
text(found?.callsign) ||
|
||||
text(found?.registration) ||
|
||||
text(found?.name) ||
|
||||
text(found?.icao24) ||
|
||||
text(found?.mmsi) ||
|
||||
text(found?.noradId) ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} picked Result of `scene.pick()`.
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.dataManager] Layer lifecycle (enabled state, modules).
|
||||
* @returns {{layerId: string, id: string, label: string, kind: string, lat: number|null, lon: number|null, altitudeM?: number|null}|null}
|
||||
*/
|
||||
export function resolvePickedContext(picked, { dataManager = null } = {}) {
|
||||
if (!picked) return null;
|
||||
const record = contextCarrierRecord(picked, dataManager);
|
||||
if (record) return fromRecord(record);
|
||||
|
||||
const pickId = resolvePickId(picked);
|
||||
if (!pickId) return null;
|
||||
const layerId = findPickOwner(pickId);
|
||||
if (!layerId || IGNORED_OWNERS.has(layerId)) return null;
|
||||
if (dataManager?.isEnabled && !dataManager.isEnabled(layerId)) return null;
|
||||
const layer = dataManager?.layers?.get?.(layerId);
|
||||
let found = null;
|
||||
try {
|
||||
found = layer?.module?.findByQuery?.(pickId) || null;
|
||||
} catch {
|
||||
found = null;
|
||||
}
|
||||
return {
|
||||
layerId,
|
||||
id: pickId,
|
||||
label: descriptorLabel(found, text(layer?.name) || pickId),
|
||||
kind: pickedKindForLayer(layerId),
|
||||
lat: finite(found?.latitude),
|
||||
lon: finite(found?.longitude),
|
||||
altitudeM: finite(found?.altitudeM),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
globalThis.window ||= globalThis;
|
||||
globalThis.CustomEvent ||= class CustomEvent {
|
||||
constructor(type, init) {
|
||||
this.type = type;
|
||||
this.detail = init?.detail;
|
||||
}
|
||||
};
|
||||
globalThis.dispatchEvent ||= () => true;
|
||||
|
||||
const { registerEntityContext } = await import('./contextStore.js');
|
||||
const { registerPickOwner, unregisterPickOwner, findPickOwner } = await import('./pickRegistry.js');
|
||||
const { resolvePickedContext, pickedKindForLayer } = await import('./pickedContext.js');
|
||||
|
||||
function manager({ enabled = new Set(['flights', 'local-datacenters']), modules = {} } = {}) {
|
||||
return {
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
layers: new Map(Object.entries(modules).map(([id, module]) => [id, { name: id, module }])),
|
||||
};
|
||||
}
|
||||
|
||||
test('an aircraft billboard resolves through its pick owner to a live label', () => {
|
||||
registerPickOwner('flights', (id) => id === 'a1b2c3');
|
||||
const dataManager = manager({
|
||||
modules: {
|
||||
flights: {
|
||||
findByQuery: (q) => (q === 'a1b2c3' ? { icao24: 'a1b2c3', callsign: 'UPS793', latitude: 30.2, longitude: -97.7, altitudeM: 9000 } : null),
|
||||
},
|
||||
},
|
||||
});
|
||||
const picked = { id: 'a1b2c3', primitive: {} };
|
||||
assert.equal(findPickOwner('a1b2c3'), 'flights');
|
||||
assert.deepEqual(resolvePickedContext(picked, { dataManager }), {
|
||||
layerId: 'flights',
|
||||
id: 'a1b2c3',
|
||||
label: 'UPS793',
|
||||
kind: 'aircraft',
|
||||
lat: 30.2,
|
||||
lon: -97.7,
|
||||
altitudeM: 9000,
|
||||
});
|
||||
unregisterPickOwner('flights');
|
||||
});
|
||||
|
||||
test('an entity-backed feature resolves through the context store', () => {
|
||||
const entity = { show: true };
|
||||
registerEntityContext(entity, {
|
||||
id: 'local-datacenters:7',
|
||||
layerId: 'local-datacenters',
|
||||
label: 'Data Center 7',
|
||||
latitude: 30.1,
|
||||
longitude: -97.6,
|
||||
});
|
||||
const out = resolvePickedContext({ id: entity }, { dataManager: manager() });
|
||||
assert.deepEqual(out, {
|
||||
layerId: 'local-datacenters',
|
||||
id: 'local-datacenters:7',
|
||||
label: 'Data Center 7',
|
||||
kind: 'feature',
|
||||
lat: 30.1,
|
||||
lon: -97.6,
|
||||
});
|
||||
});
|
||||
|
||||
test('disabled layers, trails and empty picks never resolve', () => {
|
||||
registerPickOwner('military', (id) => id === 'm1');
|
||||
assert.equal(resolvePickedContext({ id: 'm1' }, { dataManager: manager() }), null, 'military is disabled');
|
||||
unregisterPickOwner('military');
|
||||
assert.equal(resolvePickedContext({ id: 'gev-trail:1' }, { dataManager: manager() }), null);
|
||||
assert.equal(resolvePickedContext(null), null);
|
||||
assert.equal(resolvePickedContext({ id: 'unknown' }, { dataManager: manager() }), null);
|
||||
assert.equal(pickedKindForLayer('ais-live-vessels'), 'vessel');
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import https from 'node:https';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js';
|
||||
import {
|
||||
createRadioProxyMiddleware,
|
||||
isPublicRadioAddress,
|
||||
@@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor
|
||||
}
|
||||
assert.equal(fetchCount, 0);
|
||||
});
|
||||
|
||||
// Answer every pinned request with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of leaving the fetch pending.
|
||||
function mockMirrorStatus(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
function fetchMirror() {
|
||||
return fetchPinnedRadioResponse(
|
||||
new URL('https://de1.api.radio-browser.info/json/servers'),
|
||||
{ headers: {} },
|
||||
[{ address: '93.184.216.34', family: 4 }],
|
||||
);
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => {
|
||||
const crashed = mockMirrorStatus(t, status);
|
||||
const response = await Promise.race([fetchMirror(), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockMirrorStatus(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchMirror(), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeRegionalArticles } from './regionalModel.js';
|
||||
test('compact source dates must denote an actual calendar instant', () => {
|
||||
for (const date of [
|
||||
'20260230T120000Z',
|
||||
'20260431T120000Z',
|
||||
'20261301T120000Z',
|
||||
'20260716T240000Z',
|
||||
'20260716T126000Z',
|
||||
'20260716T120060Z',
|
||||
]) {
|
||||
const [article] = normalizeRegionalArticles({
|
||||
articles: [
|
||||
{
|
||||
title: 'Fixture',
|
||||
url: 'https://fixture.example/news',
|
||||
seendate: date,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(article.publishedAt, null, date);
|
||||
assert.equal(article.title, 'Fixture');
|
||||
}
|
||||
for (const [input, expected] of [
|
||||
['20240229T004500Z', '2024-02-29T00:45:00Z'],
|
||||
['20260716T000000Z', '2026-07-16T00:00:00Z'],
|
||||
['2026-07-16T12:00:00Z', '2026-07-16T12:00:00.000Z'],
|
||||
]) {
|
||||
assert.equal(
|
||||
normalizeRegionalArticles({
|
||||
articles: [
|
||||
{
|
||||
title: 'Fixture',
|
||||
url: 'https://fixture.example/news',
|
||||
seendate: input,
|
||||
},
|
||||
],
|
||||
})[0].publishedAt,
|
||||
expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
+28
-11
@@ -67,11 +67,20 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
seen.add(signature);
|
||||
const rawDate = cleanText(row?.seendate, 32);
|
||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
||||
const publishedAt = compactDate
|
||||
const candidateDate = compactDate
|
||||
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
||||
: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString();
|
||||
: rawDate;
|
||||
const parsedMs = Date.parse(candidateDate);
|
||||
const validDate =
|
||||
Number.isFinite(parsedMs) &&
|
||||
(!compactDate ||
|
||||
new Date(parsedMs).toISOString().slice(0, 19) ===
|
||||
candidateDate.slice(0, 19));
|
||||
const publishedAt = validDate
|
||||
? compactDate
|
||||
? candidateDate
|
||||
: new Date(parsedMs).toISOString()
|
||||
: null;
|
||||
articles.push({
|
||||
title,
|
||||
url,
|
||||
@@ -87,14 +96,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
return articles;
|
||||
}
|
||||
|
||||
/** A reported number, or null when the observation is missing or not numeric. */
|
||||
function observedNumber(value) {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'boolean' ||
|
||||
(typeof value === 'string' && !value.trim())
|
||||
)
|
||||
return null;
|
||||
return Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
}
|
||||
|
||||
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
|
||||
export function normalizeRegionalWeather(payload) {
|
||||
const current = payload?.current;
|
||||
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
|
||||
const numberOrNull = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
};
|
||||
if (!current || observedNumber(current.temperature_2m) == null) return null;
|
||||
const numberOrNull = observedNumber;
|
||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||
const observedRaw =
|
||||
@@ -119,8 +136,8 @@ export function normalizeRegionalWeather(payload) {
|
||||
|
||||
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
|
||||
export function weatherCodeLabel(code) {
|
||||
const value = Number(code);
|
||||
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
|
||||
const value = observedNumber(code);
|
||||
if (value == null) return 'CONDITIONS UNKNOWN';
|
||||
if (value === 0) return 'CLEAR';
|
||||
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
|
||||
if (value === 3) return 'OVERCAST';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js';
|
||||
const step = (type) => ({
|
||||
maneuver: { type, location: [10, 20] },
|
||||
distance: 10,
|
||||
duration: 1,
|
||||
});
|
||||
test('exact-cap routes remain complete and boundary exits retain their distance', () => {
|
||||
const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn'));
|
||||
assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false);
|
||||
assert.equal(
|
||||
normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] })
|
||||
.truncated,
|
||||
true,
|
||||
);
|
||||
steps[steps.length - 1] = step('roundabout');
|
||||
const result = normalizeOsrmSteps({
|
||||
legs: [{ steps: [...steps, step('exit roundabout')] }],
|
||||
});
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(result.steps.length, ROUTE_STEPS_MAX);
|
||||
assert.equal(result.steps.at(-1).distanceM, 20);
|
||||
});
|
||||
@@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
|
||||
step.instruction = instructionFor(step);
|
||||
out.push(step);
|
||||
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
|
||||
}
|
||||
}
|
||||
return { steps: out, truncated: false };
|
||||
|
||||
@@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () =
|
||||
// would present a route that simply stops in the middle of a road.
|
||||
assert.equal(cut.truncated, true);
|
||||
const exact = normalizeOsrmSteps({
|
||||
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
|
||||
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }],
|
||||
});
|
||||
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
|
||||
assert.equal(exact.steps.length, ROUTE_STEPS_MAX);
|
||||
assert.equal(exact.truncated, false, 'a route that fits is not cut');
|
||||
});
|
||||
|
||||
|
||||
@@ -47,3 +47,52 @@ export function isPickedWorldPosition(position) {
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* The world position under a screen point: the rendered surface where the
|
||||
* depth buffer has it, else the ellipsoid; null over the sky.
|
||||
*
|
||||
* @param {object} viewer A Cesium viewer.
|
||||
* @param {{x: number, y: number}} screenPosition
|
||||
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
|
||||
*/
|
||||
export function pickGroundPosition(viewer, screenPosition) {
|
||||
const scene = viewer?.scene;
|
||||
let position = null;
|
||||
if (
|
||||
scene?.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
position = scene.pickPosition(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(position) &&
|
||||
typeof viewer?.camera?.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
position = viewer.camera.pickEllipsoid(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
return isPickedWorldPosition(position) ? position : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `scene.pick()` result is the map itself: nothing, or 3D tile
|
||||
* content (which has no id).
|
||||
*
|
||||
* @param {object|null|undefined} picked
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSurfacePick(picked) {
|
||||
if (!picked) return true;
|
||||
if (picked.id !== undefined) return false;
|
||||
return (
|
||||
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
|
||||
);
|
||||
}
|
||||
+68
-13
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from './scenePick.js';
|
||||
|
||||
const describe = (position) => (position
|
||||
? `(${position.x}, ${position.y}, ${position.z})`
|
||||
: String(position));
|
||||
const describe = (position) =>
|
||||
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
|
||||
|
||||
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
// These are the shapes `scene.pickPosition()` can produce when the depth read
|
||||
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
new Cesium.Cartesian3(0.2, -0.3, 0.1),
|
||||
];
|
||||
for (const position of degenerate) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(0, 0, 5_999_999),
|
||||
];
|
||||
for (const position of belowTheFloor) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
|
||||
const carto = Cesium.Cartographic.fromCartesian(position);
|
||||
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
|
||||
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
|
||||
assert.ok(
|
||||
carto.height < -300_000,
|
||||
'and puts the "target" hundreds of km underground',
|
||||
);
|
||||
}
|
||||
|
||||
const aboveTheCeiling = [
|
||||
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(1e155, 0, 0),
|
||||
];
|
||||
for (const position of aboveTheCeiling) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
|
||||
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
);
|
||||
|
||||
// Both edges of the band are inclusive, and both are real magnitudes.
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a real picked position is accepted', () => {
|
||||
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
];
|
||||
for (const position of real) {
|
||||
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
true,
|
||||
`must accept ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The floor has real margin under the smallest legitimate magnitude, so no
|
||||
// plausible pick sits anywhere near the boundary.
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
|
||||
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(
|
||||
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
|
||||
);
|
||||
assert.ok(
|
||||
polarSurface > 6_300_000,
|
||||
`smallest real magnitude was ${polarSurface}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
// Earth's core. That is the quietest failure of the three and the reason
|
||||
// the guard needs a magnitude band, not just a finiteness check.
|
||||
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
|
||||
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
|
||||
assert.ok(
|
||||
Cesium.Cartographic.fromCartesian(nearCenter),
|
||||
'Cesium converts it without complaint',
|
||||
);
|
||||
assert.equal(isPickedWorldPosition(nearCenter), false);
|
||||
|
||||
// …and every position the guard accepts must convert to the right place.
|
||||
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
|
||||
});
|
||||
|
||||
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
|
||||
const { pickGroundPosition } = await import('./scenePick.js');
|
||||
const ground = { x: 6378137, y: 0, z: 0 };
|
||||
const sky = { x: 500, y: 0, z: 0 };
|
||||
const viewer = (picked, ellipsoid) => ({
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: { pickEllipsoid: () => ellipsoid },
|
||||
});
|
||||
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
|
||||
assert.equal(pickGroundPosition(null, {}), null);
|
||||
});
|
||||
|
||||
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
|
||||
const { isSurfacePick } = await import('./scenePick.js');
|
||||
assert.equal(isSurfacePick(undefined), true);
|
||||
assert.equal(isSurfacePick({ content: {} }), true);
|
||||
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
|
||||
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
|
||||
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
|
||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||
import * as input from './inputOwnership.js';
|
||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
|
||||
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
||||
const v = viewer(t);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
||||
const layer = createDirectionsLayer({ services: {
|
||||
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
|
||||
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
||||
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
|
||||
@@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) {
|
||||
if (parts.length !== 2) return null;
|
||||
const lon = Number(parts[0]);
|
||||
const lat = Number(parts[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
if (
|
||||
!Number.isFinite(lon) ||
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
Math.abs(lat) > 90
|
||||
)
|
||||
return null;
|
||||
points.push([lon, lat]);
|
||||
}
|
||||
return points;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseTerrainPoints,
|
||||
fetchTerrainChunkWithRetry,
|
||||
resolveTerrainHeightRequest,
|
||||
terrainPointKey,
|
||||
@@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as
|
||||
assert.equal(response.cacheChanged, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => {
|
||||
for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91'])
|
||||
assert.equal(parseTerrainPoints(value), null, value);
|
||||
assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [
|
||||
[-180, -90],
|
||||
[180, 90],
|
||||
[0, 0],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
|
||||
test('missing weather observations never assert clear conditions', () => {
|
||||
for (const value of [null, undefined, '', ' ', false, true]) {
|
||||
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
|
||||
assert.equal(
|
||||
normalizeRegionalWeather({ current: { temperature_2m: value } }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert.equal(weatherCodeLabel(0), 'CLEAR');
|
||||
assert.equal(weatherCodeLabel('0'), 'CLEAR');
|
||||
const record = normalizeRegionalWeather({
|
||||
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
|
||||
});
|
||||
assert.equal(record.temperatureC, 0);
|
||||
assert.equal(record.weatherCode, null);
|
||||
assert.equal(record.windKph, null);
|
||||
});
|
||||
Reference in new issue
Block a user