mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream main and migrate cable changelog to fragment
This commit is contained in:
commit
4c0a8e3748
1020 files changed
+126461
-21563
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 } : {}),
|
||||
};
|
||||
}
|
||||
+13
-11
@@ -7,6 +7,8 @@
|
||||
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
|
||||
*/
|
||||
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
const HELI = new Set([
|
||||
'EC20',
|
||||
'EC25',
|
||||
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts. */
|
||||
/** Readable name of each class, for cards and readouts (English data). */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: 'Light aircraft',
|
||||
glider: 'Glider',
|
||||
turboprop: 'Turboprop',
|
||||
airliner: 'Airliner',
|
||||
widebody: 'Widebody',
|
||||
quadjet: 'Four-engine jet',
|
||||
helicopter: 'Helicopter',
|
||||
fastjet: 'Fast jet',
|
||||
bizjet: 'Business jet',
|
||||
uav: 'Drone',
|
||||
light: msg('Light aircraft'),
|
||||
glider: msg('Glider'),
|
||||
turboprop: msg('Turboprop'),
|
||||
airliner: msg('Airliner'),
|
||||
widebody: msg('Widebody'),
|
||||
quadjet: msg('Four-engine jet'),
|
||||
helicopter: msg('Helicopter'),
|
||||
fastjet: msg('Fast jet'),
|
||||
bizjet: msg('Business jet'),
|
||||
uav: msg('Drone'),
|
||||
});
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** @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: msg('General emergency'), severity: 'emergency' },
|
||||
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
|
||||
nordo: { label: msg('Radio failure'), severity: 'emergency' },
|
||||
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
|
||||
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
|
||||
lifeguard: {
|
||||
label: msg('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);
|
||||
});
|
||||
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!env.hasKey) {
|
||||
const actions = terminateOwned('key-absent');
|
||||
status = 'missing-key';
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = 'AISSTREAM_API_KEY is not set';
|
||||
reconnectAttempt = 0;
|
||||
keyFingerprint = null;
|
||||
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (env.hasTransport === false) {
|
||||
const actions = terminateOwned('no-transport');
|
||||
status = 'unsupported';
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = 'Node WebSocket transport is unavailable';
|
||||
return actions;
|
||||
}
|
||||
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!silenceWatchArmed) return [];
|
||||
const silentFor = monoNow - silenceSinceMono;
|
||||
if (silentFor >= recycleAfterMs) {
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
|
||||
const actions = terminateOwned('silent');
|
||||
scheduleRetry('transport');
|
||||
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
|
||||
function onClose(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) return [];
|
||||
release();
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
if (!error) error = 'AISStream websocket closed';
|
||||
scheduleRetry('transport');
|
||||
return [];
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
+17
-12
@@ -27,6 +27,7 @@
|
||||
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
||||
import { pointInRing } from './naturalEarthRegions.js';
|
||||
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
/**
|
||||
* Layers the engine understands, with the fields queries may reference,
|
||||
@@ -98,17 +99,9 @@ function toEpochMs(value) {
|
||||
return Date.parse(text);
|
||||
}
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
|
||||
/** Great-circle distance in km. */
|
||||
/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */
|
||||
export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const d2r = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * d2r;
|
||||
const dLon = (lon2 - lon1) * d2r;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -888,6 +881,7 @@ export function createAnalystEngine(providers) {
|
||||
followUp && !spec.scope ? lastResult.distanceOf || null : null;
|
||||
let resolvedScope = null;
|
||||
let scopeNote = rememberedScope?.coverage || 'anywhere';
|
||||
let scopeSource = rememberedScope?.source || null;
|
||||
// Human phrasing for the same scope, so every spoken count can name what it
|
||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
@@ -897,8 +891,16 @@ export function createAnalystEngine(providers) {
|
||||
// further when a new scope is given.
|
||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||
if (scope.kind === 'region') {
|
||||
const region = await providers.resolveRegionRing(scope.name, signal);
|
||||
if (!isCurrent()) return cancelled();
|
||||
let region;
|
||||
try {
|
||||
region = await providers.resolveRegionRing(scope.name, signal);
|
||||
} catch (error) {
|
||||
// Cancellation belongs to the query owner, not an error's name: a
|
||||
// resource-local AbortError from a current lookup still propagates.
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
throw error;
|
||||
}
|
||||
if (signal?.aborted || !isCurrent()) return cancelled();
|
||||
if (region?.error === 'region-timeout') {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -917,6 +919,7 @@ export function createAnalystEngine(providers) {
|
||||
}
|
||||
resolvedScope = region;
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeSource = region.source || null;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
scopeDetail = scopeLabel;
|
||||
} else if (scope.kind === 'radius') {
|
||||
@@ -1056,6 +1059,7 @@ export function createAnalystEngine(providers) {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
@@ -1088,6 +1092,7 @@ export function createAnalystEngine(providers) {
|
||||
label: memoryOverride?.scopeLabel || result.scopeLabel,
|
||||
detail: memoryOverride?.scopeDetail || result.display.scope,
|
||||
coverage: committedCoverage.scope,
|
||||
source: committedCoverage.scopeSource || null,
|
||||
note: committedCoverage.note || null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import { createAnnotationResolver } from '../annotations/resolver.js';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
@@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => {
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
});
|
||||
|
||||
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
|
||||
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async () => ({ ...TEXLAND, source }),
|
||||
});
|
||||
const result = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.coverage.scopeSource, source);
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
|
||||
const lookups = [];
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
lookups.push(name);
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const airborne = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
||||
});
|
||||
const military = await engine.query({
|
||||
followUp: true,
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
|
||||
assert.equal(first.count, 3);
|
||||
assert.equal(airborne.count, 2);
|
||||
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
|
||||
for (const result of [first, airborne, military]) {
|
||||
assert.equal(result.coverage.scope, 'region:Texland');
|
||||
assert.equal(result.scopeLabel, 'over Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
}
|
||||
assert.deepEqual(lookups, ['Texland']);
|
||||
});
|
||||
|
||||
test('analyst: an explicit replacement region replaces retained provenance', async () => {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: {
|
||||
name,
|
||||
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
|
||||
source: 'remote-boundary',
|
||||
},
|
||||
});
|
||||
await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({
|
||||
followUp: true,
|
||||
scope: { kind: 'region', name: 'Northern Texland' },
|
||||
});
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(result.coverage.scope, 'region:Northern Texland');
|
||||
assert.equal(result.coverage.scopeSource, 'remote-boundary');
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
|
||||
for (const scope of [
|
||||
{ kind: 'anywhere' },
|
||||
{ kind: 'view' },
|
||||
{ kind: 'radius', km: 150 },
|
||||
{ kind: 'region', name: 'Uncredited Texland' },
|
||||
]) {
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({
|
||||
...TEXLAND,
|
||||
name,
|
||||
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
});
|
||||
const replacement = await engine.query({ followUp: true, scope });
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
|
||||
assert.equal(first.coverage.scopeSource, 'natural-earth');
|
||||
for (const result of [replacement, followUp]) {
|
||||
assert.equal(result.ok, true, scope.kind);
|
||||
assert.equal(result.count, 3, scope.kind);
|
||||
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
|
||||
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [label, committedScope, scopeSource] of [
|
||||
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
|
||||
['window without a boundary source', 'window:presented', null],
|
||||
]) {
|
||||
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
|
||||
const committedSnapshot = {
|
||||
id: 'flights', enabled: true, feedState: 'stale',
|
||||
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
|
||||
};
|
||||
let reads = 0;
|
||||
let lookups = 0;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => { reads++; return FLIGHTS; },
|
||||
getLayerSnapshot: () => ({
|
||||
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
|
||||
}),
|
||||
resolveRegionRing: async () => {
|
||||
lookups++;
|
||||
return { ...TEXLAND, source: 'natural-earth' };
|
||||
},
|
||||
});
|
||||
await engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{
|
||||
beforeCommit: async (result) => {
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.coverage.scopeSource, 'natural-earth');
|
||||
const { scopeSource: _originalSource, ...coverage } = result.coverage;
|
||||
return {
|
||||
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
|
||||
scopeLabel: 'the presented cohort',
|
||||
scopeDetail: 'the presented cohort window',
|
||||
coverage: {
|
||||
...coverage,
|
||||
scope: committedScope,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
feedProvenance: { layers: [committedSnapshot] },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.equal(followUp.count, 1);
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
|
||||
assert.equal(followUp.scopeLabel, 'the presented cohort');
|
||||
assert.equal(followUp.display.scope, 'the presented cohort window');
|
||||
assert.equal(followUp.coverage.scope, committedScope);
|
||||
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
|
||||
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
|
||||
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
|
||||
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
@@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
assert.equal(result.cancelled, true);
|
||||
});
|
||||
|
||||
for (const [label, reason, trackAuthority] of [
|
||||
['default abort reason', undefined, false],
|
||||
['custom error reason', new Error('operator stopped'), false],
|
||||
['primitive reason and revoked authority', 'operator stopped', true],
|
||||
]) {
|
||||
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
|
||||
const started = Promise.withResolvers();
|
||||
const geocode = Promise.withResolvers();
|
||||
const controller = new AbortController();
|
||||
const lookups = [];
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
|
||||
method,
|
||||
async () => { throw new Error('offline region query reached feature source'); },
|
||||
])),
|
||||
});
|
||||
const placeSearch = {
|
||||
geocode(name, { signal }) {
|
||||
lookups.push(name);
|
||||
assert.equal(signal, controller.signal);
|
||||
started.resolve();
|
||||
return geocode.promise;
|
||||
},
|
||||
};
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
|
||||
name, signal, placeSearch, { budgetMs: Infinity },
|
||||
),
|
||||
});
|
||||
const first = await engine.query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.coverage.scopeSource, 'bundled-admin');
|
||||
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
|
||||
let commits = 0;
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
|
||||
beforeCommit: async () => { commits++; },
|
||||
},
|
||||
);
|
||||
await started.promise;
|
||||
controller.abort(reason);
|
||||
// Let an actual late geocode result cross the resolver's post-await check.
|
||||
geocode.resolve({ place: {
|
||||
lat: 5, lng: -73, label: 'Cordillera Oriental',
|
||||
name: 'Cordillera Oriental', types: ['natural_feature'],
|
||||
} });
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 'CANCELLED');
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(commits, 0);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
|
||||
assert.equal(followUp.scopeLabel, first.scopeLabel);
|
||||
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
|
||||
const lookup = Promise.withResolvers();
|
||||
let current = true;
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: (name) => name === 'Texland'
|
||||
? { ...TEXLAND, source: 'natural-earth' }
|
||||
: lookup.promise,
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ isCurrent: () => current },
|
||||
);
|
||||
current = false;
|
||||
lookup.reject(new Error('late lookup failed'));
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
});
|
||||
|
||||
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
|
||||
const controller = new AbortController();
|
||||
const lookup = Promise.withResolvers();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: () => lookup.promise,
|
||||
});
|
||||
const pending = engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
|
||||
assert.equal((await pending).code, 'CANCELLED');
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
});
|
||||
|
||||
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
|
||||
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
|
||||
const controller = new AbortController();
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => {
|
||||
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
await assert.rejects(
|
||||
engine.query(
|
||||
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
(thrown) => thrown === error,
|
||||
);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
const followUp = await engine.query({ followUp: true });
|
||||
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
|
||||
assert.equal(followUp.coverage.scope, first.coverage.scope);
|
||||
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
|
||||
* short way round, so points either side of 180° stay close.
|
||||
*/
|
||||
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * 111.32;
|
||||
let dLonDeg = lon2 - lon1;
|
||||
dLonDeg -= 360 * Math.round(dLonDeg / 360);
|
||||
const dLon =
|
||||
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
||||
facebook: Object.freeze(['https://www.facebook.com']),
|
||||
});
|
||||
|
||||
// Known English from public/events/bhote-koshi-2026/event.json, translated at
|
||||
// display (whole or by " · " parts). Anything else shows as authored.
|
||||
const EVENT_PACK_TEXT = new Set([
|
||||
// Field reports: our labels (credits after " · " stay) and their statuses.
|
||||
msg('Rasuwagadhi witness video'),
|
||||
msg('Bhote Koshi valley footage'),
|
||||
msg('Flood footage'),
|
||||
msg('Compilation and casualty claims'),
|
||||
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
|
||||
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
|
||||
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
|
||||
msg('SOURCE GEOCONFIRMED'),
|
||||
// Reconstruction caveat.
|
||||
msg(
|
||||
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
|
||||
),
|
||||
// Evidence titles that are more than a place name.
|
||||
msg('Immediate collapse viewpoint'),
|
||||
msg('Collapse viewpoint'),
|
||||
msg('Debris-dammed lake'),
|
||||
msg('Dammed lake'),
|
||||
msg('Second landslide'),
|
||||
msg('Gyirong border gate'),
|
||||
msg('Border gate'),
|
||||
msg('Timure evidence cluster'),
|
||||
msg('Mailung / Upper Trishuli bridge'),
|
||||
msg('Dandagaun viewpoint restaurant'),
|
||||
msg('Dandagaun viewpoint'),
|
||||
msg('Bidur - Trishuli bridge'),
|
||||
msg('Bidur bridge'),
|
||||
msg('Devighat / Taadi Khola Bridge'),
|
||||
msg('Devighat bridge'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display text for an authored event-pack string.
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bhoteEventText(value) {
|
||||
const text = value == null ? '' : String(value);
|
||||
if (EVENT_PACK_TEXT.has(text)) return t(text);
|
||||
if (!text.includes(' · ')) return text;
|
||||
return text
|
||||
.split(' · ')
|
||||
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
let _xWidgetsPromise = null;
|
||||
let _facebookSdkPromise = null;
|
||||
let _facebookEmbedSequence = 0;
|
||||
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
prior?.();
|
||||
} finally {
|
||||
if (facebookSdkReady(globalRef.FB)) finish();
|
||||
else finish(new Error('Facebook player API did not initialize.'));
|
||||
else finish(new Error(msg('Facebook player API did not initialize.')));
|
||||
}
|
||||
};
|
||||
globalRef.fbAsyncInit = ready;
|
||||
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('Facebook player API could not load.')),
|
||||
() => finish(new Error(msg('Facebook player API could not load.'))),
|
||||
{ once: true },
|
||||
);
|
||||
timer =
|
||||
globalRef.setTimeout?.(
|
||||
() => finish(new Error('Facebook player API timed out.')),
|
||||
() => finish(new Error(msg('Facebook player API timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
) ?? null;
|
||||
}).catch((error) => {
|
||||
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
};
|
||||
const ready = () => {
|
||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
||||
else finish(new Error('X embed API did not initialize.'));
|
||||
else finish(new Error(msg('X embed API did not initialize.')));
|
||||
};
|
||||
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||
if (!script) {
|
||||
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('X embed could not load.')),
|
||||
() => finish(new Error(msg('X embed could not load.'))),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
},
|
||||
);
|
||||
timer = setTimeout(
|
||||
() => finish(new Error('X embed timed out.')),
|
||||
() => finish(new Error(msg('X embed timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
);
|
||||
}).catch((error) => {
|
||||
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
return _xWidgetsPromise;
|
||||
}
|
||||
|
||||
// Outcomes a trimmed YouTube clip reports to the card status.
|
||||
const YOUTUBE_PHASE_LABELS = Object.freeze({
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
timeout: msg('TIMEOUT'),
|
||||
blocked: msg('BLOCKED'),
|
||||
});
|
||||
|
||||
function providerLabel(provider) {
|
||||
return (
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
|
||||
t('SOURCE')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
globalRef.setTimeout?.(() => {
|
||||
if (!session.active || session.ready) return;
|
||||
session.error = new Error(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
msg(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
),
|
||||
);
|
||||
session.onError?.(session.error);
|
||||
}, EMBED_TIMEOUT_MS) ?? null;
|
||||
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = 'LOADING X POST…';
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
);
|
||||
if (token !== generation || record !== current) return;
|
||||
if (!widget)
|
||||
throw new Error('The X post is unavailable or cannot be embedded.');
|
||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
else if (session.error) session.onError(session.error);
|
||||
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
}),
|
||||
);
|
||||
const leader = documentRef.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
player.className = 'bhote-embedded-callout-player';
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = String(
|
||||
observation?.shortTitle || observation?.title || 'Witness source',
|
||||
title.textContent = bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = 'OPEN ORIGINAL ↗';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
});
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+262
-73
@@ -19,11 +19,13 @@ import {
|
||||
SOURCE_PATH_BEAT_IDS,
|
||||
} from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
bhoteEventText,
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
|
||||
return Math.max(0, Math.min(beats.length - 1, current + delta));
|
||||
}
|
||||
|
||||
// Evidence phases from the event pack, shown as the beat meta prefix.
|
||||
const EVIDENCE_PHASE_LABELS = Object.freeze({
|
||||
initiation: msg('INITIATION'),
|
||||
damming: msg('DAMMING'),
|
||||
'secondary failure': msg('SECONDARY FAILURE'),
|
||||
'border arrival': msg('BORDER ARRIVAL'),
|
||||
propagation: msg('PROPAGATION'),
|
||||
'downstream passage': msg('DOWNSTREAM PASSAGE'),
|
||||
consequence: msg('CONSEQUENCE'),
|
||||
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
|
||||
});
|
||||
|
||||
/** Display label for an evidence phase; unknown phases show as authored. */
|
||||
function evidencePhaseLabel(phase) {
|
||||
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
|
||||
if (label) return t(label);
|
||||
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
|
||||
}
|
||||
|
||||
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
|
||||
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
|
||||
const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized name and date of an imagery endpoint for display.
|
||||
* @param {{label?: string, observedAt?: string}} entry
|
||||
* @returns {{name: string, date: string}}
|
||||
*/
|
||||
export function imageryEndpointLabel(entry) {
|
||||
const label = String(entry?.label || '');
|
||||
const split = label.lastIndexOf(' · ');
|
||||
const authoredName = split >= 0 ? label.slice(0, split) : label;
|
||||
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
|
||||
let name = authoredName;
|
||||
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
|
||||
const match = pattern.exec(authoredName);
|
||||
if (match) name = t(message, { year: match[1] });
|
||||
}
|
||||
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
|
||||
}
|
||||
|
||||
function imageryEndpointText(entry) {
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
return [name, date].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||
function renderImageryEndpoint(element, entry) {
|
||||
if (!element) return;
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
element.textContent = date ? `${name} · ` : name;
|
||||
if (!date) return;
|
||||
const dateElement = document.createElement('span');
|
||||
dateElement.className = 'bhote-event-date';
|
||||
dateElement.textContent = date;
|
||||
element.appendChild(dateElement);
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
|
||||
return count > 1
|
||||
? tn(
|
||||
count,
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
|
||||
)
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Interpolate headings across north using the shortest angular path. */
|
||||
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
|
||||
}
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
||||
ctx.fillText('NO PREVIEW', 20, 30);
|
||||
ctx.fillText(t('NO PREVIEW'), 20, 30);
|
||||
}
|
||||
|
||||
function ellipsizeEvidenceText(value, limit) {
|
||||
@@ -650,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
|
||||
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
// The pack names the kind of source in `media.publisher` (event.json); the
|
||||
// frame shows it in the UI language. Any other publisher shows as given.
|
||||
const EVIDENCE_PUBLISHER_TEXT = new Set([
|
||||
msg('YouTube source'),
|
||||
msg('Facebook source'),
|
||||
]);
|
||||
|
||||
/** An evidence item's publisher as its frame shows it. */
|
||||
function evidencePublisherText(publisher) {
|
||||
const text = String(publisher);
|
||||
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
|
||||
}
|
||||
|
||||
/** Repaint one stable frame with a poster or the current source-video frame. */
|
||||
export function updateEvidenceFrame(
|
||||
canvas,
|
||||
@@ -704,25 +796,78 @@ export function updateEvidenceFrame(
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
||||
ctx.fillText(
|
||||
ellipsizeEvidenceText(
|
||||
observation.shortTitle || observation.title,
|
||||
bhoteEventText(observation.shortTitle || observation.title),
|
||||
layout.titleChars,
|
||||
).toUpperCase(),
|
||||
16,
|
||||
infoY + 27,
|
||||
);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||
const publisher = String(
|
||||
observation.media?.publisher || 'SOURCE LINK',
|
||||
const publisher = (
|
||||
observation.media?.publisher
|
||||
? evidencePublisherText(observation.media.publisher)
|
||||
: t('SOURCE LINK')
|
||||
).toUpperCase();
|
||||
ctx.fillText(
|
||||
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
|
||||
fitEvidenceSourceLine(
|
||||
ctx,
|
||||
publisher,
|
||||
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
|
||||
canvas.width - 32,
|
||||
),
|
||||
16,
|
||||
infoY + 53,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The smallest the source line's font may shrink to before the publisher is
|
||||
// shortened instead.
|
||||
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
|
||||
|
||||
/**
|
||||
* Set the font for the «{publisher} / OPEN» line and return a line that fits
|
||||
* `maxWidth`. Translations run longer: first shrink the font (to 80%), then
|
||||
* shorten the publisher; «/ OPEN» always stays whole.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} publisher Display publisher, already upper-cased.
|
||||
* @param {string} font CSS font with a px size.
|
||||
* @param {number} maxWidth
|
||||
* @returns {string}
|
||||
*/
|
||||
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
|
||||
const line = (limit) =>
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, limit),
|
||||
});
|
||||
let limit = 24;
|
||||
let text = line(limit);
|
||||
ctx.font = font;
|
||||
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
|
||||
const width = () => Number(ctx.measureText(text)?.width) || 0;
|
||||
if (width() <= maxWidth) return text;
|
||||
const size = /(\d+(?:\.\d+)?)px/.exec(font);
|
||||
if (size) {
|
||||
const base = Number(size[1]);
|
||||
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
|
||||
// Width scales with the font size; step down until it fits or bottoms out.
|
||||
let next = Math.max(
|
||||
floor,
|
||||
Math.floor(((base * maxWidth) / width()) * 10) / 10,
|
||||
);
|
||||
for (;;) {
|
||||
ctx.font = font.replace(size[0], `${next}px`);
|
||||
if (width() <= maxWidth || next <= floor) break;
|
||||
next = Math.max(floor, next - 0.5);
|
||||
}
|
||||
}
|
||||
while (width() > maxWidth && limit > 4) {
|
||||
limit = Math.min(limit, publisher.length) - 1;
|
||||
text = line(limit);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Compose source imagery and caveats into one stable renderer-owned frame. */
|
||||
export function createEvidenceFrame(observation, poster = null, options = {}) {
|
||||
const canvas = evidenceFrameCanvas(observation);
|
||||
@@ -813,28 +958,40 @@ function collectPanelReferences(panel) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
|
||||
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
|
||||
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
@@ -842,44 +999,44 @@ function createPanel(event, handlers) {
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
|
||||
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
|
||||
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
|
||||
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">▶ PLAY</button>
|
||||
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
|
||||
<button type="button" data-action="story-replay">↺ FULL STORY</button>
|
||||
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
|
||||
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
|
||||
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
refs.beforeDate.textContent = event.imagery.before.label;
|
||||
refs.afterDate.textContent = event.imagery.after.label;
|
||||
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||
refs.caveat.textContent = event.reconstruction.caveat;
|
||||
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||
|
||||
for (const report of event.fieldReports) {
|
||||
const button = document.createElement('button');
|
||||
@@ -887,8 +1044,8 @@ function createPanel(event, handlers) {
|
||||
const status = document.createElement('small');
|
||||
button.type = 'button';
|
||||
button.className = 'bhote-event-report';
|
||||
label.textContent = report.label;
|
||||
status.textContent = report.status;
|
||||
label.textContent = bhoteEventText(report.label);
|
||||
status.textContent = bhoteEventText(report.status);
|
||||
button.append(label, status);
|
||||
button.addEventListener('click', () => openExternal(report.url));
|
||||
refs.reportList.appendChild(button);
|
||||
@@ -1205,7 +1362,7 @@ export function createBhoteKoshiEventLayer({
|
||||
_embeddedMedia?.show?.({
|
||||
observation: {
|
||||
title: witness.title,
|
||||
shortTitle: 'Rasuwagadhi witness',
|
||||
shortTitle: t('Rasuwagadhi witness'),
|
||||
media: { sourceUrl: witness.sourceUrl },
|
||||
},
|
||||
anchor,
|
||||
@@ -2320,7 +2477,10 @@ export function createBhoteKoshiEventLayer({
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 60,
|
||||
interactive: true,
|
||||
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
|
||||
accessibilityLabel: t(
|
||||
'Open original source for {source}; capture time unverified',
|
||||
{ source: bhoteEventText(observation.title) },
|
||||
),
|
||||
activate: () => openExternal(observation.media.sourceUrl),
|
||||
minDistance: 0,
|
||||
maxDistance: 220000,
|
||||
@@ -2353,7 +2513,7 @@ export function createBhoteKoshiEventLayer({
|
||||
position: evidencePositionGetter(index, true),
|
||||
cullPosition: anchor,
|
||||
variant: 'label',
|
||||
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
|
||||
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
|
||||
details: [],
|
||||
accent: WORLD_OVERLAY_STYLE.accent,
|
||||
priority: 80_000 - index,
|
||||
@@ -2559,11 +2719,14 @@ export function createBhoteKoshiEventLayer({
|
||||
id: 'bhote-koshi-split-line',
|
||||
handleClass: 'bhote-koshi-split-handle',
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: event.imagery.before.label,
|
||||
afterTitle: event.imagery.after.label,
|
||||
ariaLabel: 'Historical reference and post-event image divider',
|
||||
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||
afterTitle: imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
{ before: beforePercent, after: afterPercent },
|
||||
),
|
||||
onChange: setSplit,
|
||||
});
|
||||
}
|
||||
@@ -2595,7 +2758,7 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
}
|
||||
@@ -2961,13 +3124,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.progressInput,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? 'Seek Nepal scene clock'
|
||||
: 'Schematic downstream progression',
|
||||
? t('Seek Nepal scene clock')
|
||||
: t('Schematic downstream progression'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.timelineLabel,
|
||||
'textContent',
|
||||
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
|
||||
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
|
||||
);
|
||||
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
||||
setElementProperty(
|
||||
@@ -2980,22 +3143,22 @@ export function createBhoteKoshiEventLayer({
|
||||
);
|
||||
const playLabel = sceneDirected
|
||||
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
||||
? '… PLAYING SHOT'
|
||||
: '▶ PLAY SHOT'
|
||||
? t('… PLAYING SHOT')
|
||||
: t('▶ PLAY SHOT')
|
||||
: _playing
|
||||
? 'Ⅱ PAUSE'
|
||||
? t('Ⅱ PAUSE')
|
||||
: _progress >= 1
|
||||
? _cinematicReplayArmed
|
||||
? '↺ REPLAY CINEMATIC'
|
||||
: '↺ REPLAY'
|
||||
: '▶ PLAY';
|
||||
? t('↺ REPLAY CINEMATIC')
|
||||
: t('↺ REPLAY')
|
||||
: t('▶ PLAY');
|
||||
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
'textContent',
|
||||
_sceneActionPending?.type === 'scene'
|
||||
? '… PLAYING SCENE'
|
||||
: '▶ PLAY SCENE',
|
||||
? t('… PLAYING SCENE')
|
||||
: t('▶ PLAY SCENE'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
@@ -3011,19 +3174,24 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementAttribute(
|
||||
_panelRefs.playSceneButton,
|
||||
'aria-label',
|
||||
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
|
||||
t('Continue {scene} from next shot', {
|
||||
scene:
|
||||
_sceneContext?.sceneDisplayTitle ||
|
||||
_sceneContext?.sceneTitle ||
|
||||
t('current scene'),
|
||||
}),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.storyReplayButton,
|
||||
'textContent',
|
||||
'↺ FULL STORY',
|
||||
t('↺ FULL STORY'),
|
||||
);
|
||||
if (!sceneDirected) {
|
||||
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
||||
setElementAttribute(
|
||||
_panelRefs.storyReplayButton,
|
||||
'aria-label',
|
||||
'Replay the full reconstruction',
|
||||
t('Replay the full reconstruction'),
|
||||
);
|
||||
}
|
||||
const beatIndex = currentEvidenceIndex();
|
||||
@@ -3047,18 +3215,26 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatTitle,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? _sceneContext?.shotTitle || 'SCENE SHOT'
|
||||
: observation?.shortTitle || observation?.title || 'EVIDENCE',
|
||||
? _sceneContext?.shotDisplayTitle ||
|
||||
_sceneContext?.shotTitle ||
|
||||
t('SCENE SHOT')
|
||||
: observation?.shortTitle || observation?.title
|
||||
? bhoteEventText(observation.shortTitle || observation.title)
|
||||
: t('EVIDENCE'),
|
||||
);
|
||||
const sourceCount = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
const beatMeta = [
|
||||
String(observation?.phase || 'EVIDENCE').toUpperCase(),
|
||||
'TIME UNVERIFIED',
|
||||
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
|
||||
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
|
||||
evidencePhaseLabel(observation?.phase),
|
||||
t('TIME UNVERIFIED'),
|
||||
sourceCount > 1
|
||||
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
|
||||
: '',
|
||||
observation?.imageryCoverage === 'outside'
|
||||
? t('OUTSIDE IMAGERY SWIPE')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
@@ -3066,7 +3242,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatMeta,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
|
||||
? t('{scene} / AUTHORED SHOT', {
|
||||
scene: String(
|
||||
_sceneContext?.sceneDisplayTitle ||
|
||||
_sceneContext?.sceneTitle ||
|
||||
t('NEPAL FLOOD INCIDENT'),
|
||||
).toUpperCase(),
|
||||
})
|
||||
: beatMeta,
|
||||
);
|
||||
setElementProperty(
|
||||
@@ -3093,18 +3275,23 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.playButton,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
|
||||
: 'Play reconstruction',
|
||||
? t('Play {shot}', {
|
||||
shot:
|
||||
_sceneContext?.shotDisplayTitle ||
|
||||
_sceneContext?.shotTitle ||
|
||||
t('current scene shot'),
|
||||
})
|
||||
: t('Play reconstruction'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.previousButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
|
||||
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.nextButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load next scene shot' : 'Next story beat',
|
||||
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.openSourceButton,
|
||||
@@ -3153,8 +3340,8 @@ export function createBhoteKoshiEventLayer({
|
||||
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
||||
if (_panelRefs?.caveat) {
|
||||
_panelRefs.caveat.textContent = imageryAvailable
|
||||
? _event?.reconstruction?.caveat || ''
|
||||
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
|
||||
? bhoteEventText(_event?.reconstruction?.caveat || '')
|
||||
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
|
||||
}
|
||||
if (_panelRefs?.imageryCloudNote)
|
||||
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
||||
@@ -3717,7 +3904,9 @@ export function createBhoteKoshiEventLayer({
|
||||
count: _enabled ? 1 : 0,
|
||||
status: _enabled ? 'nominal' : 'idle',
|
||||
source: 'Vantor + GeoPera',
|
||||
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
|
||||
coverage: _enabled
|
||||
? t('Rasuwa · 26 Aug 2026')
|
||||
: t('Event reconstruction'),
|
||||
};
|
||||
},
|
||||
getPlaybackState() {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
|
||||
// a long translation shrinks to fit the portrait card, action word whole.
|
||||
// English is drawn at full size.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
// JetBrains Mono advances every glyph by 0.6 em.
|
||||
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
|
||||
const measure = (text, font) => text.length * 0.6 * fontPx(font);
|
||||
|
||||
function drawSourceLine(observation, publisher) {
|
||||
const layout = evidenceCardLayout(observation);
|
||||
const drawn = [];
|
||||
const context = {
|
||||
font: '',
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
drawImage() {},
|
||||
measureText(text) {
|
||||
return { width: measure(String(text), this.font) };
|
||||
},
|
||||
fillText(value, x) {
|
||||
drawn.push({ text: String(value), font: this.font, x });
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
width: layout.canvasWidth,
|
||||
height: layout.canvasHeight,
|
||||
_bhoteEvidenceLayout: layout,
|
||||
getContext: () => context,
|
||||
};
|
||||
const subject = publisher
|
||||
? { ...observation, media: { ...observation.media, publisher } }
|
||||
: observation;
|
||||
assert.equal(updateEvidenceFrame(canvas, subject), true);
|
||||
const line = drawn.find((entry) => entry.text.includes(' / '));
|
||||
assert.ok(line, 'source line drawn');
|
||||
return { ...line, maxWidth: layout.canvasWidth - 32 };
|
||||
}
|
||||
|
||||
async function portraitObservations() {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const portrait = event.evidenceSpine.filter(
|
||||
(o) => evidenceCardLayout(o).kind === 'portrait',
|
||||
);
|
||||
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
|
||||
const facebook = portrait.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube, 'a portrait YouTube card exists');
|
||||
return [youtube, facebook].filter(Boolean);
|
||||
}
|
||||
|
||||
test('English source lines draw unchanged on every card', async () => {
|
||||
setLocale('en');
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
for (const observation of event.evidenceSpine) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
|
||||
assert.match(
|
||||
line.text,
|
||||
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ru/es source lines fit the portrait card with the action word whole', async () => {
|
||||
const observations = await portraitObservations();
|
||||
try {
|
||||
for (const [locale, action] of [
|
||||
['ru', '/ ОТКРЫТЬ'],
|
||||
['es', '/ ABRIR'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
for (const observation of observations) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
|
||||
assert.ok(
|
||||
measure(line.text, line.font) <= line.maxWidth,
|
||||
`${locale}: «${line.text}» at ${line.font} overflows`,
|
||||
);
|
||||
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
|
||||
}
|
||||
}
|
||||
setLocale('ru');
|
||||
const ru = drawSourceLine(observations[0]);
|
||||
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
|
||||
const [observation] = await portraitObservations();
|
||||
try {
|
||||
setLocale('ru');
|
||||
const line = drawSourceLine(
|
||||
observation,
|
||||
'A very long regional broadcaster',
|
||||
);
|
||||
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
|
||||
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
|
||||
assert.equal(fontPx(line.font), 21 * 0.8);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
// The publisher on the evidence frame ("YouTube source") is pack text shown
|
||||
// in the UI language; English stays "YOUTUBE SOURCE / OPEN".
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function captureFrameText(observation) {
|
||||
const originalDocument = globalThis.document;
|
||||
const text = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const context = {
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
fillText(value) {
|
||||
text.push(String(value));
|
||||
},
|
||||
drawImage() {},
|
||||
};
|
||||
return { width: 0, height: 0, getContext: () => context };
|
||||
},
|
||||
};
|
||||
try {
|
||||
assert.ok(createEvidenceFrame(observation));
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
test('evidence card source line is fully in the UI language (es, ru)', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
const facebook = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube && facebook);
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const observation of [youtube, facebook]) {
|
||||
const lines = captureFrameText(observation);
|
||||
const sourceLine = lines.find((line) => line.includes(' / '));
|
||||
assert.ok(sourceLine, 'source line drawn');
|
||||
assert.doesNotMatch(
|
||||
sourceLine,
|
||||
/SOURCE/,
|
||||
`${locale}: English "SOURCE" left in "${sourceLine}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence card source line keeps its English wording', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
setLocale('en');
|
||||
const sourceLine = captureFrameText(youtube).find((line) =>
|
||||
line.includes(' / '),
|
||||
);
|
||||
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg, getLocale } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
@@ -51,23 +52,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
|
||||
const TRIGGER_SOURCE_URL =
|
||||
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
|
||||
|
||||
// Descriptive pin titles translate at display (same entries as the event
|
||||
// panel); place names stay Latin, anything else shows as authored.
|
||||
const PLACE_TITLE_TEXT = new Map(
|
||||
[
|
||||
msg('Collapse viewpoint'),
|
||||
msg('Dammed lake'),
|
||||
msg('Second landslide'),
|
||||
msg('Border gate'),
|
||||
msg('Dandagaun viewpoint'),
|
||||
msg('Bidur bridge'),
|
||||
msg('Devighat bridge'),
|
||||
].map((text) => [text.toUpperCase(), text]),
|
||||
);
|
||||
|
||||
function placeTitle(title) {
|
||||
const text = PLACE_TITLE_TEXT.get(title);
|
||||
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
|
||||
}
|
||||
|
||||
const INCIDENT = Object.freeze({
|
||||
lat: 28.3632333,
|
||||
lon: 85.4423639,
|
||||
heightM: 5200,
|
||||
title: 'BHOTE KOSHI FLOOD INCIDENT',
|
||||
subtitle: 'NEPAL · 26 AUG 2026',
|
||||
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
|
||||
subtitle: msg('NEPAL · 26 AUG 2026'),
|
||||
});
|
||||
const TRIGGER_RECORD = Object.freeze({
|
||||
lat: 28.271,
|
||||
lon: 85.515,
|
||||
heightM: 4200,
|
||||
title: 'USGS INITIAL REPORT',
|
||||
title: msg('USGS INITIAL REPORT'),
|
||||
details: Object.freeze([
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
'28.2710° N · 85.5150° E',
|
||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
||||
msg('26 AUG 2026 · 08:37 NPT'),
|
||||
msg('28.2710° N · 85.5150° E'),
|
||||
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
|
||||
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
|
||||
]),
|
||||
});
|
||||
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
||||
@@ -90,11 +110,13 @@ const NEPAL = Object.freeze({
|
||||
lat: 28.3949,
|
||||
lon: 84.124,
|
||||
heightM: 6800,
|
||||
// i18n-ignore: country name, shown as is
|
||||
title: 'NEPAL',
|
||||
});
|
||||
const NEARBY_CITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'kathmandu',
|
||||
// i18n-ignore: city name
|
||||
title: 'KATHMANDU',
|
||||
lat: 27.7017,
|
||||
lon: 85.3206,
|
||||
@@ -103,6 +125,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'bhaktapur',
|
||||
// i18n-ignore: city name
|
||||
title: 'BHAKTAPUR',
|
||||
lat: 27.673,
|
||||
lon: 85.43,
|
||||
@@ -111,6 +134,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'dhulikhel',
|
||||
// i18n-ignore: city name
|
||||
title: 'DHULIKHEL',
|
||||
lat: 27.6221,
|
||||
lon: 85.5428,
|
||||
@@ -394,8 +418,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: subject.title,
|
||||
details: [subject.subtitle],
|
||||
title: t(subject.title),
|
||||
details: [t(subject.subtitle)],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -536,8 +560,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: TRIGGER_RECORD.title,
|
||||
details: [...TRIGGER_RECORD.details],
|
||||
title: t(TRIGGER_RECORD.title),
|
||||
details: TRIGGER_RECORD.details.map((line) => t(line)),
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -549,8 +573,9 @@ export function createBhoteKoshiLocatorLayer({
|
||||
protected: true,
|
||||
active: true,
|
||||
interactive: true,
|
||||
accessibilityLabel:
|
||||
accessibilityLabel: t(
|
||||
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
|
||||
),
|
||||
activate: () => {
|
||||
const opened = window.open(
|
||||
TRIGGER_SOURCE_URL,
|
||||
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
|
||||
details: [],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: progress,
|
||||
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'nepal-context',
|
||||
// i18n-ignore: country name
|
||||
label: 'NEPAL',
|
||||
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Show the Nepal context callout without a border or country fill',
|
||||
),
|
||||
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-regional',
|
||||
// i18n-ignore: river name (Bhote Koshi)
|
||||
label: 'BHOTE',
|
||||
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-city-context',
|
||||
label: 'CITIES',
|
||||
label: t('CITIES'),
|
||||
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Keep the regional incident context and introduce nearby city labels',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-incident-places',
|
||||
label: 'PLACES',
|
||||
label: t('PLACES'),
|
||||
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce the reconstruction scene key places from trigger to downstream',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-flood-path',
|
||||
label: 'PATH',
|
||||
label: t('PATH'),
|
||||
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-path-overview',
|
||||
label: 'OVERVIEW',
|
||||
label: t('OVERVIEW'),
|
||||
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
title: 'Keep the completed flood path and location labels visible',
|
||||
title: t(
|
||||
'Keep the completed flood path and location labels visible',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-trigger-record',
|
||||
label: 'RECORD',
|
||||
label: t('RECORD'),
|
||||
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Reveal the trigger record while making a slow camera approach',
|
||||
),
|
||||
params: {
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
preview: true,
|
||||
|
||||
+408
-121
@@ -1,9 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import {
|
||||
INCIDENT_OVERVIEW_HOLD_SEC,
|
||||
INCIDENT_OVERVIEW_PLACES,
|
||||
} from './bhoteKoshiIncidentPlaces.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { createPlaybackClock } from '../director/clock.js';
|
||||
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
||||
@@ -29,8 +33,12 @@ function viewerFixture() {
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
add(source) {
|
||||
added.push(source);
|
||||
},
|
||||
remove(source) {
|
||||
removed.push(source);
|
||||
},
|
||||
},
|
||||
_test: { added, removed },
|
||||
};
|
||||
@@ -38,7 +46,9 @@ function viewerFixture() {
|
||||
|
||||
function nonRunningAnimation() {
|
||||
return {
|
||||
scheduleFrame() { return 1; },
|
||||
scheduleFrame() {
|
||||
return 1;
|
||||
},
|
||||
cancelFrame() {},
|
||||
};
|
||||
}
|
||||
@@ -58,24 +68,68 @@ function overlayHostFixture() {
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
||||
[80, 26], [88, 26], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
]),
|
||||
[
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 26],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[80, 26],
|
||||
]),
|
||||
[
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[80, 26],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
['bad', 27],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
||||
const island = [[82, 25], [83, 25], [82, 25]];
|
||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
||||
assert.equal(largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}), main);
|
||||
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
|
||||
const main = [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
[80, 26],
|
||||
];
|
||||
const island = [
|
||||
[82, 25],
|
||||
[83, 25],
|
||||
[82, 25],
|
||||
];
|
||||
assert.equal(
|
||||
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
|
||||
main,
|
||||
);
|
||||
assert.equal(
|
||||
largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}),
|
||||
main,
|
||||
);
|
||||
assert.deepEqual(
|
||||
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
new Cesium.Cartesian3(10, 0, 0),
|
||||
new Cesium.Cartesian3(20, 0, 0),
|
||||
];
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [
|
||||
positions[0],
|
||||
positions[0],
|
||||
]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||
positions[0],
|
||||
new Cesium.Cartesian3(5, 0, 0),
|
||||
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
overlayHost,
|
||||
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(publication.entries.length, 2);
|
||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const nepal = publication.entries.find(
|
||||
({ id }) => id === 'nepal-context-callout',
|
||||
);
|
||||
const incident = publication.entries.find(
|
||||
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||
);
|
||||
assert.equal(nepal.variant, 'label');
|
||||
assert.equal(nepal.title, 'NEPAL');
|
||||
assert.equal(nepal.anchorDot, true);
|
||||
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(incident.presentationScale(), 1);
|
||||
assert.equal(layer.getStats().status, 'nominal');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
});
|
||||
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||
|
||||
assert.equal(await layer.disable(), true);
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(
|
||||
overlayHost._test.clears.at(-1),
|
||||
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
|
||||
);
|
||||
assert.equal(layer.getStats().status, 'idle');
|
||||
});
|
||||
|
||||
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||
['nepal-context-halo'],
|
||||
);
|
||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||
assert.equal(nepalLabel.variant, 'label');
|
||||
assert.equal(nepalLabel.placement, 'right');
|
||||
assert.equal(nepalLabel.anchorDot, true);
|
||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
|
||||
[
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
],
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
overlayHost._test.publications.at(-1).entries
|
||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
'card',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||
['nepal-context-halo'],
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
||||
let nowMs = 0;
|
||||
let pulseFrame = null;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null, requestRender() {},
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
||||
scheduleFrame(callback) {
|
||||
pulseFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame() {},
|
||||
});
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
const event = JSON.parse(
|
||||
await readFile(
|
||||
new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(event.evidenceSpine.length, 16);
|
||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||
assert.equal(source.entities.values.length, 15);
|
||||
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
||||
const id = `bhote-koshi-place-${point.id}`;
|
||||
const pin = labels.find((entry) => entry.id === id);
|
||||
assert.ok(pin, point.id);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
|
||||
assert.equal(
|
||||
pin.title,
|
||||
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
|
||||
);
|
||||
assert.equal(pin.variant, 'label');
|
||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
|
||||
);
|
||||
nowMs = 349 + index * 650;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
nowMs += 1;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
|
||||
assert.equal(
|
||||
source.entities.values.filter(
|
||||
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
nowMs += 520;
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||
// A repeat halo cycle must not erase already revealed labels.
|
||||
nowMs = 350 + 15 * 650;
|
||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
||||
assert.equal(
|
||||
source.entities
|
||||
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
|
||||
.show.getValue(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
labels
|
||||
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
|
||||
.sourceAlpha(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
|
||||
false,
|
||||
);
|
||||
assert.equal(typeof pulseFrame, 'function');
|
||||
pulseFrame();
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
|
||||
assert.equal(
|
||||
source.entities.values.some((entity) =>
|
||||
entity.id.startsWith('bhote-koshi-place-'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => 0,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const titles = new Map(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.map(({ id, title }) => [id, title]),
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
|
||||
'01 · ВИД НА ОБРУШЕНИЕ',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-gyirong-border-gate'),
|
||||
'04 · ПОГРАНПЕРЕХОД',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
|
||||
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
|
||||
'14 · МОСТ BIDUR',
|
||||
);
|
||||
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
||||
const trigger = labels.find(
|
||||
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
|
||||
);
|
||||
const bidur = labels.find(
|
||||
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
|
||||
);
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
}
|
||||
assert.equal(path.polyline.clampToGround.getValue(), true);
|
||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
));
|
||||
assert.ok(
|
||||
path.polyline.material.color
|
||||
.getValue()
|
||||
.equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
),
|
||||
);
|
||||
assert.equal(path.show.getValue(), false);
|
||||
assert.equal(trigger.sourceAlpha(), 1);
|
||||
assert.equal(bidur.sourceAlpha(), 1);
|
||||
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
const halfwayCount = path.polyline.positions.getValue().length;
|
||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||
nowMs = 4250;
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.equal(
|
||||
path.polyline.positions.getValue().length,
|
||||
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||
);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||
// More observation pins must not extend or straighten the sourced route.
|
||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
||||
)));
|
||||
assert.deepEqual(
|
||||
path.polyline.positions.getValue(),
|
||||
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
||||
assert.equal(labels.length, 15);
|
||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
|
||||
assert.equal(
|
||||
pin.title,
|
||||
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||
);
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
||||
assert.equal(
|
||||
path.polyline.positions.getValue().length,
|
||||
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equals(
|
||||
path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
|
||||
),
|
||||
);
|
||||
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(
|
||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
||||
source.entities.getById(
|
||||
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
const record = publication.entries[0];
|
||||
|
||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
||||
'bhote-koshi-flood-path',
|
||||
'bhote-koshi-trigger-record-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
source.entities.values.map(({ id }) => id),
|
||||
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
|
||||
);
|
||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||
assert.deepEqual(record.details, [
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
viewer.camera = {
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights[0].duration, 4.6);
|
||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
|
||||
);
|
||||
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||
flights[0].complete();
|
||||
assert.equal(flights.length, 2);
|
||||
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
viewer.camera = {
|
||||
moveStart, moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
cancelDelay(id) { timers.delete(id); },
|
||||
cancelDelay(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const director = {
|
||||
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
|
||||
_clock: createPlaybackClock({
|
||||
isRunning: () => false,
|
||||
timingForShot: () => null,
|
||||
onProgress() {},
|
||||
}),
|
||||
_scenePacks: createDefaultScenePacks(),
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
await layer.enable(viewer);
|
||||
if (phase === 'move-end') moveStart.raiseEvent();
|
||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
||||
assert.equal(
|
||||
flights.length,
|
||||
oldFlights.length,
|
||||
'no post-Stop approach or orbit',
|
||||
);
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
1,
|
||||
'Stop preserves the visible locator',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
callbacks.at(-1)();
|
||||
const successor = flights.at(-1);
|
||||
const count = flights.length;
|
||||
callbacks[0]();
|
||||
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
|
||||
for (const flight of oldFlights) {
|
||||
flight.cancel();
|
||||
flight.complete();
|
||||
}
|
||||
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||
successor.complete();
|
||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
||||
} finally { await layer.destroy(); }
|
||||
assert.equal(
|
||||
flights.length,
|
||||
count + 1,
|
||||
'successor retains its own orbit',
|
||||
);
|
||||
} finally {
|
||||
await layer.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
const source = viewer._test.added[0];
|
||||
const borderFill = source.entities.getById('nepal-border-fill');
|
||||
const entries = overlayHost._test.publications.at(-1).entries;
|
||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
||||
const incident = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||
);
|
||||
const kathmandu = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-kathmandu',
|
||||
);
|
||||
const bhaktapur = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-bhaktapur',
|
||||
);
|
||||
const dhulikhel = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-dhulikhel',
|
||||
);
|
||||
|
||||
assert.equal(borderFill.show.getValue(), true);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
||||
variant === 'label' && anchorDot === true
|
||||
)));
|
||||
assert.ok(
|
||||
[kathmandu, bhaktapur, dhulikhel].every(
|
||||
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
|
||||
),
|
||||
);
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
assert.equal(dhulikhel.sourceAlpha(), 0);
|
||||
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
nowMs = 1300;
|
||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
|
||||
});
|
||||
try {
|
||||
await layer.enable(viewer);
|
||||
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
|
||||
const ids = viewer._test.added[0].entities.values.map(
|
||||
(entity) => entity.id,
|
||||
);
|
||||
assert.ok(ids.includes('nepal-context-halo'));
|
||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||
);
|
||||
assert.equal(layer.getStats().status, 'degraded');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const boundary = new Promise((resolve) => {
|
||||
releaseBoundary = resolve;
|
||||
});
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
||||
|
||||
const enabling = layer.enable(viewer);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('nepal-border-highlight'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(overlayHost._test.publications.length, 1);
|
||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications[0].entries.map(({ id }) => id),
|
||||
['nepal-context-callout'],
|
||||
);
|
||||
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
|
||||
releaseBoundary({
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
});
|
||||
await enabling;
|
||||
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
|
||||
assert.ok(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||
);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(overlayHost._test.publications.length, 2);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
||||
const viewer = viewerFixture();
|
||||
const controller = new AbortController();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const boundary = new Promise((resolve) => {
|
||||
releaseBoundary = resolve;
|
||||
});
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
||||
});
|
||||
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||
controller.abort();
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
|
||||
releaseBoundary({
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
],
|
||||
});
|
||||
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -77,6 +77,11 @@ import {
|
||||
CCTV_FOCUS_REQUEST_EVENT,
|
||||
activateCctvCameraFromWorldClick,
|
||||
} from '../cctvFocusRequest.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from '../layers/cctv/policy.js';
|
||||
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(
|
||||
@@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu
|
||||
}
|
||||
});
|
||||
|
||||
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
|
||||
const record = (id, feedType) => ({
|
||||
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
|
||||
billboard: {},
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
});
|
||||
const idleVideo = record('idle-hls', 'hls');
|
||||
const idleStill = record('idle-image', 'image');
|
||||
const activeVideo = record('active-hls', 'hls');
|
||||
const viewer = { entities: { add: (entity) => entity } };
|
||||
|
||||
try {
|
||||
// Disabled keeps the active video camera from opening a real projection;
|
||||
// icon colors are assigned either way.
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer,
|
||||
records: [idleVideo, idleStill, activeVideo],
|
||||
activeCameraId: activeVideo.camera.id,
|
||||
enabled: false,
|
||||
coverageMode: 'off',
|
||||
});
|
||||
refreshCoverageStyles();
|
||||
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
|
||||
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
|
||||
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
|
||||
} finally {
|
||||
_setCctvCoverageStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
||||
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
|
||||
assert.equal(kept.available, 5);
|
||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||
});
|
||||
|
||||
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
|
||||
const envSources = (prefix, count = 3) =>
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${prefix}-${i}`,
|
||||
name: `${prefix} ${i}`,
|
||||
lat: 30 + i * 0.01,
|
||||
lon: -97,
|
||||
url: `https://example.test/${prefix}-${i}.jpg`,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Let a background refresh that needs no timers run to completion. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
// Live packs run alongside the env pack, so one slow upstream can hold
|
||||
// the refresh open.
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const hung = [];
|
||||
let hang = false;
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
hang
|
||||
? new Promise((_, reject) => {
|
||||
hung.push(() => reject(new Error('upstream gave up')));
|
||||
})
|
||||
: Promise.reject(new Error('offline')),
|
||||
);
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Past the TTL, with every live pack's upstream hanging.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
hang = true;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
let timer;
|
||||
const served = await Promise.race([
|
||||
getSources(),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve('waited'), 200);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
assert.notEqual(
|
||||
served,
|
||||
'waited',
|
||||
'a stale request waited for the background refresh',
|
||||
);
|
||||
assert.deepEqual(
|
||||
served.map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Requests during the refresh share it rather than starting another.
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await pause();
|
||||
const inFlight = hung.length;
|
||||
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
|
||||
await getSources();
|
||||
await getSources();
|
||||
await pause();
|
||||
assert.equal(hung.length, inFlight, 'only one refresh ran');
|
||||
|
||||
// When the slow pack gives up, the refreshed catalog replaces the old one.
|
||||
hang = false;
|
||||
for (const giveUp of hung) giveUp();
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const ids = (await getSources()).map((s) => s.id);
|
||||
if (ids[0] === 'new-0') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
|
||||
process.env.CCTV_MAX_SOURCES = '8';
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
// Nine cameras over a cap of eight make every refresh warn; throwing from
|
||||
// that warning is how this test makes a refresh reject.
|
||||
let failRefresh = false;
|
||||
let failedRefreshes = 0;
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => {
|
||||
const text = args.join(' ');
|
||||
if (failRefresh && text.includes('exceeds cap')) {
|
||||
failedRefreshes += 1;
|
||||
throw new Error('refresh broke');
|
||||
}
|
||||
warnings.push(text);
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
assert.equal(old.length, 8);
|
||||
|
||||
// Past the TTL the refresh throws: the old catalog is still served.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = true;
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1);
|
||||
assert.equal(
|
||||
warnings.filter((w) => w.includes('background catalog refresh failed'))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
|
||||
// The failure is not retried on every request until the next TTL.
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
|
||||
|
||||
// After it, a refresh that works replaces the catalog. With no live packs
|
||||
// the refresh finishes before the request that started it returns.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = false;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
await getSources();
|
||||
await settle();
|
||||
const fresh = await getSources();
|
||||
assert.deepEqual(
|
||||
fresh.map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
// Trim notes stay with the list they describe, so a request served the
|
||||
// old list during the refresh never reports the new list's notes.
|
||||
assert.deepEqual(
|
||||
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
|
||||
[['env', 9, 8]],
|
||||
);
|
||||
assert.deepEqual(getSources.trimmedPacks(fresh), []);
|
||||
assert.deepEqual(getSources.trimmedPacks(), []);
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('offline'));
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
|
||||
// Past the TTL every pack comes back empty, as in an upstream outage.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
process.env.CCTV_SOURCES_JSON = '[]';
|
||||
assert.equal(await getSources(), old);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
if (warnings.some((w) => w.includes('source refresh returned empty')))
|
||||
break;
|
||||
await settle();
|
||||
}
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes('serving 3 stale cameras')),
|
||||
'the empty refresh kept the old catalog',
|
||||
);
|
||||
|
||||
// Until the next TTL, requests are served without touching any upstream.
|
||||
const fetchesAfterRefresh = fetches;
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(
|
||||
fetches,
|
||||
fetchesAfterRefresh,
|
||||
'an empty refresh reran at once',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_LOCATIONS_URL,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTarkteeSourcesFromDatex,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
@@ -84,3 +96,193 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
const images = parseTarkteeDatexImages(xml);
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
|
||||
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
|
||||
quiet(t);
|
||||
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||
<predefinedLocation id="loc-1" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
<predefinedLocation id="loc-2" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init?.redirect]);
|
||||
const body =
|
||||
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(seen, [
|
||||
[TARKTEE_LOCATIONS_URL, 'manual'],
|
||||
[TARKTEE_IMAGES_URL, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'ee-tarktee-42');
|
||||
assert.equal(cameras[0].name, 'Tallinn Ringtee');
|
||||
});
|
||||
|
||||
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
|
||||
quiet(t);
|
||||
const cancelled = { locations: false, images: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
if (isLocations) cancelled.locations = true;
|
||||
else cancelled.images = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: isLocations ? 302 : 200,
|
||||
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
|
||||
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
|
||||
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||
for (const delayedSibling of [false, true]) {
|
||||
await t.test(
|
||||
`${failedUrl}, delayed sibling: ${delayedSibling}`,
|
||||
async (t) => {
|
||||
quiet(t);
|
||||
let cancelled = false;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) === failedUrl) throw new TypeError('fetch failed');
|
||||
if (delayedSibling) await new Promise(setImmediate);
|
||||
return response;
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
cancelled,
|
||||
true,
|
||||
'fulfilled sibling body is cancelled',
|
||||
);
|
||||
} finally {
|
||||
await response.body.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
let siblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
siblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
siblingCancelled,
|
||||
true,
|
||||
'sibling 200 stream is cancelled on 503',
|
||||
);
|
||||
|
||||
let overflowSiblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
const padded =
|
||||
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
|
||||
return new Response(padded, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(padded.length) },
|
||||
});
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
overflowSiblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
overflowSiblingCancelled,
|
||||
true,
|
||||
'sibling stream is cancelled when the other feed exceeds the byte cap',
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||
const timeouts = [];
|
||||
const timeout = AbortSignal.timeout;
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
timeouts.push(ms);
|
||||
return timeout.call(AbortSignal, ms);
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
String(url).includes('roadCameraLocations')
|
||||
? SAMPLE_LOCATIONS_XML
|
||||
: SAMPLE_IMAGES_XML,
|
||||
),
|
||||
);
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(timeouts, [30_000, 30_000]);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
floridaCameraId,
|
||||
floridaCameraToSource,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyFloridaCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/';
|
||||
|
||||
/** A response with a live body, plus a flag that flips when it is cancelled. */
|
||||
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 };
|
||||
};
|
||||
|
||||
/** One FL511 feature, shaped like the live FeatureServer payload. */
|
||||
const feature = (overrides = {}) => ({
|
||||
attributes: {
|
||||
OBJECTID_1: 6,
|
||||
ID: '1220',
|
||||
DESCRIPT: 'I-75 @ MM 346.5 SB',
|
||||
COUNTY: 'Marion',
|
||||
HIGHWAY: 'I-75',
|
||||
DIRECTION: 'S',
|
||||
LATITUDE: 29.107363,
|
||||
LONGITUDE: -82.188334,
|
||||
IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
/** One catalog page of `count` distinct cameras, channels from `start`. */
|
||||
const page = (start, count, extra = {}) => ({
|
||||
features: Array.from({ length: count }, (_, i) =>
|
||||
feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }),
|
||||
),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const offsetOf = (url) =>
|
||||
Number(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
|
||||
/** Lift the pack cap for one test so every loaded camera comes back. */
|
||||
const uncapped = (t) => {
|
||||
const previous = process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES = '4200';
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES;
|
||||
else process.env.CCTV_FLORIDA_MAX_SOURCES = previous;
|
||||
});
|
||||
};
|
||||
|
||||
test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => {
|
||||
const source = floridaCameraToSource(feature());
|
||||
assert.equal(source.id, 'fl-9431');
|
||||
assert.equal(source.name, 'I-75 @ MM 346.5 SB');
|
||||
assert.equal(source.city, 'Marion County');
|
||||
assert.equal(source.cityId, 'florida');
|
||||
assert.equal(source.provider, 'FL511');
|
||||
assert.equal(source.lat, 29.107363);
|
||||
assert.equal(source.lon, -82.188334);
|
||||
assert.equal(source.headingDeg, 180);
|
||||
assert.equal(
|
||||
floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg,
|
||||
90,
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.equal(source.sourceKind, 'fl511-open-data');
|
||||
assert.match(source.license, /personal use only/);
|
||||
assert.match(source.license, /written consent/);
|
||||
});
|
||||
|
||||
test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => {
|
||||
const source = floridaCameraToSource(
|
||||
feature({ DIRECTION: 'NOT DIRECTIONAL' }),
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431'));
|
||||
});
|
||||
|
||||
test('rows outside Florida, off-host frames and unusable rows are dropped', () => {
|
||||
const dropped = [
|
||||
feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta
|
||||
feature({ LATITUDE: 0, LONGITUDE: 0 }),
|
||||
feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped
|
||||
feature({ LATITUDE: '', LONGITUDE: '' }),
|
||||
feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }),
|
||||
feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }),
|
||||
feature({
|
||||
IMAGE: 'https://user:[email protected]/DGI/chan-9431_h.jpg',
|
||||
}),
|
||||
feature({ IMAGE: 'not a url' }),
|
||||
{ attributes: null },
|
||||
null,
|
||||
];
|
||||
for (const row of dropped) assert.equal(floridaCameraToSource(row), null);
|
||||
});
|
||||
|
||||
test('camera ids come from the image channel, not the repeating ID column', () => {
|
||||
// FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera.
|
||||
const marion = floridaCameraToSource(feature());
|
||||
const broward = floridaCameraToSource(
|
||||
feature({
|
||||
DESCRIPT: 'I-95 SB before E Cypress Rd',
|
||||
COUNTY: 'Broward',
|
||||
LATITUDE: 26.22006,
|
||||
LONGITUDE: -80.13676,
|
||||
IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`,
|
||||
}),
|
||||
);
|
||||
assert.equal(marion.id, 'fl-9431');
|
||||
assert.equal(broward.id, 'fl-6351');
|
||||
// Any resolution suffix keys the same way.
|
||||
assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77');
|
||||
// A filename without a channel falls back to a stable path slug.
|
||||
assert.equal(
|
||||
floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'),
|
||||
'fl-us-1-20at-20sr-5',
|
||||
);
|
||||
assert.equal(floridaCameraId('not a url'), null);
|
||||
});
|
||||
|
||||
test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => {
|
||||
const inside = [
|
||||
[24.5551, -81.78], // Key West
|
||||
[30.4213, -87.2169], // Pensacola
|
||||
[30.99, -84.0], // Georgia line
|
||||
[25.7617, -80.1918], // Miami
|
||||
];
|
||||
const outside = [
|
||||
[33.749, -84.388], // Atlanta
|
||||
[25.0443, -77.3504], // Nassau
|
||||
[0, 0],
|
||||
[-81.78, 24.5551], // swapped
|
||||
[NaN, -81],
|
||||
];
|
||||
for (const [lat, lon] of inside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), true);
|
||||
for (const [lat, lon] of outside)
|
||||
assert.equal(isLikelyFloridaCoordinate(lat, lon), false);
|
||||
});
|
||||
|
||||
test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
assert.ok(String(url).startsWith(FLORIDA_CCTV_URL));
|
||||
calls.push([offsetOf(url), init.redirect]);
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
// Last page (no exceededTransferLimit): two new cameras and a repeat.
|
||||
return Response.json(page(1999, 3));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(calls, [
|
||||
[0, 'manual'],
|
||||
[2000, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 2002);
|
||||
assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002);
|
||||
});
|
||||
|
||||
test('pages shorter than requested keep paging while the server says more remain', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
uncapped(t);
|
||||
const offsets = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const params = new URL(String(url)).searchParams;
|
||||
// Offset paging is only stable over a fixed order on the object id.
|
||||
assert.equal(params.get('orderByFields'), 'OBJECTID_1');
|
||||
const offset = offsetOf(url);
|
||||
offsets.push(offset);
|
||||
// A layer whose max record count was lowered to 1,000.
|
||||
return Response.json(
|
||||
page(offset, offset < 2000 ? 1000 : 500, {
|
||||
exceededTransferLimit: offset < 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.deepEqual(offsets, [0, 1000, 2000]);
|
||||
assert.equal(cameras.length, 2500);
|
||||
});
|
||||
|
||||
test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
uncapped(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
});
|
||||
const cameras = await loadFloridaSourcesFromOpenData();
|
||||
assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES);
|
||||
assert.equal(cameras.length, 2000);
|
||||
assert.ok(warnings.some((line) => /page ceiling/.test(line)));
|
||||
});
|
||||
|
||||
test('a redirect on any page drops the whole pack and releases it', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: redirected.response,
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed later page keeps the pages already read', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
uncapped(t);
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
offsetOf(url) === 0
|
||||
? Response.json(page(0, 2000, { exceededTransferLimit: true }))
|
||||
: failed.response,
|
||||
);
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
// A network error on a later page keeps them too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (offsetOf(url) === 0)
|
||||
return Response.json(page(0, 2000, { exceededTransferLimit: true }));
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000);
|
||||
});
|
||||
|
||||
test('an oversized catalog page is refused', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(JSON.stringify(page(0, 1)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadFloridaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only turns out too long while streaming.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(FLORIDA_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 loadFloridaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve a two-camera FL511 page to the Florida 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.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2));
|
||||
return Response.json([]);
|
||||
});
|
||||
// No curated catalogs or ground-height sidecar, so only live lanes are in play.
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Put process.env back exactly as it was. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key in saved) continue;
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_FLORIDA_ENABLED = '1';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
'the catalog refresh invokes the Florida loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources
|
||||
.filter((s) => s.cityId === 'florida')
|
||||
.map((s) => s.id)
|
||||
.sort(),
|
||||
['fl-0', 'fl-1'],
|
||||
'Florida cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_FLORIDA_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'florida'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
hkCameraName,
|
||||
hkCameraToSource,
|
||||
hkImageUrlFromKey,
|
||||
loadHkSourcesFromOpenData,
|
||||
normalizeHkImageUrl,
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
HK_IMAGE_ORIGIN,
|
||||
HK_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
prioritizeSources,
|
||||
} 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('<image-list>'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One Transport Department `<image>` block as a parsed row. */
|
||||
const row = (overrides = {}) => ({
|
||||
key: 'BC101F',
|
||||
region: 'New Territories',
|
||||
district: 'North',
|
||||
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
|
||||
latitude: '22.508',
|
||||
longitude: '114.078',
|
||||
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Fixture XML matching the published English locations schema. */
|
||||
const fixtureXml = (...records) => {
|
||||
const images = records
|
||||
.map(
|
||||
(record) => `
|
||||
<image>
|
||||
<key>${record.key}</key>
|
||||
<region>${record.region}</region>
|
||||
<district>${record.district}</district>
|
||||
<description>${record.description}</description>
|
||||
<latitude>${record.latitude}</latitude>
|
||||
<longitude>${record.longitude}</longitude>
|
||||
<url>${record.url}</url>
|
||||
</image>`,
|
||||
)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
|
||||
};
|
||||
|
||||
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = hkCameraToSource(row());
|
||||
assert.equal(source.id, 'hk-bc101f');
|
||||
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
|
||||
assert.equal(source.cityId, 'hk');
|
||||
assert.equal(source.provider, 'Transport Department');
|
||||
assert.equal(source.lat, 22.508);
|
||||
assert.equal(source.lon, 114.078);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'hk-td-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: '' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
hkCameraToSource(
|
||||
row({
|
||||
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
|
||||
assert.equal(hkCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
|
||||
assert.equal(
|
||||
hkImageUrlFromKey('bc101f'),
|
||||
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
);
|
||||
assert.equal(hkImageUrlFromKey(''), null);
|
||||
assert.equal(hkImageUrlFromKey('bad/key'), null);
|
||||
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(normalizeHkImageUrl(''), null);
|
||||
assert.equal(normalizeHkImageUrl('not a url'), null);
|
||||
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
hkCameraName(
|
||||
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
|
||||
'hk-h429f',
|
||||
),
|
||||
'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
);
|
||||
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
|
||||
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
|
||||
});
|
||||
|
||||
test('the XML parser reads each image block', () => {
|
||||
const rows = parseHkCameraLocationsXml(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H429F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Southern',
|
||||
description: 'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
latitude: '22.24845',
|
||||
longitude: '114.1505',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].key, 'BC101F');
|
||||
assert.equal(rows[1].key, 'H429F');
|
||||
assert.equal(rows[1].latitude, '22.24845');
|
||||
});
|
||||
|
||||
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) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
row({ url: 'https://evil.example/x.jpg' }),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
const cameras = await loadHkSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest Central/TST first: Wan Chai before the northern New Territories.
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
|
||||
});
|
||||
|
||||
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 loadHkSourcesFromOpenData(), []);
|
||||
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 loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the description, trimmed to the code width', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
|
||||
'SAN TIN HIGHWAY [BC101F]',
|
||||
);
|
||||
const long = hkCameraToSource(
|
||||
row({
|
||||
description:
|
||||
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
|
||||
}),
|
||||
);
|
||||
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
|
||||
});
|
||||
|
||||
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.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[DEFAULT_HK_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 = fixtureXml(row());
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(HK_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 loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
|
||||
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('hk', DEFAULT_HK_MAX_SOURCES),
|
||||
],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['hk', 10],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the HK catalog to the HK 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.includes('Traffic_Camera_Locations_En.xml')) {
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the HK lane is wired into the catalog and its loader runs', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_HK_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
'the catalog refresh invokes the HK loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
'HK 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_HK_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_HK_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk'),
|
||||
[],
|
||||
);
|
||||
} 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);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES >= 1013,
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
|
||||
);
|
||||
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
|
||||
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
|
||||
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
|
||||
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
|
||||
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
|
||||
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
|
||||
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
|
||||
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
|
||||
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
|
||||
});
|
||||
|
||||
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
|
||||
const cameras = [
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'H001F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Central & Western',
|
||||
description: 'Central harbour [H001F]',
|
||||
latitude: '22.2819',
|
||||
longitude: '114.158',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'ST101F',
|
||||
region: 'New Territories',
|
||||
district: 'Sha Tin',
|
||||
description: 'Sha Tin Road [ST101F]',
|
||||
latitude: '22.3827',
|
||||
longitude: '114.19',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'K999F',
|
||||
region: 'Kowloon',
|
||||
district: 'Yau Tsim Mong',
|
||||
description: 'Far Kowloon filler [K999F]',
|
||||
latitude: '22.32',
|
||||
longitude: '114.18',
|
||||
}),
|
||||
),
|
||||
].filter(Boolean);
|
||||
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
|
||||
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
|
||||
'hk-h001f',
|
||||
'hk-st101f',
|
||||
]);
|
||||
});
|
||||
@@ -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,530 @@
|
||||
// /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 WEBM = {
|
||||
id: 'webm-fixture',
|
||||
name: 'WebM fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'webm',
|
||||
url: 'https://cams.upstream.invalid/live.webm',
|
||||
};
|
||||
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 = { ...res.headers, ...headers };
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
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,
|
||||
WEBM,
|
||||
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, headers = {}) => {
|
||||
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],
|
||||
);
|
||||
});
|
||||
|
||||
/** An upstream Response whose body records whether it was cancelled. */
|
||||
function trackedUpstream({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const state = { cancelled: false };
|
||||
const response = new Response(
|
||||
new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
),
|
||||
{ status, headers },
|
||||
);
|
||||
return { response, state };
|
||||
}
|
||||
|
||||
test('/media refuses an upstream type that does not match the feed kind', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current.response);
|
||||
for (const [camera, contentType, body] of [
|
||||
[VIDEO, 'text/html', '<!doctype html><p>x</p>'],
|
||||
[VIDEO, 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
[VIDEO, 'application/vnd.apple.mpegurl', '#EXTM3U'],
|
||||
[VIDEO, 'video/webm', 'video'],
|
||||
[VIDEO, undefined, 'video'],
|
||||
[WEBM, 'video/mp4', 'video'],
|
||||
[MJPEG, 'multipart/x-mixed-replace', '--frame'],
|
||||
[MJPEG, 'multipart/x-mixed-replace; boundary=', '--frame'],
|
||||
[MJPEG, 'image/jpeg', 'jpeg'],
|
||||
[MJPEG, 'text/html; boundary=frame', '<!doctype html><p>x</p>'],
|
||||
]) {
|
||||
current = trackedUpstream({
|
||||
headers: contentType === undefined ? {} : { 'content-type': contentType },
|
||||
body,
|
||||
});
|
||||
const label = `${camera.id}: ${contentType}`;
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 502, label);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json', label);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'Media proxy failed' });
|
||||
assert.equal(current.state.cancelled, true, `${label}: upstream cancelled`);
|
||||
const health = await app.health(camera.id);
|
||||
assert.equal(health.status, 'degraded', label);
|
||||
assert.equal(health.message, 'Unexpected media type', label);
|
||||
}
|
||||
});
|
||||
|
||||
test('/media relays each feed kind under its canonical type, including a partial response', async (t) => {
|
||||
let current;
|
||||
const app = mount(t, () => current);
|
||||
for (const [camera, upstream, expected] of [
|
||||
[VIDEO, { 'content-type': 'Video/MP4; codecs="avc1.42E01E"' }, 'video/mp4'],
|
||||
[
|
||||
VIDEO,
|
||||
{
|
||||
'content-type': 'video/mp4',
|
||||
'content-range': 'bytes 0-4/100',
|
||||
'content-length': '5',
|
||||
'accept-ranges': 'bytes',
|
||||
},
|
||||
'video/mp4',
|
||||
],
|
||||
[WEBM, { 'content-type': 'video/webm' }, 'video/webm'],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace;boundary="frame"' },
|
||||
'multipart/x-mixed-replace; boundary=frame',
|
||||
],
|
||||
[
|
||||
MJPEG,
|
||||
{ 'content-type': 'multipart/x-mixed-replace; boundary="a b"' },
|
||||
'multipart/x-mixed-replace; boundary="a b"',
|
||||
],
|
||||
]) {
|
||||
const status = upstream['content-range'] ? 206 : 200;
|
||||
current = upstreamResponse({ status, headers: upstream, body: 'bytes' });
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, status, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], expected);
|
||||
assert.equal(res.body, 'bytes');
|
||||
if (status === 206)
|
||||
assert.equal(res.headers['Content-Range'], 'bytes 0-4/100');
|
||||
assert.equal((await app.health(camera.id)).status, 'ok');
|
||||
}
|
||||
});
|
||||
|
||||
test('/media does not follow a live camera off its origin', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === VIDEO.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://elsewhere.invalid/live.mp4' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: 'elsewhere',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.doesNotMatch(res.body, /elsewhere/);
|
||||
assert.deepEqual(app.requests, [{ url: VIDEO.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('every CCTV route refuses cross-site requests and admits the app itself', async (t) => {
|
||||
const app = mount(t, ({ url }) =>
|
||||
upstreamResponse({
|
||||
headers: {
|
||||
'content-type': url === VIDEO.url ? 'video/mp4' : 'image/jpeg',
|
||||
},
|
||||
body: 'bytes',
|
||||
}),
|
||||
);
|
||||
const host = 'localhost:5173';
|
||||
const paths = [
|
||||
'/sources',
|
||||
'/health',
|
||||
`/stream/${STILL.id}`,
|
||||
`/frame/${STILL.id}`,
|
||||
`/media/${VIDEO.id}`,
|
||||
];
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'cross-site' },
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://elsewhere.example' },
|
||||
{ host, origin: 'null' },
|
||||
])
|
||||
for (const path of paths) {
|
||||
const res = await app.call(path, headers);
|
||||
assert.equal(res.statusCode, 403, `${path} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a refused request reached an upstream');
|
||||
for (const headers of [
|
||||
{ host, 'sec-fetch-site': 'same-origin' },
|
||||
{ host, origin: `http://${host}` },
|
||||
{},
|
||||
]) {
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4');
|
||||
}
|
||||
});
|
||||
|
||||
test('/frame falls back past a non-raster upstream and Street View frame', async (t) => {
|
||||
let streetViewType;
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/')
|
||||
? trackedUpstream({
|
||||
headers:
|
||||
streetViewType === undefined
|
||||
? {}
|
||||
: { 'content-type': streetViewType },
|
||||
body: 'street-view',
|
||||
}).response
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/svg+xml' },
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
}),
|
||||
);
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
for (const type of [
|
||||
'image/svg+xml',
|
||||
'text/html',
|
||||
'application/xml',
|
||||
undefined,
|
||||
]) {
|
||||
streetViewType = type;
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic', String(type));
|
||||
assert.doesNotMatch(res.body, /street-view/);
|
||||
}
|
||||
streetViewType = 'image/JPEG; charset=x';
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.body, 'street-view');
|
||||
});
|
||||
|
||||
test('a forwarded same-origin request reaches every CCTV route but not the Street View fallback', async (t) => {
|
||||
const app = mount(t, ({ url }) => {
|
||||
if (url.startsWith('https://maps.googleapis.com/'))
|
||||
return trackedUpstream({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'street-view',
|
||||
}).response;
|
||||
return url === VIDEO.url
|
||||
? upstreamResponse({ headers: { 'content-type': 'video/mp4' } })
|
||||
: upstreamResponse({ status: 503, body: '' });
|
||||
});
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
|
||||
const host = 'localhost:5173';
|
||||
const streetViewCalls = () =>
|
||||
app.requests.filter(({ url }) =>
|
||||
url.startsWith('https://maps.googleapis.com/'),
|
||||
).length;
|
||||
for (const forwarded of [
|
||||
{ 'x-forwarded-for': '203.0.113.9' },
|
||||
{ via: '1.1 proxy' },
|
||||
{ forwarded: 'for=203.0.113.9' },
|
||||
{ 'x-real-ip': '203.0.113.9' },
|
||||
{ 'x-forwarded-for': '' },
|
||||
{ 'x-forwarded-for': ' ' },
|
||||
{ 'cf-connecting-ip': '' },
|
||||
]) {
|
||||
const headers = { host, 'sec-fetch-site': 'same-origin', ...forwarded };
|
||||
const label = JSON.stringify(forwarded);
|
||||
assert.equal((await app.call('/sources', headers)).statusCode, 200, label);
|
||||
const media = await app.call(`/media/${VIDEO.id}`, headers);
|
||||
assert.equal(media.statusCode, 200, label);
|
||||
assert.equal(media.headers['Content-Type'], 'video/mp4', label);
|
||||
const frame = await app.call(`/frame/${STILL.id}`, headers);
|
||||
assert.equal(frame.statusCode, 200, label);
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'synthetic', label);
|
||||
assert.equal(frame.headers['Content-Type'], 'image/svg+xml', label);
|
||||
// Cross-site stays refused whatever the forwarding headers say.
|
||||
for (const path of ['/sources', `/frame/${STILL.id}`, `/media/${VIDEO.id}`])
|
||||
assert.equal(
|
||||
(
|
||||
await app.call(path, {
|
||||
...forwarded,
|
||||
host,
|
||||
'sec-fetch-site': 'cross-site',
|
||||
})
|
||||
).statusCode,
|
||||
403,
|
||||
`${label} ${path}`,
|
||||
);
|
||||
}
|
||||
assert.equal(streetViewCalls(), 0, 'a forwarded request spent the key');
|
||||
const direct = await app.call(`/frame/${STILL.id}`, {
|
||||
host,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(direct.headers['X-CCTV-Source'], 'streetview');
|
||||
assert.equal(direct.body, 'street-view');
|
||||
assert.equal(streetViewCalls(), 1);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyCataloniaCoordinate,
|
||||
toFiniteNumber,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
loadCaltransSourcesFromOpenData,
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTflSourcesFromOpenData,
|
||||
parseCataloniaXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.js';
|
||||
import { createModel } from '../layers/cctv/model.js';
|
||||
|
||||
/** Values `Number` reads as 0 (or 1) although they carry no number. */
|
||||
const ABSENT = [null, undefined, '', ' ', false, true];
|
||||
|
||||
/** Set env vars for one test and restore them afterwards. */
|
||||
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', () => {});
|
||||
}
|
||||
|
||||
/** The production browser model; only the catalog's pose pass is stubbed. */
|
||||
function browserModel() {
|
||||
const parts = {};
|
||||
parts.model = createModel({
|
||||
state: {},
|
||||
services: { focus: {} },
|
||||
parts,
|
||||
source: {},
|
||||
});
|
||||
return parts.model;
|
||||
}
|
||||
|
||||
/** The browser catalog over the production model. */
|
||||
function browserCatalog(model, cityPois = {}) {
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: cityPois } },
|
||||
parts: { model: { ...model, ensureCameraPose: () => {} } },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/cctv/sources through the real proxy middleware. */
|
||||
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);
|
||||
}
|
||||
|
||||
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
|
||||
const { safeNumber } = browserModel();
|
||||
for (const value of ABSENT) {
|
||||
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
|
||||
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
|
||||
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
|
||||
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
|
||||
}
|
||||
for (const [value, expected] of [
|
||||
[0, 0],
|
||||
['0', 0],
|
||||
[-17, -17],
|
||||
[' 12.5 ', 12.5],
|
||||
]) {
|
||||
assert.equal(toFiniteNumber(value, 99), expected);
|
||||
assert.equal(safeNumber(value, 99), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
|
||||
quiet(t);
|
||||
const pose = {
|
||||
headingDeg: null,
|
||||
pitchDeg: null,
|
||||
fovDeg: null,
|
||||
rangeM: null,
|
||||
mountHeightM: null,
|
||||
groundElevationM: null,
|
||||
};
|
||||
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
|
||||
CCTV_SOURCES_JSON: JSON.stringify([
|
||||
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
|
||||
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
|
||||
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
|
||||
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
|
||||
]),
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
|
||||
const model = browserModel();
|
||||
const cameras = browserCatalog(model, {
|
||||
austin: { name: 'Austin', groundElevation: 150 },
|
||||
}).buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
|
||||
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
|
||||
const camera = byId.get(id);
|
||||
assert.ok(camera, id);
|
||||
assert.equal(camera.headingDeg, model.headingFromId(id), id);
|
||||
assert.equal(camera.pitchDeg, -17, id);
|
||||
assert.equal(camera.fovDeg, 74, id);
|
||||
assert.equal(camera.rangeM, 700, id);
|
||||
assert.equal(camera.mountHeightM, 24, id);
|
||||
assert.equal(camera.groundElevationM, 150, id);
|
||||
}
|
||||
// No position is not a camera in the Gulf of Guinea.
|
||||
assert.equal(byId.has('pack-coords-null'), false);
|
||||
assert.equal(byId.has('pack-coords-blank'), false);
|
||||
});
|
||||
|
||||
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, 'tallinn.json');
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'tln-null-heading',
|
||||
lat: 59.437,
|
||||
lon: 24.753,
|
||||
headingDeg: null,
|
||||
groundElevationM: null,
|
||||
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
|
||||
},
|
||||
]),
|
||||
);
|
||||
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
|
||||
|
||||
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
|
||||
assert.equal(camera.groundElevationM, 15);
|
||||
});
|
||||
|
||||
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
|
||||
quiet(t);
|
||||
const jamCam = (id, lat, lon) => ({
|
||||
id: `JamCams_${id}`,
|
||||
commonName: `JamCam ${id}`,
|
||||
lat,
|
||||
lon,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{
|
||||
key: 'imageUrl',
|
||||
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
|
||||
},
|
||||
],
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json([
|
||||
jamCam('00001.00001', 51.5072, -0.1276),
|
||||
jamCam('00001.00002', null, null),
|
||||
jamCam('00001.00003', 51.5072, ''),
|
||||
]),
|
||||
);
|
||||
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['tfl-00001.00001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
|
||||
const feature = (coordinates) => `<gml:featureMember>
|
||||
<cite:cameres fid="cameres.1">
|
||||
<gml:coordinates>${coordinates}</gml:coordinates>
|
||||
<cite:link>https://example.org/cam.jpg</cite:link>
|
||||
</cite:cameres>
|
||||
</gml:featureMember>`;
|
||||
const [blank, valid] = parseCataloniaXml(
|
||||
feature(',41.5') + feature('2.17,41.39'),
|
||||
);
|
||||
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
|
||||
assert.ok(Number.isNaN(blank.lon));
|
||||
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
|
||||
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
|
||||
});
|
||||
|
||||
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
cctv: {
|
||||
inService: 'true',
|
||||
location: {
|
||||
latitude: '37.8044',
|
||||
longitude: '-122.2712',
|
||||
elevation: '',
|
||||
locationName: 'TV101 -- I-880 : Test Ave',
|
||||
},
|
||||
imageData: {
|
||||
static: {
|
||||
currentImageURL:
|
||||
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [camera] = await loadCaltransSourcesFromOpenData();
|
||||
assert.ok(camera);
|
||||
assert.equal(camera.groundElevationM, 150);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -259,3 +259,57 @@ test('rejected snapshot responses abort the upstream download', async () => {
|
||||
assert.equal(signal.aborted, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts only raster image types and sends their canonical type', async () => {
|
||||
const frame = (contentType, body = 'frame') => {
|
||||
const state = { cancelled: false };
|
||||
const headers = contentType === null ? {} : { 'Content-Type': contentType };
|
||||
const response = new Response(new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(body));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
}, { highWaterMark: 0 }), { status: 200, headers });
|
||||
return { response, state };
|
||||
};
|
||||
for (const [contentType, canonical] of [
|
||||
['image/jpeg', 'image/jpeg'],
|
||||
['image/JPEG; charset=x', 'image/jpeg'],
|
||||
[' Image/PNG ', 'image/png'],
|
||||
['image/gif', 'image/gif'],
|
||||
['image/webp;q=1', 'image/webp'],
|
||||
['image/avif', 'image/avif'],
|
||||
]) {
|
||||
const { response } = frame(contentType);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result?.contentType, canonical, contentType);
|
||||
assert.equal(result.body.toString(), 'frame');
|
||||
}
|
||||
for (const [contentType, body] of [
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['Image/SVG+XML; charset=utf-8', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['text/html; charset=utf-8', '<!doctype html><p>x</p>'],
|
||||
['application/xml', '<x/>'],
|
||||
['text/xml', '<x/>'],
|
||||
[null, 'frame'],
|
||||
['', 'frame'],
|
||||
['image', 'frame'],
|
||||
['image/', 'frame'],
|
||||
['image/jpeg/x', 'frame'],
|
||||
['image/jpeg, text/html', 'frame'],
|
||||
['application/octet-stream', 'frame'],
|
||||
]) {
|
||||
const { response, state } = frame(contentType, body);
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
fetchImpl: async () => response,
|
||||
});
|
||||
assert.equal(result, null, String(contentType));
|
||||
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Camera catalogs are pinned to their agency's image directory. The pin is a
|
||||
// parsed-URL check, not a string prefix, so a path that only starts with the
|
||||
// directory before dot segments, an encoded separator, credentials or another
|
||||
// port are resolved never counts as inside it, and a redirect on a pinned host
|
||||
// cannot leave the directory either.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadTflSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { pinnedCameraUrl } from '../../server/providers/cctv/normalize.js';
|
||||
|
||||
const BUCKET = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
|
||||
/** One available JamCam whose frame is at `imageUrl`. */
|
||||
const jamCam = (n, imageUrl) => ({
|
||||
id: `JamCams_00001.0000${n}`,
|
||||
commonName: `JamCam ${n}`,
|
||||
lat: 51.5072,
|
||||
lon: -0.1276,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{ key: 'imageUrl', value: imageUrl },
|
||||
],
|
||||
});
|
||||
|
||||
test('TfL JamCams keep only frames inside the official bucket, by parsed URL', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const accepted = [
|
||||
[`${BUCKET}00001.00001.jpg`, `${BUCKET}00001.00001.jpg`],
|
||||
[`${BUCKET}00001.00002.jpg?t=1`, `${BUCKET}00001.00002.jpg?t=1`],
|
||||
[
|
||||
'https://s3-eu-west-1.amazonaws.com:443/jamcams.tfl.gov.uk/00001.00003.jpg',
|
||||
`${BUCKET}00001.00003.jpg`,
|
||||
],
|
||||
];
|
||||
const rejected = [
|
||||
`${BUCKET}../other/x.jpg`,
|
||||
`${BUCKET}%2e%2e/other/x.jpg`,
|
||||
`${BUCKET}%2E%2E/other/x.jpg`,
|
||||
`${BUCKET}.%2e/other/x.jpg`,
|
||||
`${BUCKET}..%2fother/x.jpg`,
|
||||
`${BUCKET}..%5Cother/x.jpg`,
|
||||
`${BUCKET}..\\other\\x.jpg`,
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk-other/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com/other/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://user:[email protected]/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com:8443/jamcams.tfl.gov.uk/x.jpg',
|
||||
'http://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/x.jpg',
|
||||
'https://s3-eu-west-1.amazonaws.com.other.example/jamcams.tfl.gov.uk/x.jpg',
|
||||
'not a url',
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
[...accepted.map(([url]) => url), ...rejected].map((url, i) =>
|
||||
jamCam(i + 1, url),
|
||||
),
|
||||
),
|
||||
);
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.url),
|
||||
accepted.map(([, normalized]) => normalized),
|
||||
);
|
||||
});
|
||||
|
||||
test('a redirect on a pinned camera host stays inside the pinned directory', async () => {
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === `${BUCKET}00001.00001.jpg`
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of [
|
||||
'/jamcams.tfl.gov.uk.other/x.jpg',
|
||||
'/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/../other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/%2e%2e/other/x.jpg',
|
||||
'/jamcams.tfl.gov.uk/..%2fother/x.jpg',
|
||||
])
|
||||
assert.equal(
|
||||
await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo(location),
|
||||
),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
`${BUCKET}00001.00001.jpg`,
|
||||
{},
|
||||
redirectTo('/jamcams.tfl.gov.uk/00001.00002.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
// A host with no pinned directory keeps the same-origin rule alone.
|
||||
const unpinned = await fetchWithinHost(
|
||||
'https://cams.example/a/frame.jpg',
|
||||
{},
|
||||
async (url) =>
|
||||
url === 'https://cams.example/a/frame.jpg'
|
||||
? new Response(null, { status: 302, headers: { location: '/b/x.jpg' } })
|
||||
: new Response('frame'),
|
||||
);
|
||||
assert.equal(await unpinned.text(), 'frame');
|
||||
});
|
||||
|
||||
test('a pin prefix without a trailing slash still names a directory', () => {
|
||||
assert.equal(
|
||||
pinnedCameraUrl('https://h.example/camsX/a.jpg', 'https://h.example/cams'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTaiwanSources,
|
||||
parseTaiwanCctvXml,
|
||||
resetTaiwanSourcesCache,
|
||||
taiwanCameraToSource,
|
||||
taiwanFrame,
|
||||
TAIWAN_FREEWAY_CCTV_URL,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
TAIWAN_RETRY_AFTER_FAILURE_MS,
|
||||
} from '../../server/providers/cctv/taiwan.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
// Settings that would steer the loader or the catalog away from the mocks.
|
||||
const ISOLATED_ENV = [
|
||||
'CCTV_SOURCES_FILE',
|
||||
'CCTV_SOURCES_JSON',
|
||||
'CCTV_PREFER_AUSTIN',
|
||||
'CCTV_FORCE_AUSTIN',
|
||||
'CCTV_TAIWAN_ENABLED',
|
||||
'CCTV_TAIWAN_URL',
|
||||
'CCTV_TAIWAN_MAX_SOURCES',
|
||||
];
|
||||
|
||||
let savedEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTaiwanSourcesCache();
|
||||
savedEnv = Object.fromEntries(
|
||||
ISOLATED_ENV.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
for (const key of ISOLATED_ENV) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function cctvXml(...blocks) {
|
||||
return `<?xml version='1.0' encoding='utf-8'?>
|
||||
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
|
||||
<AuthorityCode>NFB</AuthorityCode>
|
||||
<CCTVs>${blocks.join('')}</CCTVs>
|
||||
</CCTVList>`;
|
||||
}
|
||||
|
||||
function block({
|
||||
id = 'CCTV-N1-S-0.000-M',
|
||||
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
lat = '25.1229931',
|
||||
lon = '121.735695',
|
||||
} = {}) {
|
||||
return `<CCTV>
|
||||
<CCTVID>${id}</CCTVID>
|
||||
<VideoStreamURL>${stream}</VideoStreamURL>
|
||||
<PositionLon>${lon}</PositionLon>
|
||||
<PositionLat>${lat}</PositionLat>
|
||||
<RoadName>國道1號</RoadName>
|
||||
<RoadDirection>S</RoadDirection>
|
||||
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
|
||||
<LocationMile>0K+000</LocationMile>
|
||||
</CCTV>`;
|
||||
}
|
||||
|
||||
test('parses the MOTC 2.0 table and registers the same camera still', () => {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block()));
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(camera.lat, 25.1229931);
|
||||
assert.equal(camera.lon, 121.735695);
|
||||
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
|
||||
assert.equal(camera.cityId, 'taiwan');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
camera.url,
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.match(camera.license, /Open Government Data License, Taiwan/);
|
||||
});
|
||||
|
||||
test('each camera server maps its stream path to its own still path only', () => {
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
|
||||
).frameUrl,
|
||||
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
).frameUrl,
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
|
||||
).frameUrl,
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
|
||||
);
|
||||
});
|
||||
|
||||
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
|
||||
for (const stream of [
|
||||
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
|
||||
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(taiwanFrame(stream), null, stream);
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
|
||||
assert.equal(taiwanCameraToSource(row), null, stream);
|
||||
}
|
||||
});
|
||||
|
||||
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
|
||||
for (const [lat, lon] of [
|
||||
['121.735695', '25.1229931'],
|
||||
['35.68', '139.76'],
|
||||
['', '121.7'],
|
||||
['NaN', '121.7'],
|
||||
]) {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
|
||||
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
|
||||
}
|
||||
assert.equal(taiwanCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
|
||||
const [row] = parseTaiwanCctvXml(
|
||||
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&(北)' })),
|
||||
);
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
|
||||
});
|
||||
|
||||
function mockCatalog(t, xml) {
|
||||
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 (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return new Response(xml, {
|
||||
headers: { 'content-type': 'application/xml' },
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||
mockCatalog(
|
||||
t,
|
||||
cctvXml(
|
||||
block(),
|
||||
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
|
||||
block({
|
||||
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
|
||||
lat: '24.1',
|
||||
lon: '120.6',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
|
||||
'tw-freeway-cctvc-495',
|
||||
'tw-freeway-cctvn-10000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const status of [302, 503]) {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(cctvXml(block()), {
|
||||
status,
|
||||
headers: status === 302 ? { location: 'https://evil.test/' } : {},
|
||||
}),
|
||||
);
|
||||
resetTaiwanSourcesCache();
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
}
|
||||
});
|
||||
|
||||
async function runCatalogWithTaiwanFlag(t, flag) {
|
||||
const requested = mockCatalog(t, cctvXml(block()));
|
||||
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
}
|
||||
|
||||
test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
|
||||
'taiwan',
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||
});
|
||||
|
||||
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block()) + padding),
|
||||
);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
resetTaiwanSourcesCache();
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
});
|
||||
|
||||
test('a failed download is not retried until the backoff ends', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
t.mock.method(Date, 'now', () => 1_000_000);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.equal(calls, 1, 'the second refresh made no request');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
|
||||
);
|
||||
await loadTaiwanSources();
|
||||
assert.equal(calls, 2, 'the backoff ends after an hour');
|
||||
});
|
||||
|
||||
test('a failed download serves the last good table', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block())),
|
||||
);
|
||||
const first = await loadTaiwanSources();
|
||||
assert.deepEqual(
|
||||
first.map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await loadTaiwanSources()).map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const rows = Array.from({ length: 3 }, (_, i) =>
|
||||
block({
|
||||
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
|
||||
}),
|
||||
);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(...rows)),
|
||||
);
|
||||
for (const [value, expected] of [
|
||||
['0', 1],
|
||||
['2', 2],
|
||||
['5000', 3],
|
||||
['abc', 3],
|
||||
]) {
|
||||
resetTaiwanSourcesCache();
|
||||
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
|
||||
const sources = await loadTaiwanSources();
|
||||
assert.equal(sources.length, expected, value);
|
||||
assert.equal(sources.available, 3, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('names fall back to the published ID when the row has no road details', () => {
|
||||
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
|
||||
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
|
||||
.replace('<LocationMile>0K+000</LocationMile>', '')
|
||||
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
|
||||
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
|
||||
assert.equal(camera.name, 'CCTV-N3-N-100-M');
|
||||
});
|
||||
|
||||
test('numeric character references in the table are decoded', () => {
|
||||
const xml = cctvXml(block()).replace(
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
);
|
||||
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
|
||||
});
|
||||
|
||||
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
|
||||
const camera = taiwanCameraToSource(
|
||||
parseTaiwanCctvXml(
|
||||
cctvXml(
|
||||
block({
|
||||
stream:
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
|
||||
}),
|
||||
),
|
||||
)[0],
|
||||
);
|
||||
assert.equal(
|
||||
camera.id,
|
||||
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,11 +2,24 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
applyVegvesenGroundHeights,
|
||||
clearKartverketHeightCache,
|
||||
fetchKartverketHeights,
|
||||
kartverketBackoffMs,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
KARTVERKET_HEIGHT_URL,
|
||||
KARTVERKET_CATALOG_WAIT_MS,
|
||||
KARTVERKET_CONCURRENCY,
|
||||
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||
KARTVERKET_MAX_POINTS,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
KARTVERKET_NULL_TTL_MS,
|
||||
KARTVERKET_USER_AGENT,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
clearKartverketHeightCache();
|
||||
t.after(clearKartverketHeightCache);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// The catalog first; the rest is the Kartverket height lookup.
|
||||
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
|
||||
// Two unique positions: one Kartverket request.
|
||||
assert.equal(requested.length, 2);
|
||||
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Answer a Kartverket request the way the live API does: echo every point,
|
||||
* with the height `heightFor(x, y)` returns (null = outside coverage). */
|
||||
const kartverketResponse = (url, heightFor) => {
|
||||
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
|
||||
});
|
||||
};
|
||||
|
||||
/** Cameras at distinct Norwegian positions. */
|
||||
const cameraAt = (i) => ({
|
||||
id: `no-vegvesen-${i}`,
|
||||
lat: 60 + i * 0.001,
|
||||
lon: 7 + i * 0.001,
|
||||
groundElevationM: 150,
|
||||
});
|
||||
|
||||
const withHeightsEnv = (t) => {
|
||||
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
clearKartverketHeightCache();
|
||||
t.after(() => {
|
||||
clearKartverketHeightCache();
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
|
||||
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
};
|
||||
|
||||
const sentPoints = (url) =>
|
||||
JSON.parse(new URL(url).searchParams.get('punkter'));
|
||||
|
||||
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push(String(url));
|
||||
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
|
||||
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
|
||||
// Redirects are refused, so a request can never leave the fixed host.
|
||||
assert.equal(init.redirect, 'error');
|
||||
// Reply in reverse order: matching must not depend on list position.
|
||||
const response = kartverketResponse(url, (x) =>
|
||||
x > 7.04 ? null : Math.round((x - 7) * 10000),
|
||||
);
|
||||
const body = await response.json();
|
||||
body.punkter.reverse();
|
||||
return Response.json(body);
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(failure, null);
|
||||
// One pass, no retries: 20 + 20 + 10.
|
||||
assert.deepEqual(
|
||||
requested.map((url) => sentPoints(url).length),
|
||||
[KARTVERKET_MAX_POINTS, 20, 10],
|
||||
);
|
||||
for (const url of requested) {
|
||||
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
|
||||
}
|
||||
assert.equal(heights.size, 50);
|
||||
assert.equal(heights.get('7.010000,60.010000'), 100);
|
||||
// Past x = 7.04 the API has no height: recorded as null, never as 0.
|
||||
assert.equal(heights.get('7.049000,60.049000'), null);
|
||||
});
|
||||
|
||||
test('only the points a request asked about are recorded', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: [
|
||||
...points.map(([x, y]) => ({ x, y, z: 10 })),
|
||||
{ x: 7.5, y: 60.5, z: 999 }, // never asked
|
||||
],
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
|
||||
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
|
||||
});
|
||||
|
||||
test('either water marker counts as the surface', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const [a, b, c] = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: [
|
||||
{
|
||||
x: a[0],
|
||||
y: a[1],
|
||||
z: -547.1,
|
||||
datakilde: 'dybdekurver',
|
||||
terreng: null,
|
||||
},
|
||||
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
|
||||
{
|
||||
x: c[0],
|
||||
y: c[1],
|
||||
z: -0.2,
|
||||
datakilde: 'dtm1',
|
||||
terreng: 'ÅpentOmråde',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([
|
||||
cameraAt(0),
|
||||
cameraAt(1),
|
||||
cameraAt(2),
|
||||
]);
|
||||
assert.equal(heights.get('7.000000,60.000000'), 0);
|
||||
assert.equal(heights.get('7.001000,60.001000'), 0);
|
||||
// A quay just below the datum on land data stays as measured.
|
||||
assert.equal(heights.get('7.002000,60.002000'), -0.2);
|
||||
});
|
||||
|
||||
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
const zs = [-20, -20.5, 2500, 2500.5];
|
||||
const points = sentPoints(url);
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
|
||||
});
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights([
|
||||
cameraAt(0),
|
||||
cameraAt(1),
|
||||
cameraAt(2),
|
||||
cameraAt(3),
|
||||
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
|
||||
]);
|
||||
assert.equal(requested.length, 1);
|
||||
assert.equal(sentPoints(requested[0]).length, 4);
|
||||
assert.deepEqual(
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
heights.get(
|
||||
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
|
||||
),
|
||||
),
|
||||
[-20, null, 2500, null],
|
||||
);
|
||||
});
|
||||
|
||||
test('no more than eight requests are in flight', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return kartverketResponse(url, () => 1);
|
||||
});
|
||||
const { heights } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(heights.size, 400);
|
||||
assert.equal(peak, KARTVERKET_CONCURRENCY);
|
||||
});
|
||||
|
||||
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
// The first wave is already in flight when the first failure lands; nothing
|
||||
// after it starts, and no point is resent.
|
||||
assert.equal(calls, KARTVERKET_CONCURRENCY);
|
||||
assert.equal(heights.size, 0);
|
||||
assert.deepEqual(failure, {
|
||||
status: 429,
|
||||
retryAfter: '120',
|
||||
deadline: false,
|
||||
});
|
||||
});
|
||||
|
||||
/** A fetch that never answers, and rejects only when its signal aborts. */
|
||||
const hangingFetch = (url, init) => {
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
return new Promise((_, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason));
|
||||
});
|
||||
};
|
||||
|
||||
test('the lookup deadline bounds a hanging service', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
const started = Date.now();
|
||||
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||
timeoutMs: 30,
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
|
||||
assert.equal(heights.size, 0);
|
||||
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
|
||||
});
|
||||
|
||||
test('one hanging request times out on its own, as a failing service', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
|
||||
timeoutMs: 60_000,
|
||||
requestTimeoutMs: 20,
|
||||
});
|
||||
assert.deepEqual(failure, {
|
||||
status: null,
|
||||
retryAfter: null,
|
||||
deadline: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||
calls += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => resolve(kartverketResponse(url, () => 1)),
|
||||
40,
|
||||
);
|
||||
init.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(init.signal.reason);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
|
||||
cameraAt(i),
|
||||
),
|
||||
{ timeoutMs: 100 },
|
||||
);
|
||||
assert.equal(failure?.deadline, true);
|
||||
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
|
||||
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
|
||||
});
|
||||
|
||||
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const answers = {
|
||||
503: () => new Response('down', { status: 503 }),
|
||||
'not json': () =>
|
||||
new Response('<html>maintenance</html>', {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
oversized: () =>
|
||||
Response.json({
|
||||
punkter: [],
|
||||
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
|
||||
}),
|
||||
};
|
||||
for (const [label, answer] of Object.entries(answers)) {
|
||||
clearKartverketHeightCache();
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return answer();
|
||||
});
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
|
||||
assert.equal(heights.size, 0, label);
|
||||
assert.equal(failure?.deadline, false, label);
|
||||
assert.equal(failure?.status, label === '503' ? 503 : null, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
sentPoints(url)[0][0] === 7
|
||||
? new Response('bad point', { status: 400 })
|
||||
: kartverketResponse(url, () => 42),
|
||||
);
|
||||
const { heights, failure } = await fetchKartverketHeights(
|
||||
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
|
||||
);
|
||||
assert.equal(failure, null);
|
||||
assert.equal(heights.size, 40);
|
||||
assert.equal(heights.get('7.000000,60.000000'), null);
|
||||
assert.equal(heights.get('7.020000,60.020000'), 42);
|
||||
});
|
||||
|
||||
test('a point a 200 answer leaves out is cached as no height', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const points = sentPoints(url).slice(1); // drop the first point
|
||||
return Response.json({
|
||||
koordsys: 4258,
|
||||
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
|
||||
});
|
||||
});
|
||||
const cameras = [cameraAt(0), cameraAt(1)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
// The next refresh asks nothing: the left-out point is a cached miss.
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
|
||||
const now = Date.parse('2026-10-06T12:00:00Z');
|
||||
assert.equal(kartverketBackoffMs('120', now), 120_000);
|
||||
assert.equal(kartverketBackoffMs('5', now), 60_000);
|
||||
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
|
||||
assert.equal(
|
||||
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
|
||||
300_000,
|
||||
);
|
||||
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
|
||||
});
|
||||
|
||||
test('heights are cached, and misses are not asked again until they expire', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const asked = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
asked.push(sentPoints(url).length);
|
||||
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
|
||||
});
|
||||
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.groundElevationM),
|
||||
[1043.3, 1043.3, 150],
|
||||
);
|
||||
// The next refresh asks nothing: heights and the miss are both cached.
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
|
||||
now,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(asked, [3]);
|
||||
// Once the miss expires, only that point is asked again.
|
||||
clock += KARTVERKET_NULL_TTL_MS + 1;
|
||||
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
|
||||
assert.deepEqual(asked, [3, 1]);
|
||||
});
|
||||
|
||||
test('after a failure no lookup starts until the backoff ends', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
// The default backoff outlasts the catalog refresh, so the next refresh
|
||||
// and every one inside the backoff cost nothing.
|
||||
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
|
||||
clock += CCTV_SOURCE_CACHE_MS;
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
|
||||
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
|
||||
// After it, the failed point is asked again.
|
||||
clock += 2;
|
||||
fetchMock.mock.mockImplementation(async (url) =>
|
||||
kartverketResponse(url, () => 321),
|
||||
);
|
||||
const later = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
|
||||
assert.equal(later[0].groundElevationM, 321);
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('a 429 backs off for its Retry-After, not the default', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
const retryAfterS = 50 * 60;
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfterS) },
|
||||
}),
|
||||
);
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { now });
|
||||
assert.equal(fetchMock.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let clock = 1_000_000;
|
||||
const now = () => clock;
|
||||
t.mock.method(globalThis, 'fetch', hangingFetch);
|
||||
// The lookup runs out of time; that is not a failing service.
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], {
|
||||
now,
|
||||
timeoutMs: 10,
|
||||
waitMs: 1000,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 64),
|
||||
);
|
||||
clock += CCTV_SOURCE_CACHE_MS;
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
|
||||
assert.equal(cameras[0].groundElevationM, 64);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('overlapping refreshes share one lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 3);
|
||||
});
|
||||
await Promise.all([
|
||||
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
|
||||
]);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
release();
|
||||
});
|
||||
|
||||
test('a warm cache does not wait for a few new cameras', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 500),
|
||||
);
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 600);
|
||||
});
|
||||
const refresh = [...cameras, cameraAt(20)];
|
||||
const started = Date.now();
|
||||
assert.equal(
|
||||
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
|
||||
20,
|
||||
);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
// The background lookup fills the cache for the next refresh.
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [...cameras, cameraAt(20)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next), 21);
|
||||
assert.equal(next[20].groundElevationM, 600);
|
||||
});
|
||||
|
||||
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 9),
|
||||
);
|
||||
const cameras = [
|
||||
{ id: 'no-coordinates' },
|
||||
null,
|
||||
{ id: 'strings', lat: '60', lon: '7' },
|
||||
cameraAt(0),
|
||||
];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
|
||||
assert.equal(cameras[3].groundElevationM, 9);
|
||||
});
|
||||
|
||||
test('a lookup started before a cache clear cannot write after it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 111);
|
||||
});
|
||||
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
|
||||
clearKartverketHeightCache();
|
||||
t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 222),
|
||||
);
|
||||
const fresh = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
|
||||
assert.equal(fresh[0].groundElevationM, 222);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const later = [cameraAt(0)];
|
||||
await applyVegvesenGroundHeights(later, { waitMs: 0 });
|
||||
assert.equal(later[0].groundElevationM, 222);
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
|
||||
kartverketResponse(url, () => 5),
|
||||
);
|
||||
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
|
||||
const cameras = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
await gate;
|
||||
return kartverketResponse(url, () => 812);
|
||||
});
|
||||
const first = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
|
||||
assert.equal(first[0].groundElevationM, 150);
|
||||
release();
|
||||
// The lookup kept running and filled the cache for the next refresh.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const next = [cameraAt(0)];
|
||||
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
|
||||
assert.equal(next[0].groundElevationM, 812);
|
||||
});
|
||||
|
||||
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return kartverketResponse(url, () => 987.6);
|
||||
}
|
||||
return Response.json({ type: 'FeatureCollection', features: [feature()] });
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => [c.id, c.groundElevationM]),
|
||||
[['no-vegvesen-3000063_1', 987.6]],
|
||||
);
|
||||
});
|
||||
|
||||
test('the loader budgets the height wait from its own start', async (t) => {
|
||||
withHeightsEnv(t);
|
||||
const realNow = Date.now.bind(Date);
|
||||
let offset = 0;
|
||||
t.mock.method(Date, 'now', () => realNow() + offset);
|
||||
t.mock.method(globalThis, 'fetch', (url, init) => {
|
||||
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
|
||||
return hangingFetch(url, init);
|
||||
}
|
||||
// The catalog fetch uses up all but 10 ms of the lane's budget.
|
||||
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
|
||||
return Promise.resolve(
|
||||
Response.json({ type: 'FeatureCollection', features: [feature()] }),
|
||||
);
|
||||
});
|
||||
const started = performance.now();
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.ok(performance.now() - started < 1000);
|
||||
assert.equal(cameras[0].groundElevationM, 150);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
+143
-4
@@ -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)',
|
||||
@@ -225,7 +239,8 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
|
||||
'· provided as is, without warranty; actual road conditions may differ',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
@@ -234,22 +249,129 @@ 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: 'taiwan-cctv',
|
||||
html:
|
||||
'Freeway camera stills (Taiwan): ' +
|
||||
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
|
||||
'CCTV static data (MOTC 2.0), released under the ' +
|
||||
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
|
||||
},
|
||||
{
|
||||
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: 'florida-cctv',
|
||||
html:
|
||||
'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' +
|
||||
'Individual, personal use only under the ' +
|
||||
'<a href="https://fl511.com/privacy#disclaimer" target="_blank" rel="noopener">FL511 terms</a>; ' +
|
||||
"any other use needs FDOT's express written consent",
|
||||
},
|
||||
{
|
||||
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: 'hk-cctv',
|
||||
html:
|
||||
'Traffic snapshot cameras (Hong Kong): ' +
|
||||
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
|
||||
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
|
||||
},
|
||||
{
|
||||
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',
|
||||
'distributed by Statens vegvesen · camera ground heights © ' +
|
||||
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
|
||||
'modified: heights over water set to 0 m and rounded to 0.1 m',
|
||||
},
|
||||
{
|
||||
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: 'mapillary',
|
||||
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'google-street-view',
|
||||
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
|
||||
},
|
||||
{
|
||||
key: 'kartaview',
|
||||
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
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ú and Tembici Bogotá, operated by Tembici; ' +
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
|
||||
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
|
||||
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
|
||||
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
|
||||
},
|
||||
{
|
||||
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, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
key: 'osrm-routing',
|
||||
@@ -307,6 +429,13 @@ export const DATA_CREDITS = [
|
||||
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'sky-background',
|
||||
html:
|
||||
'Stars: <a href="https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50" target="_blank" rel="noopener">Yale Bright Star Catalogue</a> ' +
|
||||
'(Hoffleit & Warren, via CDS). Milky Way: <a href="https://www.eso.org/public/images/eso0932a/" target="_blank" rel="noopener">ESO/S. Brunier</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>; stars removed, blurred and reprojected)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html: 'Dams: Open Infrastructure Map',
|
||||
@@ -328,6 +457,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,24 @@ 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/);
|
||||
});
|
||||
|
||||
test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
for (const key of [
|
||||
'mapillary',
|
||||
'google-street-view',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
])
|
||||
assert.ok(
|
||||
DATA_CREDITS.some((entry) => entry.key === key),
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* Formats a speed in knots as a short rounded label in the UI language.
|
||||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||
* vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
|
||||
test('formatKnots writes the unit in the UI language', async () => {
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(formatKnots(9.5), '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(formatKnots(9.5), '10 kn');
|
||||
});
|
||||
+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;
|
||||
}
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
createApplicationDirectionsLayer,
|
||||
} from './directions.js';
|
||||
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
|
||||
import { instructionFor } from './routeSteps.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
directionsInstruction,
|
||||
routeDistanceText,
|
||||
routeDurationText,
|
||||
} from '../layers/directions/index.js';
|
||||
import {
|
||||
claimPointer,
|
||||
isPointerFree,
|
||||
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
|
||||
lastUpdate: 5,
|
||||
});
|
||||
assert.equal(ready.count, 3);
|
||||
assert.equal(ready.coverage, '21 km · 25 min · Drive');
|
||||
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
|
||||
assert.equal(
|
||||
ready.loadingLabel,
|
||||
'21 km · 25 min · Drive',
|
||||
'21\u00a0km · 25\u00a0min · Drive',
|
||||
'the summary is the row detail line',
|
||||
);
|
||||
assert.equal(ready.loading, undefined);
|
||||
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
|
||||
const copy = directionsStepCopy(route.steps, 1);
|
||||
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
||||
assert.deepEqual(copy.details, [
|
||||
'Step 2 of 3 · then 240 m · 39 s',
|
||||
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
|
||||
'Then: Arrive at your destination',
|
||||
]);
|
||||
const last = directionsStepCopy(route.steps, 2);
|
||||
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.items.map((item) => item.lead),
|
||||
['8 m', '240 m', '0 m'],
|
||||
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.items.map((item) => item.params),
|
||||
@@ -733,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
|
||||
pointerBlockedMessage('measure', 'a'),
|
||||
'measure is active — turn measure off, then set A',
|
||||
);
|
||||
// A registered owner without an exit is named by its tool, not its id.
|
||||
assert.equal(
|
||||
pointerBlockedMessage('recent-imagery-box', 'a'),
|
||||
'Recent imagery is active — turn Recent imagery off, then set A',
|
||||
);
|
||||
// Nothing to say when nothing is in the way, or when we are the holder.
|
||||
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
||||
assert.equal(pointerBlockedMessage('', 'a'), null);
|
||||
@@ -773,3 +785,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
|
||||
assert.equal(toasts.length, 1, 'a successful arming is silent');
|
||||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||||
});
|
||||
|
||||
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
|
||||
const types = [
|
||||
'depart',
|
||||
'arrive',
|
||||
'turn',
|
||||
'new name',
|
||||
'continue',
|
||||
'end of road',
|
||||
'fork',
|
||||
'merge',
|
||||
'on ramp',
|
||||
'off ramp',
|
||||
'roundabout',
|
||||
'rotary',
|
||||
'roundabout turn',
|
||||
'exit roundabout',
|
||||
'exit rotary',
|
||||
'use lane',
|
||||
'notification',
|
||||
'something new',
|
||||
];
|
||||
const modifiers = [
|
||||
null,
|
||||
'left',
|
||||
'right',
|
||||
'slight left',
|
||||
'slight right',
|
||||
'sharp left',
|
||||
'sharp right',
|
||||
'straight',
|
||||
'uturn',
|
||||
];
|
||||
const roads = [
|
||||
{ name: '', ref: null },
|
||||
{ name: 'Congress Avenue', ref: null },
|
||||
{ name: 'Mannerheimintie', ref: 'E12' },
|
||||
{ name: '', ref: 'I 35' },
|
||||
];
|
||||
for (const type of types)
|
||||
for (const modifier of modifiers)
|
||||
for (const road of roads)
|
||||
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
|
||||
const step = { type, modifier, exit, ...road };
|
||||
assert.equal(
|
||||
directionsInstruction(step),
|
||||
instructionFor(step),
|
||||
JSON.stringify(step),
|
||||
);
|
||||
}
|
||||
// An older payload without a maneuver type keeps the proxy's sentence.
|
||||
assert.equal(
|
||||
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
|
||||
'Head out on Kaivokatu',
|
||||
);
|
||||
});
|
||||
|
||||
test('route numbers follow the page language and never break from their unit', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||
assert.equal(routeDistanceText(-1), '');
|
||||
assert.equal(routeDurationText(Number.NaN), '');
|
||||
|
||||
setLocale('ru');
|
||||
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
|
||||
assert.equal(routeDurationText(240), '4\u00a0мин');
|
||||
assert.equal(
|
||||
directionsInstruction({
|
||||
type: 'turn',
|
||||
modifier: 'right',
|
||||
name: 'East 7th Street',
|
||||
}),
|
||||
'Поверните направо на East 7th Street',
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
|
||||
'На кольце сверните на 2-й съезд',
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,6 +79,12 @@ test('flights analyst record: full record maps every contract field', () => {
|
||||
aircraftClass: 'airliner',
|
||||
originCountry: 'United States',
|
||||
operator: 'Southwest Airlines',
|
||||
squawk: null,
|
||||
emergency: false,
|
||||
emergencyKind: null,
|
||||
emergencySource: null,
|
||||
emergencyBroadcast: null,
|
||||
lastContactAtMs: null,
|
||||
routeOrigin: 'AUS',
|
||||
routeDestination: 'LAX',
|
||||
});
|
||||
@@ -720,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
|
||||
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
|
||||
/** Seed one civil contact through the production refresh seam. */
|
||||
function seedLabelContact({ callsign, registration, tracked = false }) {
|
||||
function seedLabelContact({
|
||||
callsign,
|
||||
registration,
|
||||
tracked = false,
|
||||
// Per-test metadata overrides, spread last so a test can set a field to
|
||||
// `undefined` to model "the feed never sent it" — a default parameter
|
||||
// could not express that, it would just re-supply the default.
|
||||
meta: metaOverrides = {},
|
||||
}) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: LABEL_ICAO,
|
||||
entity: null,
|
||||
@@ -746,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.21,
|
||||
rawLon: -97.71,
|
||||
...metaOverrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -819,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
|
||||
});
|
||||
|
||||
test('the tracked descriptor publishes the reported vertical rate', () => {
|
||||
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
|
||||
// seam did not, so a consumer that needs climb/descent (a flight-phase
|
||||
// classifier, the cockpit) had to reach past getTrackedInfo() into the
|
||||
// raw record. Both seams now answer with the same field name and unit.
|
||||
seedLabelContact({
|
||||
callsign: 'SWA696',
|
||||
registration: 'N123AB',
|
||||
tracked: true,
|
||||
meta: { verticalRate: -6.4 },
|
||||
});
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
|
||||
assert.equal(
|
||||
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
|
||||
-6.4,
|
||||
'the analyst seam and the tracked seam must agree',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unreported vertical rate stays null, never a level-flight zero', () => {
|
||||
// Absent is not level. `stickyNumber` seeds this field with null, so a
|
||||
// contact that has never transmitted a rate must not read as 0 m/s — a
|
||||
// phase classifier would call that cruise while the aircraft climbs.
|
||||
for (const missing of [undefined, null, Number.NaN]) {
|
||||
seedLabelContact({
|
||||
callsign: 'SWA696',
|
||||
registration: 'N123AB',
|
||||
tracked: true,
|
||||
meta: { verticalRate: missing },
|
||||
});
|
||||
assert.equal(
|
||||
flightsLayer.getTrackedInfo()?.verticalRateMps,
|
||||
null,
|
||||
`vertical rate ${String(missing)} must publish as null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('civil label chain: identity stays icao24 while the label moves', () => {
|
||||
for (const fixture of [
|
||||
{ callsign: 'SWA696', registration: 'N123AB' },
|
||||
|
||||
@@ -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 }),
|
||||
@@ -268,3 +304,61 @@ test('GBFS rejects redirects and caps bodies through both server hooks', async (
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('GBFS relays only JSON, re-serialized under a fixed type, through both server hooks', async () => {
|
||||
const { gbfsProxy } = await import('../../server/providers/gbfs.js');
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
let handler;
|
||||
gbfsProxy()[hook]({
|
||||
middlewares: {
|
||||
use(_path, callback) {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = async (response) => {
|
||||
globalThis.fetch = async () => response;
|
||||
const res = {};
|
||||
await handler(
|
||||
{ method: 'GET', url: '/' + encodeURIComponent(STATION_URL) },
|
||||
{
|
||||
writeHead(code, headers) {
|
||||
Object.assign(res, { status: code, headers });
|
||||
},
|
||||
end(value) {
|
||||
res.body = value;
|
||||
},
|
||||
},
|
||||
);
|
||||
return res;
|
||||
};
|
||||
for (const [contentType, body] of [
|
||||
['text/html', '<!doctype html><p>x</p>'],
|
||||
['application/json', '<!doctype html><p>x</p>'],
|
||||
['image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
||||
['application/json', ''],
|
||||
]) {
|
||||
const res = await request(
|
||||
new Response(body, { headers: { 'Content-Type': contentType } }),
|
||||
);
|
||||
assert.equal(res.status, 502, `${hook}: ${contentType} ${body}`);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.equal(typeof JSON.parse(res.body).error, 'string');
|
||||
assert.doesNotMatch(res.body, /doctype|svg/);
|
||||
}
|
||||
const res = await request(
|
||||
new Response(' { "data" : { "stations" : [] } } ', {
|
||||
headers: { 'Content-Type': 'text/html; charset=iso-8859-1' },
|
||||
}),
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
|
||||
assert.equal(res.headers['Cache-Control'], 'no-store');
|
||||
assert.equal(res.body, STATION_BODY);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// GBFS PROXY — the frequency the allowlist never bounded.
|
||||
//
|
||||
// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a
|
||||
// station_information/station_status path, a 12 s timeout, a 5 MB body cap and
|
||||
// no redirects. Nothing said HOW OFTEN, so the dev server would relay an
|
||||
// unbounded stream of requests at a third-party bikeshare operator on any
|
||||
// caller's behalf — one outbound fetch and one timeout timer each.
|
||||
//
|
||||
// These cases drive the real mounted middleware. `fetch` is stubbed per test
|
||||
// and restored afterwards, so a case that reaches the network fails loudly,
|
||||
// and the stub's call count is what proves a refused request costs nothing.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js';
|
||||
|
||||
const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
|
||||
const STATION_BODY = '{"data":{"stations":[]}}';
|
||||
|
||||
/** Mount the proxy the way the dev server does; returns its route handler. */
|
||||
function mount(options) {
|
||||
const routes = new Map();
|
||||
gbfsProxy(options).configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return routes.get('/api/gbfs');
|
||||
}
|
||||
|
||||
function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = {
|
||||
method: method || 'GET',
|
||||
// connect strips the mount prefix before the handler sees the URL.
|
||||
url: '/' + encodeURIComponent(target),
|
||||
headers: {},
|
||||
socket: remoteAddress ? { remoteAddress } : undefined,
|
||||
};
|
||||
let status = 200;
|
||||
let headers = {};
|
||||
const res = {
|
||||
writeHead(code, sent) {
|
||||
status = code;
|
||||
headers = sent || {};
|
||||
return res;
|
||||
},
|
||||
end(body = '') {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = body ? JSON.parse(String(body)) : null;
|
||||
} catch {
|
||||
parsed = String(body);
|
||||
}
|
||||
resolve({ status, headers, body: parsed });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Answer every allowlisted upstream, counting the calls that got through. */
|
||||
function stubUpstream(t) {
|
||||
const asked = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
asked.push(String(url));
|
||||
return new Response(STATION_BODY, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
return asked;
|
||||
}
|
||||
|
||||
test('a maximal real session is never throttled', async (t) => {
|
||||
const asked = stubUpstream(t);
|
||||
const handler = mount();
|
||||
|
||||
// The bikeshare layer catalogues 15 systems and refreshes each one's status
|
||||
// once every 60 s, so a minute of the busiest possible session is ~15
|
||||
// requests. Run eight times that and expect every one of them served.
|
||||
let served = 0;
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
const res = await request(handler);
|
||||
if (res.status === 200) served += 1;
|
||||
}
|
||||
assert.equal(served, 120, 'the shipped ceiling leaves a real session alone');
|
||||
assert.equal(asked.length, 120);
|
||||
assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on');
|
||||
});
|
||||
|
||||
test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => {
|
||||
const asked = stubUpstream(t);
|
||||
const handler = mount({ ratePerMinute: 5 });
|
||||
|
||||
const responses = [];
|
||||
for (let i = 0; i < 12; i += 1) responses.push(await request(handler));
|
||||
|
||||
const served = responses.filter((r) => r.status === 200);
|
||||
const refused = responses.filter((r) => r.status === 429);
|
||||
assert.equal(served.length, 5, 'exactly the admitted budget is served');
|
||||
assert.equal(refused.length, 7);
|
||||
assert.equal(refused[0].body.error, 'Rate limit exceeded');
|
||||
assert.equal(refused[0].headers['Retry-After'], '10');
|
||||
assert.equal(refused[0].headers['Cache-Control'], 'no-store');
|
||||
assert.equal(
|
||||
asked.length,
|
||||
5,
|
||||
'a refused request never reaches the GBFS operator',
|
||||
);
|
||||
});
|
||||
|
||||
test('a different client keeps its own budget', async (t) => {
|
||||
stubUpstream(t);
|
||||
const handler = mount({ ratePerMinute: 3 });
|
||||
|
||||
for (let i = 0; i < 6; i += 1)
|
||||
await request(handler, { remoteAddress: '10.0.0.1' });
|
||||
const noisy = await request(handler, { remoteAddress: '10.0.0.1' });
|
||||
assert.equal(noisy.status, 429, 'the noisy client spent its own budget');
|
||||
|
||||
const quiet = await request(handler, { remoteAddress: '10.0.0.2' });
|
||||
assert.equal(quiet.status, 200, 'a quiet client is not collateral');
|
||||
});
|
||||
|
||||
test('the limiter does not displace the existing guards', async (t) => {
|
||||
const asked = stubUpstream(t);
|
||||
const handler = mount({ ratePerMinute: 10 });
|
||||
|
||||
const wrongMethod = await request(handler, { method: 'POST' });
|
||||
assert.equal(wrongMethod.status, 405);
|
||||
|
||||
const offAllowlist = await request(handler, {
|
||||
target: 'https://example.com/gbfs/station_status.json',
|
||||
});
|
||||
assert.equal(offAllowlist.status, 403);
|
||||
assert.equal(offAllowlist.body.error, 'GBFS host not allowed');
|
||||
|
||||
const wrongPath = await request(handler, {
|
||||
target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json',
|
||||
});
|
||||
assert.equal(wrongPath.status, 400);
|
||||
|
||||
assert.equal(asked.length, 0, 'none of those reached upstream');
|
||||
});
|
||||
+58
-5
@@ -4,10 +4,36 @@ 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.nextbike.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 +54,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 +100,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));
|
||||
});
|
||||
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
|
||||
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
|
||||
});
|
||||
|
||||
// --- Antimeridian ------------------------------------------------------------
|
||||
// Raw longitude arithmetic walks a segment across 180° the long way round and
|
||||
// reads a contact on the seam as half a world from its previous cell.
|
||||
|
||||
test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
|
||||
for (const [from, to, across, behind] of [
|
||||
[179.999, -179.998, -179.999, 179.998],
|
||||
[-179.999, 179.998, 179.999, -179.998],
|
||||
]) {
|
||||
const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
|
||||
const lons = cells.map((c) => c.lon);
|
||||
assert.equal(lons[0], from);
|
||||
assert.equal(lons.at(-1), to);
|
||||
assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
|
||||
assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
|
||||
assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('stickyFloorCell holds across the antimeridian', () => {
|
||||
for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
|
||||
const prev = { lat: -16.69, lon: prevLon };
|
||||
assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
|
||||
}
|
||||
});
|
||||
|
||||
// --- F2: corridor budget fairness ------------------------------------------
|
||||
// The first cut spent the budget in Map insertion order and decremented it
|
||||
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
/** Explain mapped-site availability without claiming an unobserved overload. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const reasons = {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
// layers/installations/controls.js translates describeInstallationFeedback().
|
||||
const REASONS = Object.freeze({
|
||||
rate_limited: msg('Overpass rate-limited'),
|
||||
timeout: msg('Overpass timed out'),
|
||||
query_failed: msg('Overpass could not complete the query'),
|
||||
tiles_unavailable: msg('Map tiles temporarily unavailable'),
|
||||
names_unavailable: msg('Mapped names temporarily unavailable'),
|
||||
});
|
||||
const FALLBACK_REASON = msg('Overpass temporarily unavailable');
|
||||
|
||||
/**
|
||||
* Mapped-site availability as a message to render, without claiming an
|
||||
* unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is
|
||||
* English (`{reason}` / `{seconds}` placeholders, `reason` English too), or
|
||||
* `{ count, km }` for a found-sites count (`km` is null for the view).
|
||||
* @param {object} [stats]
|
||||
* @param {number} [now]
|
||||
* @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}}
|
||||
*/
|
||||
export function describeInstallationFeedback(stats = {}, now = Date.now()) {
|
||||
const reason = REASONS[stats.failureReason] || FALLBACK_REASON;
|
||||
if (stats.loading)
|
||||
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
return {
|
||||
text: stats.retrying
|
||||
? msg('Retrying mapped sites…')
|
||||
: msg('Fetching mapped sites…'),
|
||||
};
|
||||
if (stats.retryAt > 0) {
|
||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
||||
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
|
||||
return seconds
|
||||
? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds }
|
||||
: { text: msg('{reason} — retry pending'), reason };
|
||||
}
|
||||
if (stats.status === 'unavailable') return reason;
|
||||
if (stats.status === 'unavailable') return { text: reason };
|
||||
if (stats.status === 'zoom-in')
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
return { text: msg('Zoom in to search mapped installations') };
|
||||
if (stats.stale) return { text: msg('Showing cached mapped sites') };
|
||||
if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') };
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
@@ -27,9 +45,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
return { count: stats.count, km: km || null };
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
return { text: msg('Mapped sites loaded') };
|
||||
}
|
||||
|
||||
/** Explain mapped-site availability in English. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const feedback = describeInstallationFeedback(stats, now);
|
||||
if (feedback.text !== undefined)
|
||||
return feedback.text
|
||||
.replace('{reason}', feedback.reason)
|
||||
.replace('{seconds}', feedback.seconds);
|
||||
const where = feedback.km
|
||||
? ` within ${feedback.km} km of the contact`
|
||||
: ' in view';
|
||||
if (feedback.count === 0) return `No mapped sites${where}`;
|
||||
return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* English names for installations without a mapped name. Data keeps them
|
||||
* English (server, AI tools, voice context); classLabel.js and
|
||||
* militaryAwarenessEngine.js translate them.
|
||||
*/
|
||||
|
||||
/** Name of an unnamed feature, by installation class. */
|
||||
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
|
||||
airfield: msg('Military airfield'),
|
||||
naval_base: msg('Naval base'),
|
||||
range: msg('Firing range'),
|
||||
military_land: msg('Military land'),
|
||||
});
|
||||
|
||||
/** Name of an unnamed feature without a usable class. */
|
||||
export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation');
|
||||
|
||||
/** Name of a military polygon the names pack has not named. */
|
||||
export const UNNAMED_MILITARY_AREA = msg('Military area');
|
||||
|
||||
/** Every name above, for the display to translate. */
|
||||
export const UNNAMED_PLACE_NAMES = Object.freeze([
|
||||
...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS),
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_MILITARY_AREA,
|
||||
]);
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
+35
-7
@@ -1,3 +1,6 @@
|
||||
// Restore labels are marked with msg(); ui/shareRestoration.js translates them.
|
||||
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
|
||||
const msg = (text) => text;
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
@@ -296,6 +299,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'], {
|
||||
@@ -317,10 +338,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',
|
||||
@@ -458,19 +476,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedFlightsTrackingId',
|
||||
expiryWindowMs: 90_000,
|
||||
label: 'flight',
|
||||
label: msg('flight'),
|
||||
}),
|
||||
military: Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedMilitaryTrackingId',
|
||||
expiryWindowMs: 45_000,
|
||||
label: 'military flight',
|
||||
label: msg('military flight'),
|
||||
}),
|
||||
satellites: Object.freeze({
|
||||
optionOwner: 'satellites',
|
||||
optionKey: 'selectedSatTrackingId',
|
||||
expiryWindowMs: 300_000,
|
||||
label: 'satellite',
|
||||
label: msg('satellite'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -567,6 +585,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',
|
||||
@@ -607,6 +630,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',
|
||||
|
||||
@@ -202,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, 29);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
|
||||
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, {
|
||||
@@ -242,19 +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(), '3');
|
||||
assert.equal(nextLayerStateToken(), '5');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '3',
|
||||
bravo: '4',
|
||||
alpha: '5',
|
||||
bravo: '6',
|
||||
}),
|
||||
'5',
|
||||
'7',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '3',
|
||||
next: '4',
|
||||
future: '5',
|
||||
next: '6',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 3/,
|
||||
/next free token 5/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
|
||||
),
|
||||
/next free token 4/,
|
||||
/next free token 6/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
@@ -2422,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: {
|
||||
@@ -2454,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,
|
||||
@@ -2639,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, [
|
||||
@@ -2703,13 +2768,19 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
|
||||
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||
});
|
||||
|
||||
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||
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(
|
||||
|
||||
@@ -128,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, 29);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
|
||||
{
|
||||
...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(
|
||||
@@ -151,9 +161,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
|
||||
.future,
|
||||
'3',
|
||||
'5',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -171,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', '3']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '5']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '4');
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '4',
|
||||
newcomer: '6',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -186,15 +196,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '3',
|
||||
competing: '5',
|
||||
}),
|
||||
/next free token 4/,
|
||||
/next free token 6/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '3',
|
||||
newcomer: '5',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -202,8 +212,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (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,
|
||||
@@ -224,8 +234,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '3']],
|
||||
[{ id: 'pr-b', token: '3' }],
|
||||
[...reservationRows, ['pr-b', '5']],
|
||||
[{ id: 'pr-b', token: '5' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -233,10 +243,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
|
||||
[
|
||||
{ id: 'pr-a', token: '3' },
|
||||
{ id: 'pr-b', token: '3' },
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '5' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -244,16 +254,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '4');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
|
||||
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: 4/);
|
||||
assert.match(helper.stdout, /pr-b: 6/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -261,20 +271,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
|
||||
[
|
||||
{ id: 'pr-a', token: '3' },
|
||||
{ id: 'pr-b', token: '4' },
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /30 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 = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -326,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 = [...'3456789'].map((digit) => [
|
||||
const priorDigits = [...'56789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -363,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, 69);
|
||||
assert.equal(params.get('l').length, 73);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -381,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, 30);
|
||||
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"],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Sky background
|
||||
|
||||
Catalogue stars over a soft Milky Way, drawn by `src/sky/starrySky.js` in
|
||||
place of Cesium's blocky Tycho-2 sky box.
|
||||
|
||||
| File | Content | Size |
|
||||
| ---- | ------- | ---- |
|
||||
| `bright_stars.json` | 9,096 stars: J2000 RA/Dec, V magnitude, B−V | 235 KB |
|
||||
| `milkyway_{px,nx,py,ny,pz,nz}.webp` | Milky Way glow, six 1024² cube faces, no stars | 62 KB |
|
||||
|
||||
## Stars — Yale Bright Star Catalogue
|
||||
|
||||
**Source:** 5th Revised Edition (Hoffleit & Warren 1991), CDS catalogue
|
||||
[V/50](https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50), file
|
||||
`https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz` (sha256
|
||||
`3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed`, fetched
|
||||
2026-10-19).
|
||||
|
||||
**License:** public domain. CDS asks for an acknowledgement; the "Data
|
||||
attribution" popover carries it.
|
||||
|
||||
**Build:** `node scripts/build-bright-stars.mjs [catalog.gz]`. Drops the 14
|
||||
records without a J2000 position; rounds RA/Dec to 0.001° and magnitudes to
|
||||
0.01.
|
||||
|
||||
## Milky Way — ESO/S. Brunier
|
||||
|
||||
**Source:** ESO image [eso0932a](https://www.eso.org/public/images/eso0932a/),
|
||||
`https://cdn.eso.org/images/original/eso0932a.tif` (6000×3000 sRGB TIFF, sha256
|
||||
`10f209ab83e1fd89e7fa1ed70277ffc6ed19c43549f04ec637c6806d98aff035`, fetched
|
||||
2026-10-19).
|
||||
|
||||
**License:** [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
||||
**Credit: ESO/S. Brunier.**
|
||||
|
||||
**Changes made** (CC BY 4.0 asks for them): resampled to 4096×2048; stars
|
||||
removed; two images of Jupiter painted over; blurred (0.4°, 2° in the faint
|
||||
glow); reprojected to ICRF cube faces; black level at the median sky; saved as
|
||||
WebP. Build: `node scripts/build-milky-way.mjs <eso0932a.tif>`.
|
||||
|
||||
The panorama's stars sit up to ~2° off; that is invisible in the blurred glow,
|
||||
and every sharp star comes from the catalogue.
|
||||
|
||||
NASA SVS "Deep Star Maps 2020" was not used: it derives from Gaia DR2, which
|
||||
ESA licenses CC BY-NC 3.0 IGO.
|
||||
|
||||
## Frame
|
||||
|
||||
Both layers are in ICRF/J2000. The cube faces use the WebGL face layout, +X
|
||||
to the March equinox and +Z to the north celestial pole, uploaded without
|
||||
`flipY`.
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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();
|
||||
|
||||
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
|
||||
setMeshFloorPreferred(true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
|
||||
});
|
||||
|
||||
test('sampler: a cell just across the antimeridian is still near the viewer', () => {
|
||||
reset();
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => { calls += 1; return undefined; });
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
|
||||
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
|
||||
});
|
||||
+1669
-573
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,10 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
|
||||
export const AWARENESS_RADIUS_M = 250000;
|
||||
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
||||
export const AWARENESS_RELATIONSHIP = Object.freeze({
|
||||
@@ -47,7 +52,11 @@ export function formatAwarenessDistance(meters) {
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
}
|
||||
|
||||
/** Return a stable visible label without leaking missing values into UI text. */
|
||||
/**
|
||||
* Return a stable visible label without leaking missing values into UI text.
|
||||
* The English name the data gives an unnamed installation ("Military area")
|
||||
* is shown in the UI language; mapped names and callsigns show as they are.
|
||||
*/
|
||||
export function formatAwarenessLabel(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const candidate of [
|
||||
@@ -63,7 +72,11 @@ export function formatAwarenessLabel(value) {
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'string') return value.trim() || '—';
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return '—';
|
||||
return UNNAMED_NAMES.has(text) ? t(text) : text;
|
||||
}
|
||||
if (Number.isFinite(value)) return String(value);
|
||||
return '—';
|
||||
}
|
||||
@@ -82,14 +95,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed unavailable',
|
||||
reason: t('feed unavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed stale',
|
||||
reason: t('feed stale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -106,8 +119,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? 'observed or mapped nearby context'
|
||||
: 'no observed or mapped objects in current feeds',
|
||||
? t('observed or mapped nearby context')
|
||||
: t('no observed or mapped objects in current feeds'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -828,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
||||
'and the same query resolves the same way every time',
|
||||
);
|
||||
});
|
||||
|
||||
test('the military tracked descriptor publishes the reported vertical rate', () => {
|
||||
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
|
||||
// the rate under different record keys (`verticalRateMps` here,
|
||||
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
|
||||
// shape if BOTH descriptors map it — a consumer that reads the field off the
|
||||
// civil seam and finds it missing on the military one would silently treat
|
||||
// every military contact as level flight.
|
||||
const seedRate = (verticalRateMps) =>
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'ae7f01',
|
||||
entity: { gevLabelModel: { title: 'TEST', details: [] } },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: true,
|
||||
},
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: { camera: { positionCartographic: null }, scene: {} },
|
||||
meta: {
|
||||
callsign: 'RCH512',
|
||||
rawLat: 31,
|
||||
rawLon: -97,
|
||||
altitudeFt: 26_000,
|
||||
speedMps: 220,
|
||||
track: 95,
|
||||
onGround: false,
|
||||
verticalRateMps,
|
||||
},
|
||||
});
|
||||
|
||||
seedRate(12.7);
|
||||
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
|
||||
|
||||
// Absent is not level: a contact that has never transmitted a rate must read
|
||||
// as null, not as a 0 m/s a phase classifier would call cruise.
|
||||
for (const missing of [undefined, null, Number.NaN]) {
|
||||
seedRate(missing);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
|
||||
null,
|
||||
`vertical rate ${String(missing)} must publish as null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_INSTALLATION_LABEL_BY_CLASS,
|
||||
} from './installationNames.js';
|
||||
|
||||
/**
|
||||
* Normalize the deliberately small, allow-listed installation context returned
|
||||
* by `/api/military-installations`. These are mapped features, not assertions
|
||||
@@ -22,12 +27,10 @@ const CLASS_BY_MILITARY_TAG = {
|
||||
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
|
||||
* attribution and the details panel read it.
|
||||
*/
|
||||
const LABEL_BY_CLASS = {
|
||||
airfield: 'Military airfield',
|
||||
naval_base: 'Naval base',
|
||||
range: 'Firing range',
|
||||
military_land: 'Military land',
|
||||
};
|
||||
// The labels stay English in data (the server provider and AI tools read the
|
||||
// records too); the map card and Contacts translate them (installationNames.js).
|
||||
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
|
||||
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
|
||||
|
||||
const MAX_FOOTPRINT_POINTS = 400;
|
||||
|
||||
@@ -44,7 +47,7 @@ export function humanizeInstallationClass(klass) {
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
: UNNAMED_FALLBACK;
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
/** Spherical-excess ring area (km²) — same family as turf/geojson-area. */
|
||||
@@ -36,15 +36,6 @@ function ringAreaKm2(ring) {
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
|
||||
function haversineKm(lon1, lat1, lon2, lat2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a query/name for matching: lowercase, strip diacritics and
|
||||
* punctuation, collapse whitespace, strip a leading "the ".
|
||||
@@ -157,7 +148,10 @@ function buildEntries(pack, kind) {
|
||||
polygons,
|
||||
areaKm2,
|
||||
bbox: [minLon, minLat, maxLon, maxLat],
|
||||
bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat),
|
||||
bboxDiagonalKm: greatCircleKm(
|
||||
{ lat: minLat, lon: minLon },
|
||||
{ lat: maxLat, lon: maxLon },
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -195,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
return index;
|
||||
});
|
||||
|
||||
function toResult(entry) {
|
||||
function toResult(entry, candidates = 1) {
|
||||
return {
|
||||
name: entry.name,
|
||||
featurecla: entry.featurecla,
|
||||
@@ -203,6 +197,7 @@ function toResult(entry) {
|
||||
polygons: entry.polygons,
|
||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||
areaKm2: entry.areaKm2,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +210,9 @@ function toResult(entry) {
|
||||
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
|
||||
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
|
||||
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
|
||||
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
|
||||
* areaKm2:number, candidates:number}|null>} largest-area match plus the
|
||||
* number of same-name entries, or null when nothing matches. Uniqueness in
|
||||
* this pack alone does not establish the user's intended place.
|
||||
*/
|
||||
export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
@@ -229,7 +226,7 @@ export async function findNaturalRegion(query) {
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list && list.length) return toResult(list[0]);
|
||||
if (list && list.length) return toResult(list[0], list.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -276,9 +273,10 @@ export function pointInRing(ring, lat, lon) {
|
||||
/**
|
||||
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
|
||||
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
|
||||
* matched name (duplicate names included — the US and Spanish "Sierra
|
||||
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
|
||||
* geocoded anchor. Containment is simultaneously the disambiguator and the
|
||||
* matched name (duplicate names included), trying exact names, aliases and
|
||||
* broader suffix variants in that order. Returns a ring only when exactly one
|
||||
* entry under the matched name CONTAINS the anchor. Containment is the
|
||||
* disambiguator and the
|
||||
* wrong-place guard: when no ring contains the anchor this returns null and
|
||||
* the resolver's normal ladder continues unchanged.
|
||||
*
|
||||
@@ -297,23 +295,26 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
const seen = new Set();
|
||||
for (const key of candidates) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
for (const key of new Set(candidates.filter(Boolean))) {
|
||||
const containing = [];
|
||||
for (const entry of index.get(key) || []) {
|
||||
for (const ring of entry.polygons) {
|
||||
if (pointInRing(ring, lat, lon)) {
|
||||
return {
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
featurecla: entry.featurecla,
|
||||
ring,
|
||||
areaKm2: entry.areaKm2,
|
||||
};
|
||||
}
|
||||
}
|
||||
const ring = entry.polygons.find((candidate) =>
|
||||
pointInRing(candidate, lat, lon),
|
||||
);
|
||||
if (ring) containing.push({ entry, ring });
|
||||
}
|
||||
// Specific names win over aliases and broader rewrites. Ambiguity belongs
|
||||
// to this name, not the union of every lower-priority candidate name.
|
||||
if (containing.length > 1) return null;
|
||||
if (containing.length === 0) continue;
|
||||
const [{ entry, ring }] = containing;
|
||||
return {
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
featurecla: entry.featurecla,
|
||||
ring,
|
||||
areaKm2: entry.areaKm2,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { statSync, readFileSync } from 'node:fs';
|
||||
import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
|
||||
const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url);
|
||||
|
||||
@@ -19,10 +20,23 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
|
||||
assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000,
|
||||
`Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`);
|
||||
assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km');
|
||||
const coordinates = rockies.polygons.flat();
|
||||
const minLon = Math.min(...coordinates.map(([lon]) => lon));
|
||||
const minLat = Math.min(...coordinates.map(([, lat]) => lat));
|
||||
const maxLon = Math.max(...coordinates.map(([lon]) => lon));
|
||||
const maxLat = Math.max(...coordinates.map(([, lat]) => lat));
|
||||
assert.equal(
|
||||
rockies.bboxDiagonalKm,
|
||||
greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }),
|
||||
'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points',
|
||||
);
|
||||
});
|
||||
|
||||
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
|
||||
assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps');
|
||||
const alps = await findNaturalRegion('the Alps');
|
||||
assert.equal(alps?.name, 'Alps');
|
||||
assert.equal(alps?.candidates, 1);
|
||||
assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps');
|
||||
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
|
||||
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
|
||||
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
|
||||
@@ -30,6 +44,11 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"',
|
||||
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
|
||||
});
|
||||
|
||||
test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => {
|
||||
assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1);
|
||||
assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1);
|
||||
});
|
||||
|
||||
test('major deserts/ranges resolve with sane areas', async () => {
|
||||
const sahara = await findNaturalRegion('Sahara');
|
||||
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
|
||||
@@ -111,24 +130,55 @@ test('listRegions() enumerates both kinds for diagnostics', async () => {
|
||||
});
|
||||
|
||||
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
|
||||
test('outline lookup: Alps ring via containment anchor', async () => {
|
||||
test('outline lookup: containing exact names win over broader suffix variants', async () => {
|
||||
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
|
||||
assert.ok(r, 'Alps must resolve with an inside anchor');
|
||||
assert.equal(r.name, 'Alps');
|
||||
assert.ok(r.ring.length >= 8, 'range-scale ring');
|
||||
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
|
||||
const range = await lookupNaturalRegionOutline('Alaska Range', 63.14, -148.71);
|
||||
assert.equal(range?.name, 'Alaska Range');
|
||||
assert.ok(pointInRing(range.ring, 63.14, -148.71));
|
||||
assert.equal((await lookupNaturalRegionOutline('Alaska', 63.14, -148.71))?.name, 'Alaska');
|
||||
});
|
||||
|
||||
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
|
||||
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
|
||||
assert.ok(us, 'US anchor must match a Sierra Nevada');
|
||||
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
|
||||
const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73);
|
||||
assert.ok(colombia, 'a single containing candidate resolves a duplicate name');
|
||||
assert.ok(pointInRing(colombia.ring, 5, -73));
|
||||
});
|
||||
|
||||
test('outline lookup: overlapping duplicate candidates remain unresolved', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null);
|
||||
});
|
||||
|
||||
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
|
||||
});
|
||||
|
||||
test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => {
|
||||
for (const [name, lat, lon] of [
|
||||
['Gold Coast', -28, 153.4],
|
||||
['Santa Cruz', 37, -122],
|
||||
['Isabela', 17, 122],
|
||||
['India', 28, 95], // Northeast India lies outside the physical peninsula.
|
||||
['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula.
|
||||
]) {
|
||||
assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`);
|
||||
assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`);
|
||||
}
|
||||
});
|
||||
|
||||
test('outline lookup: aliases retain containment checks', async () => {
|
||||
const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10);
|
||||
assert.equal(alps?.name, 'Alps');
|
||||
assert.ok(pointInRing(alps.ring, 46.5, 10));
|
||||
assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null);
|
||||
});
|
||||
|
||||
test('outline lookup: non-region names never match', async () => {
|
||||
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
|
||||
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on
|
||||
* screen; callers translate the English name with translateKnown().
|
||||
*/
|
||||
|
||||
const POINTER_OWNER_NAMES = Object.freeze({
|
||||
draw: msg('Draw'),
|
||||
directions: msg('Directions'),
|
||||
'recent-imagery-box': msg('Recent imagery'),
|
||||
});
|
||||
|
||||
/**
|
||||
* English display name of a pointer owner. An owner without a registered name
|
||||
* falls back to its id.
|
||||
* @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pointerOwnerName(owner) {
|
||||
const id = typeof owner === 'string' ? owner.trim() : '';
|
||||
if (!id) return '';
|
||||
return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
+34
-26
@@ -1,3 +1,5 @@
|
||||
import { greatCircleMeters } from '../geoDistance.js';
|
||||
|
||||
const MAX_ARTICLES = 5;
|
||||
|
||||
function cleanText(value, maxLength = 180) {
|
||||
@@ -67,11 +69,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 +98,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 +138,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';
|
||||
@@ -136,19 +155,8 @@ export function weatherCodeLabel(code) {
|
||||
|
||||
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
||||
export function regionalDistanceM(from, to) {
|
||||
if (
|
||||
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
) {
|
||||
return Infinity;
|
||||
}
|
||||
const phi1 = (from.latitude * Math.PI) / 180;
|
||||
const phi2 = (to.latitude * Math.PI) / 180;
|
||||
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
|
||||
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) ** 2 +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleMeters(
|
||||
{ lat: from?.latitude, lon: from?.longitude },
|
||||
{ lat: to?.latitude, lon: to?.longitude },
|
||||
);
|
||||
}
|
||||
@@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () =
|
||||
|
||||
test('Launch Library uses a 15-minute cache and optional server-side token header', () => {
|
||||
assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' });
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
};
|
||||
assert.deepEqual(launchLibraryRequestHeaders(''), headers);
|
||||
assert.deepEqual(launchLibraryRequestHeaders(' secret '), {
|
||||
...headers,
|
||||
Authorization: 'Token secret',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { createModel } from '../layers/launches/model.js';
|
||||
import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
_setSelectedRocketMissionForTest,
|
||||
@@ -676,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
|
||||
assert.equal(launches[0].lon, -120.611);
|
||||
});
|
||||
|
||||
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
for (const blank of [null, '', ' ', false]) {
|
||||
assert.deepEqual(
|
||||
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('null pad coordinates use the location fallback instead of 0,0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
|
||||
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
|
||||
assert.equal(fallback.lat, 28.6);
|
||||
assert.equal(fallback.lon, -80.6);
|
||||
});
|
||||
|
||||
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
|
||||
rocket: { launcher_stage: [{ launcher_flight_number: null,
|
||||
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
|
||||
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
|
||||
const [stage] = launch.recoveryStages;
|
||||
assert.equal(stage.lat, null);
|
||||
assert.equal(stage.lon, null);
|
||||
assert.equal(stage.downrangeKm, null);
|
||||
assert.equal(stage.flightNumber, null);
|
||||
});
|
||||
|
||||
test('normalizes detailed payload and stage recovery records', () => {
|
||||
const launches = normalizeRocketLaunches({ results: [{
|
||||
id: 'recovery-details',
|
||||
@@ -728,6 +760,126 @@ test('normalizes detailed payload and stage recovery records', () => {
|
||||
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
|
||||
});
|
||||
|
||||
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
|
||||
const model = createModel({});
|
||||
assert.equal(model.finiteCoordinate(42.5), 42.5);
|
||||
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
|
||||
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
|
||||
assert.equal(model.finiteCoordinate(0), 0);
|
||||
assert.equal(model.finiteCoordinate('0'), 0);
|
||||
assert.equal(model.finiteCoordinate(undefined), null);
|
||||
assert.equal(model.finiteCoordinate('invalid'), null);
|
||||
assert.equal(model.finiteCoordinate('TBD'), null);
|
||||
assert.equal(model.finiteCoordinate(Number.NaN), null);
|
||||
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
|
||||
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
|
||||
});
|
||||
|
||||
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
|
||||
const launches = normalizeRocketLaunches(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
id: 'recovery-fallbacks',
|
||||
name: 'Falcon 9 | Edge Case Stage Normalization',
|
||||
net: '2026-07-20T10:00:00Z',
|
||||
pad: { latitude: '28.608', longitude: '-80.604' },
|
||||
rocket: {
|
||||
launcher_stage: [
|
||||
{
|
||||
id: 88,
|
||||
type: { name: 'Booster' },
|
||||
launcher_flight_number: 'invalid-flight',
|
||||
landing: {
|
||||
attempt: true,
|
||||
success: false,
|
||||
downrange_distance: 'TBD',
|
||||
landing_location: {
|
||||
name: 'Ocean Impact',
|
||||
latitude: 'N/A',
|
||||
longitude: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 89,
|
||||
type: { name: 'Expended Stage' },
|
||||
landing: {
|
||||
attempt: false,
|
||||
type: { name: 'Expendable' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(launches[0].recoveryStages.length, 2);
|
||||
|
||||
const booster = launches[0].recoveryStages[0];
|
||||
assert.equal(booster.status, 'LOST');
|
||||
assert.equal(booster.success, false);
|
||||
assert.equal(booster.attempted, true);
|
||||
assert.equal(booster.flightNumber, null);
|
||||
assert.equal(booster.downrangeKm, null);
|
||||
assert.equal(booster.lat, null);
|
||||
assert.equal(booster.lon, null);
|
||||
|
||||
const expendable = launches[0].recoveryStages[1];
|
||||
assert.equal(expendable.status, 'EXPENDABLE');
|
||||
assert.equal(expendable.attempted, false);
|
||||
assert.equal(expendable.success, null);
|
||||
});
|
||||
|
||||
test('normalizes payload amounts and fractional string masses', () => {
|
||||
const launches = normalizeRocketLaunches(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
id: 'payload-edge-cases',
|
||||
name: 'Electron | Mixed Payloads',
|
||||
net: '2026-07-20T10:00:00Z',
|
||||
pad: { latitude: '-39.26', longitude: '177.86' },
|
||||
rocket: {
|
||||
payloads: [
|
||||
{
|
||||
id: 601,
|
||||
amount: 'not-a-number',
|
||||
payload: { id: 11, name: 'Unknown Amount Payload' },
|
||||
},
|
||||
{
|
||||
id: 602,
|
||||
amount: 3,
|
||||
payload: { id: 12, name: 'Triple Payload' },
|
||||
},
|
||||
{
|
||||
id: 603,
|
||||
payload: {
|
||||
id: 13,
|
||||
name: 'Valid Fractional Mass Payload',
|
||||
mass: '12.75',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
// Blank, negative and non-numeric masses are pinned by "missing payload
|
||||
// details stay unknown without inventing mass or classification".
|
||||
const payloads = launches[0].payloads;
|
||||
assert.equal(payloads.length, 3);
|
||||
assert.equal(payloads[0].amount, 1);
|
||||
assert.equal(payloads[1].amount, 3);
|
||||
assert.equal(payloads[2].massKg, 12.75);
|
||||
});
|
||||
|
||||
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
|
||||
const launches = normalizeRocketLaunches({ results: [{
|
||||
id: 'undisclosed',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+40
-29
@@ -10,19 +10,9 @@
|
||||
* low → its destination should be nearby).
|
||||
*/
|
||||
|
||||
const D2R = Math.PI / 180;
|
||||
const R_KM = 6371;
|
||||
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
|
||||
|
||||
/** Haversine great-circle distance in km. */
|
||||
export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p1 = lat1 * D2R;
|
||||
const p2 = lat2 * D2R;
|
||||
const dp = (lat2 - lat1) * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p1 = lat1 * D2R;
|
||||
@@ -36,10 +26,23 @@ function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
|
||||
/** Signed cross-track distance (km) of point from the great circle p1→p2. */
|
||||
export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM;
|
||||
const d13 =
|
||||
greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM;
|
||||
const b13 = bearingRad(lat1, lon1, lat, lon);
|
||||
const b12 = bearingRad(lat1, lon1, lat2, lon2);
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM;
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
||||
}
|
||||
|
||||
/** Signed distance along the great circle from the origin toward the destination. */
|
||||
function alongTrackKm(position, origin, destination) {
|
||||
const distance = greatCircleKm(origin, position) / EARTH_RADIUS_KM;
|
||||
const direction =
|
||||
bearingRad(origin.lat, origin.lon, position.lat, position.lon) -
|
||||
bearingRad(origin.lat, origin.lon, destination.lat, destination.lon);
|
||||
return (
|
||||
Math.atan2(Math.sin(distance) * Math.cos(direction), Math.cos(distance)) *
|
||||
EARTH_RADIUS_KM
|
||||
);
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
@@ -65,16 +68,28 @@ export function routePlausible({
|
||||
origin = null,
|
||||
destination = null,
|
||||
}) {
|
||||
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
||||
const haveD =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const position = { lat: latDeg, lon: lonDeg };
|
||||
if (!isGeoPoint(position)) return true;
|
||||
const haveO = isGeoPoint(origin);
|
||||
const haveD = isGeoPoint(destination);
|
||||
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
||||
|
||||
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
||||
const near = (pt) =>
|
||||
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
const routeKm = greatCircleKm(origin, destination);
|
||||
const alongKm = alongTrackKm(position, origin, destination);
|
||||
// atan2 returns the principal arc in [-πR, πR]. Near an antipodal
|
||||
// destination, its allowed forward extension can cross that branch cut.
|
||||
const segmentAlongKm =
|
||||
alongKm < -CROSS_TRACK_KM
|
||||
? alongKm + 2 * Math.PI * EARTH_RADIUS_KM
|
||||
: alongKm;
|
||||
// Coincident or antipodal endpoints do not define one minor arc. Keep the
|
||||
// existing loose check when the segment cannot be judged reliably.
|
||||
const segmentKnown =
|
||||
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
@@ -85,7 +100,10 @@ export function routePlausible({
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
) < CROSS_TRACK_KM &&
|
||||
(!segmentKnown ||
|
||||
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
@@ -99,17 +117,10 @@ export function routePlausible({
|
||||
Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
) {
|
||||
if (verticalRateMps > 0) {
|
||||
if (
|
||||
haveO &&
|
||||
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
|
||||
)
|
||||
if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM)
|
||||
return false; // departing — origin should be local
|
||||
} else {
|
||||
if (
|
||||
haveD &&
|
||||
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
|
||||
LOCAL_AIRPORT_KM
|
||||
)
|
||||
if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM)
|
||||
return false; // arriving — destination should be local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// src/data/routePlausible.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { routePlausible, greatCircleKm } from './routePlausible.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754 };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085 };
|
||||
const JFK = { lat: 40.6413, lon: -73.7781 };
|
||||
|
||||
test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => {
|
||||
const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon);
|
||||
const d = greatCircleKm(SFO, LAX);
|
||||
assert.ok(Math.abs(d - 543) < 15, `got ${d}`);
|
||||
});
|
||||
|
||||
@@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => {
|
||||
latDeg: 51.5, lonDeg: -0.12,
|
||||
origin: { lat: null, lon: null }, destination: null,
|
||||
}), true);
|
||||
assert.equal(routePlausible({
|
||||
latDeg: 95, lonDeg: 0,
|
||||
origin: SFO, destination: LAX,
|
||||
}), true);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
import { createTracking } from '../layers/flights/tracking.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' };
|
||||
const beyond = { lat: -35.818227816411174, lon: -66.16647396367095 };
|
||||
const behind = { lat: 35.818227816411174, lon: 113.83352603632906 };
|
||||
const antipode = { lat: -SFO.lat, lon: SFO.lon + 180 };
|
||||
const midpoint = { lat: 35.8, lon: -120.4 };
|
||||
|
||||
const plausible = (position, origin = SFO, destination = LAX) =>
|
||||
routePlausible({
|
||||
latDeg: position.lat,
|
||||
lonDeg: position.lon,
|
||||
altitudeM: 10000,
|
||||
verticalRateMps: 0,
|
||||
origin,
|
||||
destination,
|
||||
});
|
||||
|
||||
// Independent spherical oracle: native unit-vector angular distances, without
|
||||
// the production bearing/cross-track/along-track calculations.
|
||||
const unit = ({ lat, lon }) => {
|
||||
const latitude = Cesium.Math.toRadians(lat);
|
||||
const longitude = Cesium.Math.toRadians(lon);
|
||||
return new Cesium.Cartesian3(
|
||||
Math.cos(latitude) * Math.cos(longitude),
|
||||
Math.cos(latitude) * Math.sin(longitude),
|
||||
Math.sin(latitude),
|
||||
);
|
||||
};
|
||||
const angle = (a, b) => Cesium.Cartesian3.angleBetween(unit(a), unit(b));
|
||||
const excess = (position, origin, destination) =>
|
||||
angle(origin, position) +
|
||||
angle(position, destination) -
|
||||
angle(origin, destination);
|
||||
|
||||
test('a short airport route cannot describe a plane thousands of kilometres beyond its destination', () => {
|
||||
assert.ok(excess(beyond, SFO, LAX) > 2.9);
|
||||
assert.equal(plausible(beyond), false);
|
||||
});
|
||||
|
||||
test('backwards and antipodal extensions are not the scheduled airport leg', () => {
|
||||
for (const position of [behind, antipode]) {
|
||||
assert.ok(excess(position, SFO, LAX) > 3);
|
||||
assert.equal(plausible(position), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary route offsets and generous endpoint extensions stay plausible', () => {
|
||||
assert.ok(excess(midpoint, SFO, LAX) < 0.001);
|
||||
for (const position of [midpoint, SFO, LAX, { lat: 36.5, lon: -119.5 }]) {
|
||||
assert.equal(plausible(position), true);
|
||||
}
|
||||
// ~167 km past either endpoint is outside the 130 km endpoint circle,
|
||||
// but remains inside the 200 km coarse-route tolerance.
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -1.5 }, origin, destination), true);
|
||||
assert.equal(plausible({ lat: 0, lon: 11.5 }, origin, destination), true);
|
||||
});
|
||||
|
||||
test('the generous endpoint tolerance still excludes a clearly different leg', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -2.5 }, origin, destination), false);
|
||||
assert.equal(plausible({ lat: 0, lon: 12.5 }, origin, destination), false);
|
||||
});
|
||||
|
||||
test('minor-arc admission works across the antimeridian and near the pole', () => {
|
||||
const dateline = [
|
||||
{ lat: 10, lon: 170 },
|
||||
{ lat: 10, lon: -170 },
|
||||
];
|
||||
assert.equal(plausible({ lat: 10.15, lon: 180 }, ...dateline), true);
|
||||
assert.equal(plausible({ lat: -10.15, lon: 0 }, ...dateline), false);
|
||||
const polar = [
|
||||
{ lat: 80, lon: -60 },
|
||||
{ lat: 80, lon: 60 },
|
||||
];
|
||||
const top = { lat: 84.96163122670251, lon: 0 };
|
||||
assert.ok(excess(top, ...polar) < 1e-8);
|
||||
assert.equal(plausible(top, ...polar), true);
|
||||
assert.equal(plausible({ lat: -top.lat, lon: 180 }, ...polar), false);
|
||||
});
|
||||
|
||||
test('the forward tolerance survives the signed-angle branch cut in both directions', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
for (const direction of [-1, 1]) {
|
||||
const destination = { lat: 0, lon: direction * 179.9 };
|
||||
const within = { lat: 0, lon: direction * -178.8 };
|
||||
const endpointGapKm = angle(destination, within) * 6371.0088;
|
||||
assert.ok(endpointGapKm > 130 && endpointGapKm < 200);
|
||||
assert.equal(plausible(within, origin, destination), true);
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: direction * -178 }, origin, destination),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('unjudgeable endpoint geometry and missing coordinates keep the loose contract', () => {
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: -90 }, { lat: 0, lon: 0 }, { lat: 0, lon: 180 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
plausible({ lat: 45, lon: 0 }, { lat: 0, lon: 0 }, { lat: 0, lon: 0 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(plausible(beyond, SFO, null), true);
|
||||
assert.equal(plausible(beyond, null, null), true);
|
||||
});
|
||||
|
||||
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => {
|
||||
const collection = new Cesium.BillboardCollection();
|
||||
t.after(() => collection.destroy());
|
||||
const flightState = {
|
||||
records: { data: new Map(), missingPolls: new Map() },
|
||||
feed: { _backoff: false },
|
||||
_billboards: new Map(),
|
||||
_scratchCarto: new Cesium.Cartographic(),
|
||||
};
|
||||
// Only application host services are supplied. Route admission, WGS84
|
||||
// billboard conversion and tracked-label formatting are the actual owners.
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'context',
|
||||
'trails',
|
||||
'geoid',
|
||||
'groundFloor',
|
||||
'focus',
|
||||
'militaryRegistry',
|
||||
'readout',
|
||||
'camera',
|
||||
'picking',
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
[midpoint, true],
|
||||
]) {
|
||||
const id = 'abc001';
|
||||
const route = { origin: SFO, destination: LAX };
|
||||
flightState.records.data.set(id, {
|
||||
callsign: 'OWNED001',
|
||||
altitude: 10000,
|
||||
verticalRate: 0,
|
||||
route,
|
||||
});
|
||||
flightState._billboards.set(
|
||||
id,
|
||||
collection.add({
|
||||
id,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
10000,
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
tracking._trackedLabelText(id).includes('SFO → LAX'),
|
||||
expected,
|
||||
);
|
||||
assert.equal(tracking._routeIsPlausible(id, route), expected);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
+42
-18
@@ -17,54 +17,46 @@
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Class registry. `label` is the card/legend token, `color` the point color,
|
||||
* `blurb` the plain-language gloss for the legend tooltip.
|
||||
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>}
|
||||
* Class registry: the point color of each class. The card and legend text is
|
||||
* in CLASS_DISPLAY_KEYS.
|
||||
* @type {Readonly<Record<string, { color: string }>>}
|
||||
*/
|
||||
export const SATELLITE_CLASSES = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: 'STATION',
|
||||
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
||||
// NOT amber: 40-48deg amber is the app's known-military convention
|
||||
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
||||
// leak into another domain. Slightly warm so it also separates from the
|
||||
// pure white the civilian air fleet draws in.
|
||||
color: '#fff6e5',
|
||||
blurb: 'Crewed stations and their visiting vehicles',
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: 'NAV',
|
||||
// Cyan — the strongest accent goes to the class users actually name
|
||||
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
||||
// VISUAL group used to occupy, so this adds no new hue to the app.
|
||||
color: '#4fd8ff',
|
||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: 'GEO',
|
||||
// Violet, unchanged. Already the app's "space" semantic (the detection
|
||||
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
||||
// as one clean equatorial ring at globe scale.
|
||||
color: '#c89bff',
|
||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: 'VISUAL',
|
||||
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
|
||||
// cyan it used to hold and recedes behind the three classes that mean
|
||||
// something. Still well clear of the dense shell by luminance.
|
||||
color: '#9fb3c4',
|
||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: 'COMMS',
|
||||
// Dim slate — thousands of these appear in DENSE mode. They must read as
|
||||
// texture behind the core catalog, never compete with it. Rec.601 luma
|
||||
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
||||
// NVG/FLIR shaders collapse the scene to a single channel.
|
||||
color: '#54697f',
|
||||
blurb: 'Broadband constellation shell — shown only in DENSE mode',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -77,6 +69,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
'comms',
|
||||
]);
|
||||
|
||||
// What the card and legend show for each class.
|
||||
const CLASS_DISPLAY_KEYS = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: msg('STATION'),
|
||||
blurb: msg('Crewed stations and their visiting vehicles'),
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: msg('NAV'),
|
||||
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: msg('GEO'),
|
||||
blurb: msg(
|
||||
'Geostationary belt — comms and weather, fixed over the equator',
|
||||
),
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: msg('VISUAL'),
|
||||
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: msg('COMMS'),
|
||||
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
|
||||
@@ -134,12 +152,17 @@ export function satelliteClassColor(group) {
|
||||
* The ISS is always a STATION regardless of which group ingested it — see
|
||||
* satelliteClassOf, which owns that rule for every consumer.
|
||||
* @param {string|undefined|null} group CelesTrak group tag.
|
||||
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
|
||||
* @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
|
||||
* localize: false keeps the English label (data for the AI tools).
|
||||
* @returns {string} Display label.
|
||||
*/
|
||||
export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
export function satelliteClassLabel(
|
||||
group,
|
||||
{ isIss = false, localize = true } = {},
|
||||
) {
|
||||
const { klass, subtype } = satelliteClassOf(group, { isIss });
|
||||
const base = SATELLITE_CLASSES[klass].label;
|
||||
const label = CLASS_DISPLAY_KEYS[klass].label;
|
||||
const base = localize ? t(label) : label;
|
||||
return subtype ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
|
||||
@@ -180,11 +203,12 @@ export function satelliteClassLegend(counts) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
const display = CLASS_DISPLAY_KEYS[klass];
|
||||
result.push({
|
||||
klass,
|
||||
label: spec.label,
|
||||
label: t(display.label),
|
||||
color: spec.color,
|
||||
blurb: spec.blurb,
|
||||
blurb: t(display.blurb),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
import satellitesLayer from './satellites.js';
|
||||
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
||||
|
||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
test('partial and dense catalogs do not make an early false missing decision', async () => {
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
@@ -32,31 +34,45 @@ test('partial and dense catalogs do not make an early false missing decision', a
|
||||
assert.match(partial.reason, /partial CelesTrak catalog/);
|
||||
|
||||
let releaseDense;
|
||||
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
|
||||
const densePromise = new Promise((resolve) => {
|
||||
releaseDense = resolve;
|
||||
});
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'dense',
|
||||
densePromise,
|
||||
});
|
||||
let settled = false;
|
||||
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => { settled = true; return result; });
|
||||
const resolution = satellitesLayer
|
||||
.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
|
||||
releaseDense({ status: 'ready' });
|
||||
assert.equal((await resolution).status, 'missing');
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' });
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'core',
|
||||
});
|
||||
});
|
||||
|
||||
test('satellite first update aborts every parallel catalog request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const observedSignals = [];
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = satellitesLayer.update({}, { signal: controller.signal });
|
||||
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
|
||||
assert.ok(observedSignals.length > 1);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
|
||||
assert.equal(
|
||||
observedSignals.every((signal) => signal?.aborted),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
point,
|
||||
});
|
||||
try {
|
||||
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
|
||||
assert.doesNotThrow(() =>
|
||||
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
|
||||
);
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
|
||||
|
||||
@@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999);
|
||||
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_applyPendingSatelliteTrackingRestoreForTest();
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
|
||||
@@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
|
||||
satellitesLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
@@ -187,8 +216,8 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 99999, name: 'UNRELATED SAT', point: near(50_000) }, // 50 km away
|
||||
],
|
||||
});
|
||||
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
const details = entity.gevLabelModel.details;
|
||||
// Class leads the detail block; the altitude line follows it, and the
|
||||
// consolidated companions stay last.
|
||||
assert.equal(details[0], 'STATION · ISS', 'the class line names what this is');
|
||||
assert.equal(
|
||||
details[0],
|
||||
'STATION · ISS',
|
||||
'the class line names what this is',
|
||||
);
|
||||
assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
|
||||
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card');
|
||||
assert.equal(
|
||||
details[2],
|
||||
'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card',
|
||||
);
|
||||
|
||||
// Scoping is encoded in that string: THREE neighbours were seeded but only two
|
||||
// are counted (the named one plus "+1"). The satellite 50 km away is not part
|
||||
@@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
camera: null,
|
||||
scene: { frameState: { frameNumber: 1 } },
|
||||
};
|
||||
const epochs = [
|
||||
Date.UTC(2008, 8, 20, 12, 30),
|
||||
Date.UTC(2008, 8, 20, 13, 0),
|
||||
];
|
||||
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
|
||||
let epochIndex = 0;
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
|
||||
assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544');
|
||||
// formatQuantity keeps the number and its unit together with a no-break space.
|
||||
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
|
||||
|
||||
epochIndex = 1;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
|
||||
'the class line survives an altitude-only republish');
|
||||
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||
assert.equal(
|
||||
entity.gevLabelModel.details[0],
|
||||
'STATION · ISS',
|
||||
'the class line survives an altitude-only republish',
|
||||
);
|
||||
});
|
||||
|
||||
test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => {
|
||||
@@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
|
||||
const ambient = ambientPublication[2][0];
|
||||
assert.equal(ambient.title, 'ISS');
|
||||
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
|
||||
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
|
||||
assert.equal(
|
||||
ambient.position(),
|
||||
point.position,
|
||||
'ISS getter reads the existing point cache',
|
||||
);
|
||||
assert.equal(
|
||||
point.label,
|
||||
undefined,
|
||||
'ISS point primitive carries no label graphic',
|
||||
);
|
||||
|
||||
const trackedEntity = _trackIssForTest();
|
||||
assert.ok(trackedEntity);
|
||||
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity');
|
||||
assert.equal(
|
||||
viewer.entities.values.length,
|
||||
1,
|
||||
'runtime guard requires a real tracked entity',
|
||||
);
|
||||
assert.equal(trackedEntity.label, undefined);
|
||||
assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing');
|
||||
assert.ok(
|
||||
trackedEntity.point,
|
||||
'tracked dot remains on the entity for camera framing',
|
||||
);
|
||||
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
|
||||
assert.ok(trackedEntry);
|
||||
assert.equal(trackedEntry.id, 'satellites:25544');
|
||||
assert.equal(trackedEntry.protected, true);
|
||||
assert.equal(trackedEntry.paintLane, 'tracked');
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
2,
|
||||
'tracking may refresh before ownership changes but must not publish a second ISS entry',
|
||||
);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
], 'tracked ISS suppresses ambient text so the tracked card is the only text surface');
|
||||
assert.deepEqual(
|
||||
calls.slice(-2),
|
||||
[
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
],
|
||||
'tracked ISS suppresses ambient text so the tracked card is the only text surface',
|
||||
);
|
||||
|
||||
_clearSatelliteLabelLifecycleForTest();
|
||||
cleaned = true;
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
3,
|
||||
'untracking republishes exactly one persistent ISS ambient entry before teardown',
|
||||
);
|
||||
|
||||
@@ -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,10 +7,12 @@ export function celestrakTleUrl(group) {
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
const start = new Date(end.getTime() - 30 * 86400000);
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
const start = new Date(roundedEnd.getTime() - 30 * 86400000);
|
||||
const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/');
|
||||
url.searchParams.set('net__gte', start.toISOString());
|
||||
url.searchParams.set('net__lte', end.toISOString());
|
||||
url.searchParams.set('net__lte', roundedEnd.toISOString());
|
||||
url.searchParams.set('limit', '100');
|
||||
url.searchParams.set('mode', 'detailed');
|
||||
return url;
|
||||
|
||||
Loaded 100 of 1020 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user