mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge remote-tracking branch 'upstream/main' into pr1209-merge
This commit is contained in:
commit
11aad05dc9
807 files changed
+83004
-20993
No files matched your search
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
|
||||
}
|
||||
|
||||
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
|
||||
export function pointInRing(ring, lat, lon) {
|
||||
function pointInRing(ring, lat, lon) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Lighting options shared by every aircraft 3D model (Flights, Military
|
||||
* Flights, Local ADS-B).
|
||||
*
|
||||
* Cesium gives each Model a dynamic environment map: it renders the sky around
|
||||
* the model and reads the result back from the GPU (`readPixels`) to light it,
|
||||
* when the model is first placed and again when it moves more than 1 km, when
|
||||
* the scene's atmosphere settings change, or every scene hour under sunlight
|
||||
* lighting. Aircraft models take 94% of their surface from the class colour
|
||||
* (`colorBlendAmount`), so that lighting contributes little; without it they
|
||||
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
|
||||
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
|
||||
* 14.3-14.8 -> 12.1-12.5 ms.
|
||||
*/
|
||||
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
|
||||
enabled: false,
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
|
||||
|
||||
/**
|
||||
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
|
||||
* map (a sky render and GPU readback per model). The preload calls only warm
|
||||
* the glTF cache and are never added to the scene, so they are left out:
|
||||
* drawn models are the calls that set a minimum pixel size.
|
||||
*/
|
||||
const SOURCES = [
|
||||
{
|
||||
name: 'flights',
|
||||
source: readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'militaryFlights',
|
||||
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
},
|
||||
{
|
||||
name: 'localAdsb',
|
||||
source: readFileSync(
|
||||
new URL('../layers/localAdsb/models.js', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
test('the shared option turns the dynamic environment map off', () => {
|
||||
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
|
||||
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
|
||||
});
|
||||
|
||||
for (const { name, source } of SOURCES) {
|
||||
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
|
||||
const calls = [
|
||||
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((options) => /\bminimumPixelSize:/.test(options));
|
||||
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
|
||||
for (const [index, options] of calls.entries())
|
||||
assert.match(
|
||||
options,
|
||||
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
|
||||
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true }),
|
||||
});
|
||||
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
|
||||
const tile = { primitive: { isCesium3DTileset: true } };
|
||||
const harness = installWireHarness(tile);
|
||||
let drills = 0;
|
||||
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
|
||||
drills++;
|
||||
assert.deepEqual([limit, width, height], [8, 14, 14]);
|
||||
return [tile, { id: 'a1b2c3' }];
|
||||
};
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(drills, 1);
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
unregisterPickOwner('flights');
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
|
||||
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
|
||||
const harness = installWireHarness({ id: 'atx-cam-3' });
|
||||
|
||||
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
|
||||
* @param {Object} body
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AISSTREAM_STALE_MS,
|
||||
aisStreamRows,
|
||||
ingestAisStreamEnvelope,
|
||||
readAisTrack,
|
||||
} from '../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
|
||||
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
|
||||
assert.equal(row.speed, 102.2);
|
||||
assert.equal(row.course, 359.9);
|
||||
});
|
||||
|
||||
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
|
||||
ingestAisStreamEnvelope(
|
||||
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
|
||||
);
|
||||
// AISStream's MetaData carries the last known position on every message,
|
||||
// static data included.
|
||||
assert.equal(
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: {
|
||||
ShipStaticData: {
|
||||
UserID: 244690000,
|
||||
Name: 'EXAMPLE VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
},
|
||||
MetaData: {
|
||||
MMSI: '244690000',
|
||||
latitude: 53.40879,
|
||||
longitude: 6.19741,
|
||||
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const row = rowFor('244690000');
|
||||
assert.equal(row.speed, 12.4);
|
||||
assert.equal(row.course, 86.7);
|
||||
assert.equal(row.heading, 87);
|
||||
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
|
||||
assert.equal(row.destination, 'ROTTERDAM');
|
||||
assert.equal(row.imo, '9876543');
|
||||
});
|
||||
|
||||
// AISStream carries both Class B parts; the unused part has Valid: false.
|
||||
const classBStatic = (part, value) => ({
|
||||
MessageID: 24,
|
||||
RepeatIndicator: 0,
|
||||
Valid: true,
|
||||
Reserved: 0,
|
||||
PartNumber: part === 'B',
|
||||
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
|
||||
ReportB: {
|
||||
Valid: part === 'B',
|
||||
ShipType: part === 'B' ? value : 0,
|
||||
VendorIDName: '',
|
||||
VenderIDModel: 0,
|
||||
VenderIDSerial: 0,
|
||||
CallSign: '',
|
||||
Dimension: { A: 0, B: 0, C: 0, D: 0 },
|
||||
FixType: 0,
|
||||
Spare: 0,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, scenario] of [
|
||||
{
|
||||
type: 'ShipStaticData',
|
||||
initial: [
|
||||
{
|
||||
Name: 'OLD VESSEL',
|
||||
Type: 70,
|
||||
Destination: 'ROTTERDAM',
|
||||
ImoNumber: 9876543,
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
{
|
||||
Name: 'NEW VESSEL',
|
||||
Type: 80,
|
||||
Destination: 'HAMBURG',
|
||||
ImoNumber: 9876544,
|
||||
},
|
||||
],
|
||||
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
|
||||
expected: {
|
||||
name: 'NEW VESSEL',
|
||||
type: '80',
|
||||
destination: 'HAMBURG',
|
||||
imo: '9876544',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'StaticDataReport',
|
||||
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
|
||||
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
|
||||
empty: classBStatic('A', ''),
|
||||
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
|
||||
},
|
||||
].entries()) {
|
||||
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
|
||||
const mmsi = String(244690100 + index);
|
||||
const start = Date.parse('2026-09-17T20:00:00Z');
|
||||
let now = start;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const position = (
|
||||
lat,
|
||||
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
|
||||
) => {
|
||||
const envelope = positionReport(mmsi, body);
|
||||
Object.assign(envelope.MetaData, {
|
||||
latitude: lat,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
});
|
||||
return ingestAisStreamEnvelope(envelope);
|
||||
};
|
||||
const sendStatic = (body) =>
|
||||
ingestAisStreamEnvelope({
|
||||
MessageType: scenario.type,
|
||||
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: {
|
||||
MMSI: mmsi,
|
||||
ShipName: 'STALE HEADER',
|
||||
latitude: 54,
|
||||
longitude: 7,
|
||||
time_utc: new Date(now).toISOString(),
|
||||
},
|
||||
});
|
||||
position(53.40879);
|
||||
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
|
||||
now += 60_000;
|
||||
position(53.41879);
|
||||
const before = rowFor(mmsi);
|
||||
const track = readAisTrack(mmsi);
|
||||
assert.equal(track.length, 2);
|
||||
|
||||
now += 5 * 60_000;
|
||||
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
|
||||
// Empty or omitted static fields must not erase known metadata, including
|
||||
// the cached values used when the next actual position arrives.
|
||||
assert.equal(sendStatic(scenario.empty), true);
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
assert.deepEqual(readAisTrack(mmsi), track);
|
||||
if (scenario.type === 'StaticDataReport') {
|
||||
sendStatic({
|
||||
...classBStatic('A', ''),
|
||||
ReportA: { Valid: false, Name: 'INVALID NAME' },
|
||||
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
|
||||
});
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
|
||||
}
|
||||
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
|
||||
assert.equal(
|
||||
rowFor(mmsi),
|
||||
undefined,
|
||||
'static traffic must not renew position freshness',
|
||||
);
|
||||
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
|
||||
const next = rowFor(mmsi);
|
||||
for (const [key, value] of Object.entries(scenario.expected))
|
||||
assert.equal(next[key], value, key);
|
||||
assert.equal(next.speed, 0);
|
||||
assert.equal(next.course, 0);
|
||||
assert.equal(next.heading, 0);
|
||||
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
|
||||
assert.equal(readAisTrack(mmsi).length, 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
|
||||
const mmsi = '244690200';
|
||||
const staticReport = (body) => ({
|
||||
MessageType: 'ShipStaticData',
|
||||
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
|
||||
MetaData: { MMSI: mmsi },
|
||||
});
|
||||
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
|
||||
const extended = positionReport(mmsi, {});
|
||||
extended.MessageType = 'ExtendedClassBPositionReport';
|
||||
extended.Message = {
|
||||
ExtendedClassBPositionReport: {
|
||||
UserID: Number(mmsi),
|
||||
Type: 80,
|
||||
Sog: 12,
|
||||
Cog: 86,
|
||||
},
|
||||
};
|
||||
extended.MetaData.ShipName = 'NEW VESSEL';
|
||||
ingestAisStreamEnvelope(extended);
|
||||
const before = rowFor(mmsi);
|
||||
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
|
||||
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
|
||||
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
|
||||
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
|
||||
assert.equal(rowFor(mmsi).type, '80');
|
||||
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
|
||||
});
|
||||
+1
-21
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
|
||||
* pre-watchdog meaning so a keyless install still reads as "feature off".
|
||||
*/
|
||||
export const AIS_WATCHDOG_STATUSES = Object.freeze([
|
||||
'idle',
|
||||
'missing-key',
|
||||
'unsupported',
|
||||
'connecting',
|
||||
'live',
|
||||
'stale',
|
||||
'reconnecting',
|
||||
'down',
|
||||
'auth-failed',
|
||||
]);
|
||||
|
||||
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
|
||||
export const AIS_FAILURE_KINDS = Object.freeze([
|
||||
'transport',
|
||||
'auth',
|
||||
'rate-limit',
|
||||
]);
|
||||
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
|
||||
|
||||
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
|
||||
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
|
||||
|
||||
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
|
||||
} from './alprCameras.js';
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { shownEntityLabel } from '../voice/speech.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getContextStore,
|
||||
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
||||
}
|
||||
});
|
||||
|
||||
// The voice model reads the label as data; the voice card translates it.
|
||||
test('the camera label stays English data in a translated interface', async () => {
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
setLocale('ru');
|
||||
h.setFetch(async () =>
|
||||
cameraResponse([
|
||||
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
h.click(h.source.entities.values[0].id);
|
||||
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
||||
assert.equal(
|
||||
shownEntityLabel(getSelectedEntityContext().label),
|
||||
'Камера ALPR',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
||||
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
||||
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
||||
|
||||
+17
-65
@@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
|
||||
/** Derived per-record field for layers with a time field (hours before now). */
|
||||
const AGE_FIELD = 'ageHours';
|
||||
/** Spatial scope kinds `query()` accepts. */
|
||||
export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
'view',
|
||||
'region',
|
||||
'radius',
|
||||
@@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
|
||||
* @param {string[]} layerKeys Queried layer ids.
|
||||
* @returns {string[]} Field names, common fields first.
|
||||
*/
|
||||
export function analystFieldsFor(layerKeys) {
|
||||
function analystFieldsFor(layerKeys) {
|
||||
const fields = new Set(COMMON_FIELDS);
|
||||
for (const key of layerKeys) {
|
||||
const layer = ANALYST_LAYERS[key];
|
||||
@@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
|
||||
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
|
||||
*/
|
||||
export function applyFilter(records, filter, { time = false } = {}) {
|
||||
const { field, op } = filter || {};
|
||||
if (!field || !op) return records;
|
||||
const value = time ? toEpochMs(filter.value) : filter.value;
|
||||
return records.filter((r) => {
|
||||
const got = time ? toEpochMs(r[field]) : r[field];
|
||||
if (got === null || got === undefined || (time && !Number.isFinite(got)))
|
||||
return false;
|
||||
if (time && (op === 'eq' || op === 'neq'))
|
||||
return (got === value) === (op === 'eq');
|
||||
switch (op) {
|
||||
case 'gt':
|
||||
return Number(got) > Number(value);
|
||||
case 'gte':
|
||||
return Number(got) >= Number(value);
|
||||
case 'lt':
|
||||
return Number(got) < Number(value);
|
||||
case 'lte':
|
||||
return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean')
|
||||
return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq':
|
||||
return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains':
|
||||
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
@@ -167,28 +129,6 @@ export function applyScope(records, scope, resolved) {
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Numeric summary for the narration layer. */
|
||||
function summarize(items, sortField) {
|
||||
const summary = { count: items.length };
|
||||
if (sortField && items.length) {
|
||||
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
|
||||
// the engine's argument limit.
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of items) {
|
||||
const value = Number(item[sortField]);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
if (Number.isFinite(min)) {
|
||||
summary[`${sortField}Min`] = min;
|
||||
summary[`${sortField}Max`] = max;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
const OPS_BY_TYPE = Object.freeze({
|
||||
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
|
||||
@@ -213,7 +153,7 @@ const COMMON_UNITS = Object.freeze({
|
||||
* @param {string} field Field name.
|
||||
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
|
||||
*/
|
||||
export function analystFieldType(layerKeys, field) {
|
||||
function analystFieldType(layerKeys, field) {
|
||||
if (!analystFieldsFor(layerKeys).includes(field)) return null;
|
||||
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
|
||||
for (const key of layerKeys) {
|
||||
@@ -881,6 +821,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.
|
||||
@@ -890,8 +831,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,
|
||||
@@ -910,6 +859,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') {
|
||||
@@ -1049,6 +999,7 @@ export function createAnalystEngine(providers) {
|
||||
layersQueried,
|
||||
records: totals,
|
||||
scope: scopeNote,
|
||||
...(scopeSource ? { scopeSource } : {}),
|
||||
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||
...(queriedSnapshots.length
|
||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||
@@ -1081,6 +1032,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' },
|
||||
|
||||
@@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
|
||||
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();
|
||||
const year = part({ year: 'numeric' });
|
||||
// Year-first languages (2021年10月16日) keep their own order and units.
|
||||
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
|
||||
if (whole.startsWith(year)) return whole;
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
|
||||
<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"> ${escapeMarkup(t('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);
|
||||
|
||||
@@ -14,7 +14,7 @@ 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';
|
||||
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
'bhote-koshi-locator-callout';
|
||||
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
|
||||
@@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700;
|
||||
const INCIDENT_CALLOUT_GAP_MS = 200;
|
||||
const CALLOUT_REVEAL_DURATION_MS = 1200;
|
||||
const CALLOUT_PULSE_DURATION_MS = 1800;
|
||||
const CALLOUT_DOT_SIZE_PX = 7;
|
||||
const CALLOUT_RING_MIN_SIZE_PX = 16;
|
||||
const CALLOUT_RING_SIZE_RANGE_PX = 14;
|
||||
const CITY_CALLOUT_START_DELAY_MS = 350;
|
||||
@@ -1248,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
|
||||
export default bhoteKoshiLocatorLayer;
|
||||
@@ -23,4 +23,3 @@ export {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../layers/bikeshare/index.js';
|
||||
export default layer;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
export const PLANE_VERT_ASPECT = 16 / 9;
|
||||
const PLANE_VERT_ASPECT = 16 / 9;
|
||||
export const SUPPORT_KEYS = Object.freeze([
|
||||
'bl',
|
||||
'bm',
|
||||
@@ -128,7 +128,7 @@ export function planeSupportPoints(pose) {
|
||||
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
|
||||
* @returns {number}
|
||||
*/
|
||||
export function supportAltitude(capAltM, row, dims) {
|
||||
function supportAltitude(capAltM, row, dims) {
|
||||
return capAltM + row * dims.upVert;
|
||||
}
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ export function createCalibrationGizmo({
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(
|
||||
const { forwardHoriz } = viewAxesFor(
|
||||
camera.headingDeg,
|
||||
camera.pitchDeg,
|
||||
axes,
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
+3
-3
@@ -94,7 +94,7 @@ export function incumbentRankKm(
|
||||
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
|
||||
|
||||
/** Robust pool-distance percentile used to scale the screen-space term. */
|
||||
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
@@ -172,8 +172,8 @@ export function blendCenterRankKm(
|
||||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||||
// candidate before global rank fills the rest. Owner-tunable together with
|
||||
// the budgets above.
|
||||
export const CCTV_CARD_GRID_COLS = 5;
|
||||
export const CCTV_CARD_GRID_ROWS = 4;
|
||||
const CCTV_CARD_GRID_COLS = 5;
|
||||
const CCTV_CARD_GRID_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Screen-space distribution pass (owner round 2, item C). Buckets the
|
||||
|
||||
@@ -33,6 +33,14 @@ const VIDEO = {
|
||||
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',
|
||||
@@ -69,7 +77,10 @@ function recordingResponse() {
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
res.headers = { ...res.headers, ...headers };
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers[name] = value;
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
@@ -105,6 +116,7 @@ function mount(t, respond) {
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
WEBM,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
@@ -143,9 +155,9 @@ function mount(t, respond) {
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path) => {
|
||||
const call = async (path, headers = {}) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
|
||||
await handler({ url: path, headers, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
@@ -268,3 +280,251 @@ test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
[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);
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Cockpit 3D aircraft policy.
|
||||
|
||||
@@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) {
|
||||
for (const id of track.epochs.keys())
|
||||
if (!used.has(id)) track.epochs.delete(id);
|
||||
}
|
||||
export function validFix(fix) {
|
||||
function validFix(fix) {
|
||||
return (
|
||||
Number.isFinite(fix?.t) &&
|
||||
fix.t >= 0 &&
|
||||
|
||||
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
|
||||
assert.equal(storm.issuedAt, TIME);
|
||||
assert.equal(storm.positionAt, TIME);
|
||||
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
|
||||
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
|
||||
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
|
||||
assert.equal(storm.windKt, 25);
|
||||
assert.equal(storm.pressureHpa, 1006);
|
||||
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||
|
||||
+62
-4
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const OSM_CREDIT = {
|
||||
const OSM_CREDIT = {
|
||||
key: 'openstreetmap',
|
||||
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
|
||||
};
|
||||
export const OPENMAPTILES_CREDIT = {
|
||||
const OPENMAPTILES_CREDIT = {
|
||||
key: 'openfreemap',
|
||||
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
|
||||
};
|
||||
@@ -239,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',
|
||||
@@ -257,12 +258,28 @@ export const DATA_CREDITS = [
|
||||
'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:
|
||||
@@ -280,12 +297,22 @@ export const DATA_CREDITS = [
|
||||
'<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',
|
||||
@@ -300,6 +327,22 @@ export const DATA_CREDITS = [
|
||||
'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:
|
||||
@@ -386,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',
|
||||
@@ -398,6 +448,14 @@ export const DATA_CREDITS = [
|
||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||
},
|
||||
{
|
||||
key: 's2-ghostmaps',
|
||||
html:
|
||||
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
|
||||
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
|
||||
'Shown as published; reports are S2 assessments, not verified fact.',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
|
||||
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested 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`,
|
||||
);
|
||||
});
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
detectionHorizontalSector,
|
||||
labelBudgetFor,
|
||||
normalizeAllocationStrategy,
|
||||
normalizeProfile,
|
||||
profileForDensity,
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) {
|
||||
}
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
function compareCohortContenders(a, b) {
|
||||
const priorityDelta =
|
||||
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* module so density cannot drift into contradictory mode semantics again.
|
||||
*/
|
||||
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([
|
||||
@@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
return 'front';
|
||||
}
|
||||
|
||||
export const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
|
||||
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
|
||||
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
|
||||
|
||||
@@ -24,7 +24,7 @@ import * as input from './inputOwnership.js';
|
||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||
|
||||
/** The shared scene owners every Directions instance runs on. */
|
||||
export const directionsServices = Object.freeze({
|
||||
const directionsServices = Object.freeze({
|
||||
credits,
|
||||
render,
|
||||
sprites,
|
||||
@@ -49,15 +49,11 @@ export {
|
||||
DIRECTIONS_MODES,
|
||||
DIRECTIONS_POINTER_OWNER,
|
||||
DIRECTIONS_ROUTE_COLOR,
|
||||
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
|
||||
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
|
||||
FLIGHT_PROGRESS_MS,
|
||||
POINTER_TOOL_EXITS,
|
||||
STEP_ANCHOR_DEADLINE_MS,
|
||||
STEP_ANCHOR_FAST_ATTEMPTS,
|
||||
STEP_ANCHOR_RETRY_MS,
|
||||
STEP_ANCHOR_SLOW_RETRY_MS,
|
||||
createDirectionsLayer,
|
||||
createDirectionsStepOverlayEntry,
|
||||
directionsRequestUrl,
|
||||
directionsRowControls,
|
||||
|
||||
@@ -846,9 +846,13 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
t.after(() => setLocale('en'));
|
||||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||
assert.equal(routeDistanceText(994), '990\u00a0m');
|
||||
assert.equal(routeDistanceText(996), '1.0\u00a0km');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(59.4), '59\u00a0s');
|
||||
assert.equal(routeDurationText(59.6), '1\u00a0min');
|
||||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||
assert.equal(routeDistanceText(-1), '');
|
||||
@@ -872,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const cases = [
|
||||
[
|
||||
'ru',
|
||||
'Въезжайте по съезду плавно налево на I-95',
|
||||
'Сверните на съезд резко направо',
|
||||
'Займите левую полосу',
|
||||
],
|
||||
[
|
||||
'es',
|
||||
'Toma la rampa ligeramente a la izquierda hacia I-95',
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
'从出口向右后方驶出',
|
||||
'使用左转车道',
|
||||
],
|
||||
];
|
||||
for (const [locale, ramp, exit, lane] of cases) {
|
||||
setLocale(locale);
|
||||
assert.equal(
|
||||
directionsInstruction({
|
||||
type: 'on ramp',
|
||||
modifier: 'slight left',
|
||||
name: 'I-95',
|
||||
}),
|
||||
ramp,
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }),
|
||||
exit,
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'use lane', modifier: 'sharp left' }),
|
||||
lane,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
|
||||
} = {}) {
|
||||
return createApplicationEarthquakes({ source, ...options });
|
||||
}
|
||||
export default createEarthquakesLayer();
|
||||
+33
-10
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
|
||||
// into March, and the fire's age would describe a detection that never happened.
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const timeText = String(time ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const year = Number(date.slice(0, 4));
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid =
|
||||
Number.isFinite(year) &&
|
||||
Number.isFinite(month) &&
|
||||
Number.isFinite(day) &&
|
||||
Number.isFinite(hours) &&
|
||||
Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hours > 23 ||
|
||||
minutes > 59
|
||||
) {
|
||||
cache.set(key, 0);
|
||||
return 0;
|
||||
}
|
||||
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||
const parsed = new Date(ms);
|
||||
const result =
|
||||
parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
? ms
|
||||
: 0;
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
||||
|
||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||
const cache = new Map();
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(
|
||||
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||
Date.UTC(2026, 6, 16, 10, 6),
|
||||
);
|
||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||
});
|
||||
|
||||
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
|
||||
for (const [date, time] of [
|
||||
['2026-02-30', '1200'],
|
||||
['2026-04-31', '1200'],
|
||||
['2026-13-01', '1200'],
|
||||
['2026-07-16', '2400'],
|
||||
['2026-07-16', '1260'],
|
||||
]) {
|
||||
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: date, acqTime: time }),
|
||||
]);
|
||||
assert.equal(fire.acqMs, 0, `${date}:${time}`);
|
||||
assert.equal(fire.lat, 38.99488);
|
||||
}
|
||||
const [leap] = adaptFirmsRecords([
|
||||
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
|
||||
]);
|
||||
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
|
||||
});
|
||||
|
||||
test('daynight "D" → night false', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||
assert.equal(fire.night, false);
|
||||
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
|
||||
proxyRecord({ lat: 40.1 }),
|
||||
]);
|
||||
assert.equal(fires.length, 2);
|
||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
||||
assert.deepEqual(
|
||||
fires.map((f) => f.index),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
||||
const [fire] = adaptFirmsRecords([
|
||||
proxyRecord({ frp: 'n/a', brightness: undefined }),
|
||||
]);
|
||||
assert.equal(fire.frp, 0);
|
||||
assert.equal(fire.brightness, 0);
|
||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||
|
||||
@@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
|
||||
assert.equal(card.details[0], 'high · N20');
|
||||
});
|
||||
|
||||
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
|
||||
const crossed = buildFireCard(
|
||||
{ fire: fire({ frp: 9.96 }), position: {} },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(crossed.title, '▲ 10 MW');
|
||||
const below = buildFireCard(
|
||||
{ fire: fire({ frp: 9.94 }), position: {} },
|
||||
NOW,
|
||||
);
|
||||
assert.equal(below.title, '▲ 9.9 MW');
|
||||
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
|
||||
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||
});
|
||||
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||
assert.match(card.details[0], /SNPP$/);
|
||||
|
||||
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
|
||||
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
||||
import { fireDetectionKey } from './firmsLabels.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { getSelectedEntityContext } from './contextStore.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { shownEntityLabel } from '../voice/speech.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import path from 'node:path';
|
||||
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The voice model reads these labels as data (track_entity, get_entity_context,
|
||||
// pointer_context); the voice card translates them where it shows them.
|
||||
test('fire labels stay English data in a translated interface', () => {
|
||||
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
|
||||
try {
|
||||
setLocale('ru');
|
||||
h.click();
|
||||
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
|
||||
// The card's pattern must match what the producer actually writes.
|
||||
assert.equal(
|
||||
shownEntityLabel(getSelectedEntityContext().label),
|
||||
'Пожар · FRP 1520 МВт',
|
||||
);
|
||||
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
|
||||
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
|
||||
} finally {
|
||||
h.cleanup();
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on neither sprite nor card clears without moving the camera', () => {
|
||||
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
||||
try {
|
||||
|
||||
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
|
||||
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
|
||||
* @param {number} frp Megawatts.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFrpMw(frp) {
|
||||
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
|
||||
return Number(frp).toFixed(digits);
|
||||
}
|
||||
|
||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||
export function satelliteShortName(satellite) {
|
||||
const s = String(satellite || '')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
satelliteShortName,
|
||||
formatFrpMw,
|
||||
} from './firmsLabels.js';
|
||||
|
||||
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
||||
});
|
||||
|
||||
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
|
||||
assert.equal(formatFrpMw(9.96), '10');
|
||||
assert.equal(formatFrpMw(9.94), '9.9');
|
||||
assert.equal(formatFrpMw(0.8), '0.8');
|
||||
assert.equal(formatFrpMw(1520.27), '1520');
|
||||
assert.equal(formatFrpMw(210.4), '210');
|
||||
});
|
||||
|
||||
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
|
||||
assert.equal(satelliteShortName('N20'), 'N20');
|
||||
assert.equal(satelliteShortName('N21'), 'N21');
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
|
||||
export { tilesForBounds } from './tomtomTiles.js';
|
||||
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
|
||||
const source = createFlowTileSource();
|
||||
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
|
||||
source;
|
||||
@@ -1,162 +0,0 @@
|
||||
// src/data/flowTiles.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
decodeFlowTile,
|
||||
fetchFlowForBounds,
|
||||
tilesForBounds,
|
||||
getFlowSessionStats,
|
||||
resetFlowTileCache,
|
||||
} from './flowTiles.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16).
|
||||
const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf');
|
||||
const FIXTURE_TILE = { z: 12, x: 935, y: 1686 };
|
||||
|
||||
function loadFixture() {
|
||||
return fs.readFileSync(FIXTURE);
|
||||
}
|
||||
|
||||
// ── decodeFlowTile against the real fixture ─────────────────
|
||||
|
||||
test('fixture decode: more than 50 flow segments', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
assert.ok(segments.length > 50, `got ${segments.length}`);
|
||||
});
|
||||
|
||||
test('fixture decode: every trafficLevel is within [0, 1]', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`);
|
||||
assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture decode: all coordinates land in downtown Austin', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short');
|
||||
for (const [lon, lat] of s.coords) {
|
||||
assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`);
|
||||
assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found');
|
||||
});
|
||||
|
||||
test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.equal(typeof s.roadType, 'string');
|
||||
assert.equal(typeof s.closure, 'boolean');
|
||||
}
|
||||
// The fixture carries real closures — closure decoding is exercised, not vacuous.
|
||||
assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture');
|
||||
});
|
||||
|
||||
test('decode of a non-MVT buffer returns [] (defensive)', () => {
|
||||
assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []);
|
||||
});
|
||||
|
||||
// ── tilesForBounds (re-exported slippy math) ────────────────
|
||||
|
||||
test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => {
|
||||
const tiles = tilesForBounds({
|
||||
south: 30.2672 - 0.001, north: 30.2672 + 0.001,
|
||||
west: -97.7431 - 0.001, east: -97.7431 + 0.001,
|
||||
}, 12);
|
||||
assert.ok(
|
||||
tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686),
|
||||
`fixture tile missing: ${JSON.stringify(tiles)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── fetchFlowForBounds (stubbed fetch: cache + abort) ───────
|
||||
|
||||
/** Bounds fully inside the fixture tile. */
|
||||
const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 };
|
||||
|
||||
function stubFetch(impl) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
return () => { globalThis.fetch = original; };
|
||||
}
|
||||
|
||||
test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => {
|
||||
resetFlowTileCache();
|
||||
const calls = [];
|
||||
const restore = stubFetch(async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(loadFixture(), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-protobuf' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const segments = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/);
|
||||
assert.ok(segments.length > 50);
|
||||
assert.ok(getFlowSessionStats().tilesFetched >= 1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => {
|
||||
resetFlowTileCache();
|
||||
let calls = 0;
|
||||
const restore = stubFetch(async () => {
|
||||
calls += 1;
|
||||
return new Response(loadFixture(), { status: 200 });
|
||||
});
|
||||
try {
|
||||
const first = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
const second = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
assert.equal(calls, 1, 'second call must be served from the decode cache');
|
||||
assert.equal(second.length, first.length);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => {
|
||||
resetFlowTileCache();
|
||||
const restore = stubFetch(async (url, opts) => {
|
||||
// Mimic real fetch abort semantics.
|
||||
if (opts?.signal?.aborted) {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
throw err;
|
||||
}
|
||||
return new Response(loadFixture(), { status: 200 });
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }),
|
||||
(err) => err.name === 'AbortError'
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => {
|
||||
resetFlowTileCache();
|
||||
const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 }));
|
||||
try {
|
||||
await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -304,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;
|
||||
}
|
||||
});
|
||||
+103
-24
@@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** Largest file accepted, in bytes. */
|
||||
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
|
||||
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
|
||||
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
|
||||
/** Most files on the globe at once. */
|
||||
export const MAX_IMPORTED_FILES = 12;
|
||||
/** Most entities one file may create before it is refused. */
|
||||
@@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000;
|
||||
/** Most vertices (points of every line, ring and marker) in one file. */
|
||||
export const MAX_IMPORT_POSITIONS = 250_000;
|
||||
/** Most normalized style definitions and local image links in one KML/KMZ. */
|
||||
export const MAX_KML_LINKS = 2_000;
|
||||
const MAX_KML_LINKS = 2_000;
|
||||
/** Longest plain-text name kept from a file's features. */
|
||||
export const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
const MAX_IMPORT_NAME_LENGTH = 120;
|
||||
/** What the file picker offers. */
|
||||
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
|
||||
|
||||
@@ -1040,26 +1040,104 @@ export function detachImportedTime(dataSource) {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
function parseXml(text, what) {
|
||||
/** Repair only the first KML opening tag, scanning its delimiter once. */
|
||||
export function repairKmlRootNamespaces(value, what = 'KML') {
|
||||
const text = String(value);
|
||||
// Skip XML comments and processing instructions without treating their
|
||||
// literal '<kml' text as an opening root tag.
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
start = text.indexOf('<', start);
|
||||
if (start < 0) return text;
|
||||
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
|
||||
let quote = null;
|
||||
let subsetDepth = 0;
|
||||
let end = start + 9;
|
||||
for (; end < text.length; end++) {
|
||||
const char = text[end];
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") quote = char;
|
||||
else if (char === '<' && text.startsWith('<!--', end)) {
|
||||
// Comments and processing instructions inside the internal subset
|
||||
// carry no declaration syntax; jump past them as a whole.
|
||||
const skip = text.indexOf('-->', end + 4);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 2;
|
||||
} else if (char === '<' && text.startsWith('<?', end)) {
|
||||
const skip = text.indexOf('?>', end + 2);
|
||||
if (skip < 0) return text;
|
||||
end = skip + 1;
|
||||
} else if (char === '[') subsetDepth++;
|
||||
else if (char === ']') subsetDepth--;
|
||||
else if (char === '>' && subsetDepth === 0) break;
|
||||
}
|
||||
if (end === text.length) return text;
|
||||
start = end + 1;
|
||||
continue;
|
||||
}
|
||||
const close = text.startsWith('<!--', start)
|
||||
? '-->'
|
||||
: text.startsWith('<?', start)
|
||||
? '?>'
|
||||
: null;
|
||||
if (!close) break;
|
||||
const end = text.indexOf(close, start + 2);
|
||||
if (end < 0) return text; // The XML parser reports an unterminated preamble.
|
||||
start = end + close.length;
|
||||
}
|
||||
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
|
||||
if (!match) return text;
|
||||
let quote = null;
|
||||
let end = -1;
|
||||
for (let index = start + match[0].length; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === '>') {
|
||||
end = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0)
|
||||
throw new GeoImportError(
|
||||
`That ${what} file is not valid XML.`,
|
||||
msg('That {format} file is not valid XML.'),
|
||||
{ format: what },
|
||||
);
|
||||
// Walk the root's attributes in order so quoted values are never read as
|
||||
// declarations. Anything the walk cannot tokenize is kept verbatim for the
|
||||
// XML parser to judge.
|
||||
const seen = new Set();
|
||||
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
|
||||
let position = start + match[0].length;
|
||||
let root = text.slice(start, position);
|
||||
for (;;) {
|
||||
attribute.lastIndex = position;
|
||||
const found = attribute.exec(text);
|
||||
if (!found || attribute.lastIndex > end) break;
|
||||
position = attribute.lastIndex;
|
||||
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
|
||||
if (prefix && seen.has(prefix)) continue;
|
||||
if (prefix) seen.add(prefix);
|
||||
root += found[0];
|
||||
}
|
||||
root += text.slice(position, end);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/(\/?)>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
|
||||
);
|
||||
return text.slice(0, start) + root + text.slice(end);
|
||||
}
|
||||
|
||||
/** Parse KML or GPX with the shared namespace repairs and XML validation. */
|
||||
export function parseXml(text, what) {
|
||||
// Match the namespace repairs Cesium applies to ordinary exported KML.
|
||||
if (what !== 'GPX')
|
||||
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
|
||||
const seen = new Set();
|
||||
root = root.replace(
|
||||
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
|
||||
(declaration, prefix) => {
|
||||
if (seen.has(prefix)) return '';
|
||||
seen.add(prefix);
|
||||
return declaration;
|
||||
},
|
||||
);
|
||||
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
|
||||
root = root.replace(
|
||||
/>$/,
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
);
|
||||
return root;
|
||||
});
|
||||
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
|
||||
const doc = new DOMParser().parseFromString(
|
||||
String(text).replace(/^/, ''),
|
||||
'application/xml',
|
||||
@@ -1073,7 +1151,8 @@ function parseXml(text, what) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
function decodeXml(bytes) {
|
||||
/** Decode XML bytes, honouring a UTF-16 byte-order mark. */
|
||||
export function decodeXml(bytes) {
|
||||
const encoding =
|
||||
bytes[0] === 0xff && bytes[1] === 0xfe
|
||||
? 'utf-16le'
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GeoImportError,
|
||||
repairKmlRootNamespaces,
|
||||
loadGeoFile,
|
||||
MAX_IMPORT_POSITIONS,
|
||||
MAX_IMPORT_ENTITIES,
|
||||
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
|
||||
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
|
||||
assert.ok(track.polyline.clampToGround.getValue());
|
||||
});
|
||||
|
||||
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
|
||||
const text =
|
||||
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
|
||||
const result = repairKmlRootNamespaces(text);
|
||||
assert.match(result, /note="a > b"/);
|
||||
assert.match(result, /xmlns:x="first"/);
|
||||
assert.doesNotMatch(result, /second/);
|
||||
assert.match(
|
||||
result,
|
||||
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
|
||||
);
|
||||
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
|
||||
assert.match(
|
||||
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
|
||||
/XMLSchema-instance"\/>$/,
|
||||
);
|
||||
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
|
||||
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
|
||||
});
|
||||
|
||||
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
|
||||
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
|
||||
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
|
||||
catch (error) { if (error.name !== 'GeoImportError') throw error; }
|
||||
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
|
||||
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', source],
|
||||
{ timeout: 5000, encoding: 'utf8' },
|
||||
);
|
||||
assert.ifError(child.error);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
});
|
||||
|
||||
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
|
||||
const input =
|
||||
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
|
||||
const input =
|
||||
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(' xmlns:gx="b"', ''),
|
||||
);
|
||||
});
|
||||
|
||||
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
|
||||
for (const prolog of [
|
||||
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
|
||||
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
|
||||
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
|
||||
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
|
||||
]) {
|
||||
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
|
||||
assert.equal(
|
||||
repairKmlRootNamespaces(input),
|
||||
input.replace(
|
||||
' xmlns:gx="b">',
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
),
|
||||
prolog,
|
||||
);
|
||||
}
|
||||
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
|
||||
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
|
||||
});
|
||||
|
||||
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
|
||||
const input =
|
||||
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
|
||||
const result = repairKmlRootNamespaces(input);
|
||||
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
|
||||
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
|
||||
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
|
||||
assert.match(
|
||||
repairKmlRootNamespaces(xsi),
|
||||
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Inspect encoded headers before allowing the browser to decode local images.
|
||||
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||||
const MAX_SIDE = 8192;
|
||||
|
||||
/** Width and height from a supported raster header; no image is decoded. */
|
||||
|
||||
@@ -35,14 +35,14 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
|
||||
/** VehiclePosition.VehicleStopStatus enum → label. */
|
||||
export const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
const VEHICLE_STOP_STATUS = Object.freeze({
|
||||
0: 'INCOMING_AT',
|
||||
1: 'STOPPED_AT',
|
||||
2: 'IN_TRANSIT_TO',
|
||||
});
|
||||
|
||||
/** VehiclePosition.OccupancyStatus enum → label. */
|
||||
export const OCCUPANCY_STATUS = Object.freeze({
|
||||
const OCCUPANCY_STATUS = Object.freeze({
|
||||
0: 'EMPTY',
|
||||
1: 'MANY_SEATS_AVAILABLE',
|
||||
2: 'FEW_SEATS_AVAILABLE',
|
||||
@@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000;
|
||||
export const GTFS_MAX_STRING_CHARS = 256;
|
||||
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
|
||||
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
|
||||
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
|
||||
|
||||
/**
|
||||
* Read a length-delimited string, refusing one that is implausibly long.
|
||||
|
||||
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/vessels/selection.js',
|
||||
'pickContact(viewer.scene, click.position)',
|
||||
],
|
||||
];
|
||||
for (const [file, firstPick] of guarded) {
|
||||
const source = read(file);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// src/data/issPass.js
|
||||
/**
|
||||
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
|
||||
* original findNextIssPass signature and defaults still work.
|
||||
*/
|
||||
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
|
||||
|
||||
export { lookAnglesAt };
|
||||
|
||||
/**
|
||||
* Predict the next ISS pass over an observer location.
|
||||
* @param {Object} options
|
||||
* @param {Object} options.satrec SGP4 satellite record
|
||||
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
|
||||
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
|
||||
* @param {number} options.fromMs UTC start time in milliseconds
|
||||
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||
* @param {number} [options.horizonHours=24] Maximum search window in hours
|
||||
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
|
||||
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
|
||||
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
|
||||
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
|
||||
*/
|
||||
export function findNextIssPass({
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg = 10,
|
||||
horizonHours = 24,
|
||||
coarseStepSec = 30,
|
||||
fineStepSec = 5,
|
||||
requireVisible = false,
|
||||
}) {
|
||||
return findNextSatellitePass({
|
||||
satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs,
|
||||
minElevDeg,
|
||||
horizonHours,
|
||||
coarseStepSec,
|
||||
fineStepSec,
|
||||
requireVisible,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// src/data/issPass.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { findNextIssPass, lookAnglesAt } from './issPass.js';
|
||||
import { findNextSatellitePass } from './satellitePass.js';
|
||||
|
||||
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
|
||||
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 AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
|
||||
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch
|
||||
|
||||
test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 });
|
||||
assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit');
|
||||
assert.ok(pass.riseMs > FROM_MS);
|
||||
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
|
||||
assert.ok(pass.maxElevDeg >= 10);
|
||||
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
|
||||
|
||||
// Independent re-check: elevation at the reported peak really is the max-ish
|
||||
// and ≥ threshold; one minute before rise it is below threshold.
|
||||
const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||
assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5);
|
||||
const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||
assert.ok(before && before.elevDeg < 10);
|
||||
});
|
||||
|
||||
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
|
||||
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
|
||||
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
|
||||
});
|
||||
|
||||
test('returns null when no pass clears an absurd threshold', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
|
||||
assert.equal(pass, null);
|
||||
});
|
||||
+6
-21
@@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
||||
* out-of-grammar ID is rejected outright, because half an address is a
|
||||
* DIFFERENT aircraft, not a shorter name for the same one.
|
||||
*/
|
||||
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
|
||||
export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
|
||||
/**
|
||||
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
|
||||
* complete reserved one-character space plus every two-character base-36
|
||||
@@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) {
|
||||
});
|
||||
}
|
||||
|
||||
function stringOption(key, token, defaultValue) {
|
||||
return Object.freeze({
|
||||
key,
|
||||
token,
|
||||
defaultValue,
|
||||
normalize: (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value))
|
||||
return String(value).trim().toLowerCase();
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
},
|
||||
encode: (value) => String(value),
|
||||
decode: (value) =>
|
||||
typeof value === 'string' && value.trim()
|
||||
? value.trim().toLowerCase()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
function enumOption(
|
||||
key,
|
||||
token,
|
||||
@@ -675,6 +655,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
token: 'x',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 's2-ghostmaps',
|
||||
token: '5',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'satellites',
|
||||
token: 's',
|
||||
|
||||
+145
-13
@@ -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, 31);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 32);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
|
||||
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(), '5');
|
||||
assert.equal(nextLayerStateToken(), '6');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '5',
|
||||
bravo: '6',
|
||||
alpha: '6',
|
||||
bravo: '7',
|
||||
}),
|
||||
'7',
|
||||
'8',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
[...'6789'].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: '5',
|
||||
next: '6',
|
||||
future: '6',
|
||||
next: '7',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 5/,
|
||||
/next free token 6/,
|
||||
);
|
||||
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: '5' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
|
||||
),
|
||||
/next free token 6/,
|
||||
/next free token 7/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
@@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated
|
||||
coordinator.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function enabledLayerParam(sink) {
|
||||
return new URLSearchParams(encode(sink.provider())).get('l');
|
||||
}
|
||||
|
||||
test('Clear All drops a saved layer a context mode had already switched off', async () => {
|
||||
const manager = productionManager();
|
||||
const storage = memoryStorage();
|
||||
const sink = shareSink();
|
||||
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
||||
await coordinator.start();
|
||||
await manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||
'earthquakes',
|
||||
'flights',
|
||||
]);
|
||||
// Context isolation is a temporary, passive takeover: the saved choice stays.
|
||||
await manager.setEnabled('earthquakes', false, { origin: 'programmatic' });
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||
'earthquakes',
|
||||
'flights',
|
||||
]);
|
||||
|
||||
const updatesBefore = sink.updates;
|
||||
const result = await manager.clearSelectedLayers({ origin: 'user' });
|
||||
assert.deepEqual(result.targetIds, ['flights']);
|
||||
assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh');
|
||||
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
|
||||
assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers');
|
||||
assert.deepEqual(
|
||||
parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY))
|
||||
.enabledLayerIds,
|
||||
[],
|
||||
'a reload restores nothing',
|
||||
);
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => {
|
||||
let manager;
|
||||
manager = productionManager({
|
||||
// Contacts-style owner: its own teardown releases a borrowed dependency
|
||||
// without awaiting it, which supersedes Clear All's turn for that layer.
|
||||
'military-awareness': {
|
||||
disable() {
|
||||
void manager.setEnabled('flights', false, { origin: 'programmatic' });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const storage = memoryStorage();
|
||||
const sink = shareSink();
|
||||
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
||||
await coordinator.start();
|
||||
await manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||
await manager.setEnabled('military-awareness', true, { origin: 'user' });
|
||||
|
||||
const result = await manager.clearSelectedLayers({ origin: 'user' });
|
||||
assert.equal(
|
||||
result.items.find((item) => item.id === 'flights')?.superseded,
|
||||
true,
|
||||
'the fixture reproduces the superseded flights turn',
|
||||
);
|
||||
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
|
||||
assert.equal(enabledLayerParam(sink), '');
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('Clear All keeps a layer a newer explicit request turned back on', async () => {
|
||||
const manager = productionManager();
|
||||
const sink = shareSink();
|
||||
const coordinator = new LayerStateCoordinator(manager, sink, {
|
||||
storage: memoryStorage(),
|
||||
});
|
||||
await coordinator.start();
|
||||
await manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||
const clearing = manager.clearSelectedLayers({ origin: 'user' });
|
||||
// A direct request issued during the batch owns its layer.
|
||||
const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' });
|
||||
await Promise.all([clearing, reenable]);
|
||||
assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']);
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||
'earthquakes',
|
||||
]);
|
||||
assert.equal(enabledLayerParam(sink), 'e');
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('a passive clear never rewrites saved layers', async () => {
|
||||
const manager = productionManager();
|
||||
const storage = memoryStorage();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
storage,
|
||||
});
|
||||
await coordinator.start();
|
||||
await manager.setEnabled('flights', true, { origin: 'user' });
|
||||
await manager.setEnabled('flights', false, { origin: 'programmatic' });
|
||||
const writes = storage.writes.length;
|
||||
await manager.clearSelectedLayers({ origin: 'programmatic' });
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']);
|
||||
assert.equal(storage.writes.length, writes);
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('Clear All cancels a saved restore still waiting to start', async () => {
|
||||
const gate = deferred();
|
||||
const manager = productionManager();
|
||||
const sink = shareSink();
|
||||
const coordinator = new LayerStateCoordinator(manager, sink, {
|
||||
storage: memoryStorage(),
|
||||
restoreGate: gate.promise,
|
||||
});
|
||||
const params = new URLSearchParams('v=2&l=e.f');
|
||||
const restore = coordinator.start({
|
||||
shareLayerState: decodeLayerStateParams(params),
|
||||
});
|
||||
await manager.clearSelectedLayers({ origin: 'user' });
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
|
||||
gate.resolve();
|
||||
const results = await restore;
|
||||
for (const id of ['earthquakes', 'flights']) {
|
||||
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
|
||||
}
|
||||
assert.deepEqual([...manager.getEnabledLayerIds()], []);
|
||||
assert.equal(enabledLayerParam(sink), '');
|
||||
coordinator.destroy();
|
||||
});
|
||||
@@ -148,6 +148,36 @@ export class LayerStateCoordinator {
|
||||
return this.restorePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the running layer state with a share opened later in this tab
|
||||
* (hash navigation). Every in-flight restore and pending shared Follow is
|
||||
* superseded; the new state restores through the same passive share origin
|
||||
* as start-up, so recipient-local preferences are never written. A share
|
||||
* without a layer payload restores the defaults, as a reload of it would.
|
||||
*/
|
||||
applyShareLayerState({
|
||||
shareLayerState = null,
|
||||
shareCreatedAtMs = null,
|
||||
} = {}) {
|
||||
if (this._destroyed) return Promise.resolve([]);
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort('superseded-by-share-navigation');
|
||||
this._revokePendingTrackingWatch('superseded-by-share-navigation');
|
||||
const selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
this._source = selected ? 'share' : 'legacy-share';
|
||||
this._shareCreatedAtMs =
|
||||
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
|
||||
this._durableState = selected || createDefaultLayerState();
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
this.restorePromise = this._restoreSelectedState(
|
||||
LAYER_RESTORE_ORIGINS.share,
|
||||
);
|
||||
return this.restorePromise;
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this._source;
|
||||
}
|
||||
@@ -220,6 +250,30 @@ export class LayerStateCoordinator {
|
||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||
}
|
||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||
if (change.type === 'selection-cleared') {
|
||||
// Only layers a newer request turned back on survive Clear All. Any
|
||||
// other saved layer would reappear from the share link or a reload.
|
||||
const stillEnabled = new Set(change.enabledLayerIds || []);
|
||||
const dropped = this._durableState.enabledLayerIds.filter(
|
||||
(id) => !stillEnabled.has(id),
|
||||
);
|
||||
if (dropped.length === 0) return;
|
||||
for (const layerId of dropped) {
|
||||
this._restoreControllers
|
||||
.get(layerId)
|
||||
?.abort('superseded-by-explicit-visibility');
|
||||
}
|
||||
if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) {
|
||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||
}
|
||||
this._commitExplicit({
|
||||
...this._durableState,
|
||||
enabledLayerIds: this._durableState.enabledLayerIds.filter((id) =>
|
||||
stillEnabled.has(id),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (change.type === 'visibility') {
|
||||
this._restoreControllers
|
||||
.get(change.layerId)
|
||||
@@ -312,15 +366,19 @@ export class LayerStateCoordinator {
|
||||
}
|
||||
|
||||
async _restoreSelectedState(origin) {
|
||||
// One controller set per run: a superseded run must not clear the newer
|
||||
// run's controllers when it settles.
|
||||
const controllers = new Map();
|
||||
for (const entry of LAYER_STATE_REGISTRY) {
|
||||
this._restoreControllers.set(entry.id, new AbortController());
|
||||
controllers.set(entry.id, new AbortController());
|
||||
}
|
||||
this._restoreControllers = controllers;
|
||||
try {
|
||||
await this._waitForRestoreGate();
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
const settled = await Promise.allSettled(
|
||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = this._restoreControllers.get(entry.id);
|
||||
const controller = controllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
@@ -370,7 +428,7 @@ export class LayerStateCoordinator {
|
||||
}));
|
||||
}),
|
||||
);
|
||||
this.lastRestoreResults = settled.map((result, index) => {
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
const entry = LAYER_STATE_REGISTRY[index];
|
||||
return {
|
||||
@@ -387,9 +445,13 @@ export class LayerStateCoordinator {
|
||||
succeeded: false,
|
||||
};
|
||||
});
|
||||
return this.lastRestoreResults.map((result) => ({ ...result }));
|
||||
// A run superseded by a newer share keeps the newer run's results.
|
||||
if (this._restoreControllers === controllers)
|
||||
this.lastRestoreResults = results;
|
||||
return results.map((result) => ({ ...result }));
|
||||
} finally {
|
||||
this._restoreControllers.clear();
|
||||
if (this._restoreControllers === controllers)
|
||||
this._restoreControllers.clear();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ 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, 31);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'street-level': '0',
|
||||
'gnss-integrity': '3',
|
||||
'air-quality': '4',
|
||||
's2-ghostmaps': '5',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
|
||||
.future,
|
||||
'5',
|
||||
'6',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -181,14 +182,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', '5']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '6']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
assert.equal(nextLayerStateToken(published), '7');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '6',
|
||||
newcomer: '7',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '5',
|
||||
competing: '6',
|
||||
}),
|
||||
/next free token 6/,
|
||||
/next free token 7/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '5',
|
||||
newcomer: '6',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '5']],
|
||||
[{ id: 'pr-b', token: '5' }],
|
||||
[...reservationRows, ['pr-b', '6']],
|
||||
[{ id: 'pr-b', token: '6' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
|
||||
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
|
||||
[
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '5' },
|
||||
{ id: 'pr-a', token: '6' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
|
||||
assert.equal(nextLayerStateToken(published), '7');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
|
||||
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: 6/);
|
||||
assert.match(helper.stdout, /pr-b: 7/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
|
||||
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
|
||||
[
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
{ id: 'pr-a', token: '6' },
|
||||
{ id: 'pr-b', token: '7' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /32 published, 1 new/);
|
||||
assert.match(corrected.stdout, /33 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -336,7 +337,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 = [...'56789'].map((digit) => [
|
||||
const priorDigits = [...'6789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -373,7 +374,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, 73);
|
||||
assert.equal(params.get('l').length, 75);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -391,7 +392,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, 32);
|
||||
assert.equal(Object.keys(published).length, 33);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
["radio", "r"],
|
||||
["recent-imagery", "1"],
|
||||
["rocket-launches", "x"],
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
|
||||
+69
-17
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
|
||||
/**
|
||||
* LayerLifecycle — Manages registration, toggling, and update loops
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
|
||||
* listener methods. Without one, periodic work retains its headless behavior.
|
||||
*/
|
||||
export class LayerLifecycle {
|
||||
constructor(
|
||||
viewer,
|
||||
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||
{
|
||||
allowQaRegistration = false,
|
||||
getSourceAvailability,
|
||||
visibilityTarget = null,
|
||||
} = {},
|
||||
) {
|
||||
this._catalogSourceAvailability = getSourceAvailability;
|
||||
this._visibilityTarget = visibilityTarget;
|
||||
this.viewer = viewer;
|
||||
this._activityListeners = new Set();
|
||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||
@@ -528,6 +535,13 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
_stopUpdateLoop(entry) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
entry.removeVisibilityListener?.();
|
||||
entry.removeVisibilityListener = null;
|
||||
}
|
||||
|
||||
_armUpdateLoop(layerId, entry) {
|
||||
const configuredRefreshInterval = Number(entry.module.refreshInterval);
|
||||
const updateInterval = Number(entry.module.updateInterval);
|
||||
@@ -538,9 +552,45 @@ export class LayerLifecycle {
|
||||
? updateInterval
|
||||
: 0;
|
||||
if (refreshInterval > 0) {
|
||||
entry.intervalId = setInterval(() => {
|
||||
const visibilityDocument = this._visibilityTarget;
|
||||
let intervalId = null;
|
||||
let dueAt = 0;
|
||||
const canPoll = () =>
|
||||
!visibilityDocument?.hidden &&
|
||||
visibilityDocument?.visibilityState !== 'hidden' &&
|
||||
entry.enabled &&
|
||||
!entry.destroying &&
|
||||
!entry.refreshing &&
|
||||
entry.intervalId === intervalId;
|
||||
// dueAt and the interval phase share one origin, so every interval tick
|
||||
// is a due poll. A visibility catch-up re-arms the interval at the new
|
||||
// deadline; otherwise the old phase would skip its next tick.
|
||||
const schedule = () => {
|
||||
clearInterval(intervalId);
|
||||
dueAt = Date.now() + refreshInterval;
|
||||
intervalId = setInterval(onInterval, refreshInterval);
|
||||
entry.intervalId = intervalId;
|
||||
};
|
||||
const onInterval = () => {
|
||||
if (!canPoll()) return;
|
||||
dueAt = Date.now() + refreshInterval;
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
}, refreshInterval);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (!canPoll() || Date.now() < dueAt) return;
|
||||
schedule();
|
||||
void this._runPeriodicUpdate(layerId, entry);
|
||||
};
|
||||
schedule();
|
||||
visibilityDocument?.addEventListener?.(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
entry.removeVisibilityListener = () =>
|
||||
visibilityDocument?.removeEventListener?.(
|
||||
'visibilitychange',
|
||||
onVisibilityChange,
|
||||
);
|
||||
} else if (updateInterval === 0) {
|
||||
entry.intervalId = setInterval(() => {
|
||||
if (!entry.enabled) return;
|
||||
@@ -772,8 +822,7 @@ export class LayerLifecycle {
|
||||
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
|
||||
settleLifecycle();
|
||||
if (!entry.enabled && entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
this._publishActivity({ type: 'status' });
|
||||
if (!compensated) {
|
||||
@@ -833,8 +882,7 @@ export class LayerLifecycle {
|
||||
}
|
||||
if (signal?.aborted) return finishCancelledDisable('disable');
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
entry.enabled = false;
|
||||
entry.lifecycleUncertain = false;
|
||||
@@ -845,8 +893,7 @@ export class LayerLifecycle {
|
||||
let abortCleanup = null;
|
||||
const cancelEnable = () => {
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
// Disable immediately so modules with their own AbortController (Radio)
|
||||
// cancel pending update work at the same turn boundary. A second
|
||||
@@ -876,8 +923,7 @@ export class LayerLifecycle {
|
||||
// a later abort cannot revoke a successful retry.
|
||||
signal?.removeEventListener('abort', cancelEnable);
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
await abortCleanup;
|
||||
let cleanupConfirmed = false;
|
||||
@@ -918,8 +964,7 @@ export class LayerLifecycle {
|
||||
};
|
||||
const finishFailedEnable = async (phase, error) => {
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
let cleanupConfirmed = false;
|
||||
try {
|
||||
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
|
||||
// Always clear any stale interval before assigning a new one, so we never
|
||||
// orphan a running timer and end up double-polling.
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
|
||||
// Manager-owned periodic refresh work has one normalized loading/error
|
||||
@@ -1606,6 +1650,15 @@ export class LayerLifecycle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear All owns the whole selection, not only the layers it switched off
|
||||
// itself. A layer already off for a passive reason (isolated by a context
|
||||
// mode, released by a dependency owner, or superseded mid-batch) gets no
|
||||
// OFF of its own here, so publish the set that is still on afterwards.
|
||||
this._notifyListeners({
|
||||
type: 'selection-cleared',
|
||||
origin,
|
||||
enabledLayerIds: [...this.getEnabledLayerIds()],
|
||||
});
|
||||
const items = targetIds.map((id) => {
|
||||
const state = this.getLayerLifecycleState(id) || {
|
||||
enabled: false,
|
||||
@@ -2158,8 +2211,7 @@ export class LayerLifecycle {
|
||||
return false;
|
||||
}
|
||||
if (entry.intervalId) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.intervalId = null;
|
||||
this._stopUpdateLoop(entry);
|
||||
}
|
||||
entry.enabled = false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
mapAnalystRecord,
|
||||
} from './localGeojsonCore.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost,
|
||||
});
|
||||
|
||||
const DC_RAW = {
|
||||
id: '1176042553',
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import {
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayPublisher as createPublisher,
|
||||
} from './localGeojsonCore.js';
|
||||
|
||||
export * from './localGeojsonCore.js';
|
||||
|
||||
/** Create a layer using the standalone app's operations; retain overlay overrides. */
|
||||
export function createLocalGeoJsonLayer(options) {
|
||||
return createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost:
|
||||
options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.overlayHost,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an overlay publisher using the standalone app's host by default. */
|
||||
export function createLocalInfrastructureOverlayPublisher(options) {
|
||||
return createPublisher({
|
||||
...options,
|
||||
host:
|
||||
options.host === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.host,
|
||||
});
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
GROUND_SAMPLE_MAX_ARMED_RETRIES,
|
||||
LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
LOCAL_STEM_TIP_EPSILON_M,
|
||||
createLocalGeoJsonLayer,
|
||||
createLocalGeoJsonLayer as createLayer,
|
||||
createLocalInfrastructureOverlayEntry,
|
||||
createLocalInfrastructureOverlayPublisher,
|
||||
localDatasetError,
|
||||
localInfrastructureOverlayCopy,
|
||||
selectLocalInfrastructureOverlayCohort,
|
||||
} from './localGeojson.js';
|
||||
} from './localGeojsonCore.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js';
|
||||
import {
|
||||
@@ -21,6 +22,13 @@ import {
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
|
||||
});
|
||||
|
||||
class MockLayerEvent {
|
||||
constructor() {
|
||||
this.listeners = new Set();
|
||||
@@ -1337,14 +1345,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||
});
|
||||
|
||||
|
||||
test('standalone publisher retains its default host for an undefined override', () => {
|
||||
const publisher = createLocalInfrastructureOverlayPublisher({
|
||||
sourceId: 'local-default-host-test', host: undefined,
|
||||
});
|
||||
assert.doesNotThrow(() => {
|
||||
publisher.show();
|
||||
publisher.hide();
|
||||
publisher.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLocalGeoJsonLayer } from './localGeojson.js';
|
||||
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
|
||||
import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js';
|
||||
import { getContextStore } from './contextStore.js';
|
||||
|
||||
/** The catalog's services, with the overlay host each test supplies. */
|
||||
const createLocalGeoJsonLayer = (options) =>
|
||||
createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost,
|
||||
});
|
||||
|
||||
const dataset = JSON.stringify({
|
||||
type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
|
||||
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Pure selection policy for the bundled local-infrastructure layers
|
||||
* (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer`
|
||||
* dataset). Cesium-specific projection, occlusion, and stem geometry stay in
|
||||
* `localGeojson.js`; this module turns the resulting in-view records into a
|
||||
* `localGeojsonCore.js`; this module turns the resulting in-view records into a
|
||||
* bounded "active" set — the only records that layer should spend per-frame
|
||||
* stem-geometry and ground-sample work on.
|
||||
*
|
||||
@@ -80,7 +80,7 @@ export const INFRA_LOD_INCUMBENT_BONUS = 250;
|
||||
|
||||
/**
|
||||
* Distance at or beyond which the proximity penalty is fully applied (m).
|
||||
* Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js.
|
||||
* Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in infrastructureOverlayEntry.js.
|
||||
*/
|
||||
export const INFRA_LOD_FAR_M = 12_000_000;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200;
|
||||
* (callers only compare it), but every input is normalized so the output is
|
||||
* always a finite number.
|
||||
*
|
||||
* @param {number} priority Existing label-priority score (localGeojson.js).
|
||||
* @param {number} priority Existing label-priority score (localGeojsonCore.js).
|
||||
* @param {number} distanceM Camera→feature distance in metres.
|
||||
* @param {boolean} isIncumbent Record currently holds an active stem.
|
||||
* @param {object} [options]
|
||||
@@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Probe window for the motion fallback. `localGeojson.js` marks its selection
|
||||
* Probe window for the motion fallback. `localGeojsonCore.js` marks its selection
|
||||
* dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice
|
||||
* `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route
|
||||
* flight, or a continuous orbit never emits one. Without a fallback the
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { localGeoJsonServices } from './localGeojson.js';
|
||||
import { createInfrastructureLayers } from './infrastructure.js';
|
||||
import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
|
||||
import submarineCablesLayer from './telegeographySubmarineCables.js';
|
||||
|
||||
const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices);
|
||||
|
||||
// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps
|
||||
// the historical `local-` prefix for persistence + voice-tool-enum compat,
|
||||
// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side.
|
||||
const fires = createFirmsHeatmapLayer({
|
||||
id: 'local-firms',
|
||||
name: 'FIRMS Active Fires',
|
||||
icon: '▲',
|
||||
source: 'NASA FIRMS · LIVE',
|
||||
});
|
||||
|
||||
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||
@@ -14,5 +14,4 @@ this derived database.
|
||||
|
||||
The public-release snapshot removes contact-oriented fields and note values
|
||||
that contain email or phone identifiers. The runtime layer does not display or
|
||||
depend on those fields. Both `dams.geojson` and the runtime JSONL form carry the
|
||||
same privacy transform.
|
||||
depend on those fields.
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"FeatureCollection","features":[{"id":-19953989,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.292076717,46.16897504],[14.292076711,46.168976415],[14.292080818,46.169315044],[14.292082791,46.169327497],[14.292088326,46.169339424],[14.292097199,46.169350341],[14.292109051,46.169359805],[14.2921234,46.169367433],[14.292139665,46.169372916],[14.292454909,46.16945313],[14.292472725,46.169456276],[14.292491094,46.169456843],[14.292509244,46.169454808],[14.293490178,46.169271915],[14.293505685,46.169267932],[14.293519874,46.169262048],[14.293532266,46.169254461],[14.293542439,46.16924543],[14.293550049,46.169235261],[14.293554837,46.169224298],[14.293556643,46.169212912],[14.293555403,46.16920149],[14.293552972,46.169191516],[14.293548058,46.169179216],[14.293539623,46.169167869],[14.293528021,46.169157953],[14.293513742,46.169149885],[14.293497386,46.169144005],[14.293479642,46.16914056],[14.29260928,46.169036741],[14.292221224,46.168859348],[14.292205414,46.168853594],[14.292188274,46.16885012],[14.29217048,46.168849063],[14.292152735,46.168850464],[14.292135742,46.168854269],[14.292120171,46.168860327],[14.292106638,46.168868398],[14.292095679,46.168878164],[14.292087726,46.168889239],[14.292083093,46.168901184],[14.292078818,46.168919205],[14.292077639,46.168928437],[14.292076717,46.16897504]]]},"properties":{"name":"HE Podlubnik - Centrala","tags":{"name":"HE Podlubnik - Centrala","type":"site","power":"plant","alt_name":"HE Škofja Loka","operator":"Elektro Gorenjska","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"330KW"},"osm_id":-19953989,"output":"330KW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19754263,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.715342348,34.641127032],[69.71550043,34.641135916],[69.715570922,34.64110658],[69.717521056,34.641462978],[69.71812514,34.641577224],[69.718373076,34.641638579],[69.718438371,34.641451327],[69.718438036,34.641377064],[69.718220274,34.641368766],[69.718144586,34.64121412],[69.718014247,34.640948246],[69.717587357,34.640673654],[69.71740899,34.64063493],[69.717223833,34.640595284],[69.716953182,34.640548261],[69.716766517,34.640531246],[69.716362928,34.640542142],[69.716212976,34.640950425],[69.715856829,34.640920669],[69.715476123,34.640943636],[69.715342348,34.641127032]]]},"properties":{"name":"نغلو برېښناکوټ","tags":{"name":"نغلو برېښناکوټ","type":"multipolygon","power":"plant","name:de":"Naghlu-Staudamm","name:en":"Naghlu Dam","name:ja":"ナグルーダム","name:no":"Naghludemningen","name:ru":"ГЭС Наглу","name:uk":"ГЕС Наглу","waterway":"dam","wikidata":"Q4755783","wikipedia":"en:Naghlu Dam","start_date":"1967","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":-19754263,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19685440,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.597119846,-25.41383667],[-54.597234845,-25.413703733],[-54.597107524,-25.413586135],[-54.59730056,-25.412625569],[-54.597341379,-25.412122152],[-54.597361664,-25.411634241],[-54.597330064,-25.410920019],[-54.597191679,-25.410193224],[-54.597057568,-25.409737668],[-54.59698146,-25.409498365],[-54.596806446,-25.409085472],[-54.596524311,-25.408510306],[-54.596161124,-25.407928853],[-54.59586826,-25.40753591],[-54.595651001,-25.407280513],[-54.595510101,-25.407146654],[-54.595350007,-25.407013549],[-54.595191002,-25.406910368],[-54.595019173,-25.406815317],[-54.594639221,-25.40665405],[-54.594135721,-25.406544833],[-54.593702209,-25.406530333],[-54.593262159,-25.406579954],[-54.592841471,-25.406631754],[-54.592785815,-25.406430001],[-54.588964338,-25.407187725],[-54.584364349,-25.408041757],[-54.584397877,-25.408225908],[-54.58083188,-25.40887408],[-54.580662817,-25.408794033],[-54.578582764,-25.409343216],[-54.577176783,-25.409921902],[-54.574835215,-25.411356968],[-54.573420266,-25.412660605],[-54.56995393,-25.41756251],[-54.565822741,-25.423818762],[-54.564863516,-25.426166114],[-54.561372454,-25.436599237],[-54.55977612,-25.441166117],[-54.559956834,-25.441255635],[-54.560411552,-25.441039718],[-54.561530369,-25.438307217],[-54.56325352,-25.43362584],[-54.564224731,-25.430147434],[-54.564559924,-25.42936314],[-54.564880364,-25.429084861],[-54.565817042,-25.428049779],[-54.566482565,-25.427226006],[-54.567271218,-25.426135353],[-54.568109324,-25.425411827],[-54.569107525,-25.423653136],[-54.568448121,-25.422704724],[-54.568378384,-25.422578744],[-54.568555074,-25.42190861],[-54.568801502,-25.421307544],[-54.569317157,-25.42054873],[-54.570266659,-25.419124226],[-54.57099622,-25.417694944],[-54.571731145,-25.416653241],[-54.572525079,-25.415601815],[-54.573302919,-25.414637645],[-54.573759146,-25.414053929],[-54.574652741,-25.413023961],[-54.574928338,-25.412760769],[-54.575345505,-25.412317702],[-54.57570995,-25.4Line truncated
|
||||
@@ -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 |
+1
-1
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
|
||||
/** Compatibility facade for callers that construct a manager with its panel. */
|
||||
export class DataLayerManager extends LayerLifecycle {
|
||||
constructor(viewer, options) {
|
||||
super(viewer, options);
|
||||
super(viewer, { visibilityTarget: globalThis.document, ...options });
|
||||
this._presentation = new LayerPresentation(this);
|
||||
}
|
||||
buildTogglePanel(container) {
|
||||
|
||||
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
|
||||
lifecycle.refreshLayerStats();
|
||||
assert.deepEqual(changes, ['status']);
|
||||
});
|
||||
|
||||
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||
const { module, calls } = makeSlowLayer('hidden-poll');
|
||||
manager.register(module);
|
||||
await manager.setEnabled('hidden-poll', true);
|
||||
assert.equal(calls.update, 1);
|
||||
doc.hidden = true;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(calls.update, 1);
|
||||
assert.equal(await manager.refreshLayer('hidden-poll'), true);
|
||||
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
|
||||
doc.hidden = false;
|
||||
for (const fn of listeners) fn();
|
||||
for (const fn of listeners) fn();
|
||||
assert.equal(calls.update, 3);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await manager.destroyAll();
|
||||
assert.equal(listeners.size, 0);
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(calls.update, 3);
|
||||
});
|
||||
|
||||
async function resumeBetweenTicks(t, { failResume = false } = {}) {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: false,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
|
||||
const { module, calls } = makeSlowLayer('resume-poll');
|
||||
const updates = [];
|
||||
module.update = async () => {
|
||||
calls.update++;
|
||||
updates.push(Date.now());
|
||||
await Promise.resolve();
|
||||
if (failResume && updates.length === 2) throw new Error('resume failed');
|
||||
};
|
||||
manager.register(module);
|
||||
await manager.setEnabled('resume-poll', true);
|
||||
assert.deepEqual(updates, [1000]);
|
||||
doc.hidden = true;
|
||||
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
|
||||
t.mock.timers.tick(500); // 2500: return between ticks
|
||||
doc.hidden = false;
|
||||
for (const fn of listeners) fn();
|
||||
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
|
||||
if (failResume) assert.equal(managerRefreshError, 'resume failed');
|
||||
else assert.equal(managerRefreshError, null);
|
||||
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
|
||||
assert.deepEqual(updates, [1000, 2500]);
|
||||
t.mock.timers.tick(1); // 3500: one interval after the resume poll
|
||||
assert.deepEqual(updates, [1000, 2500, 3500]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await manager.destroyAll();
|
||||
assert.equal(listeners.size, 0);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(updates.length, 4);
|
||||
}
|
||||
|
||||
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
|
||||
await resumeBetweenTicks(t);
|
||||
});
|
||||
|
||||
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
|
||||
await resumeBetweenTicks(t, { failResume: true });
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import militaryAwarenessLayer, {
|
||||
_getAwarenessNavigationStateForTest,
|
||||
@@ -43,7 +42,10 @@ import {
|
||||
formatAwarenessLabel,
|
||||
getAwarenessNavigationTargets,
|
||||
} from './militaryAwarenessEngine.js';
|
||||
import { formatAwarenessCount } from '../layers/awareness/panel.js';
|
||||
import {
|
||||
formatAwarenessCount,
|
||||
presentAwarenessDistance,
|
||||
} from '../layers/awareness/panel.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
@@ -70,6 +72,12 @@ test('Contacts labels a capped cohort as a lower bound', () => {
|
||||
assert.equal(formatAwarenessCount({ count: null }), '?');
|
||||
});
|
||||
|
||||
test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
|
||||
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
|
||||
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
|
||||
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
|
||||
});
|
||||
|
||||
function installAwarenessRuntime({
|
||||
isEnabled = () => true,
|
||||
isEffectivelyEnabled = isEnabled,
|
||||
|
||||
@@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
// by source probes against the shipped file, the same technique the HUD datum
|
||||
// tests use where a full boot is impractical.
|
||||
import { installationRetryDelayMs } from './militaryInstallations.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const installationsSource = readLayerSource(
|
||||
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js';
|
||||
export const MILITARY_POINT_CAP = 512;
|
||||
export const MILITARY_LABEL_CAP = 24;
|
||||
|
||||
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
const MILITARY_NAMES_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
|
||||
export function createMilitaryNamesLoader({
|
||||
|
||||
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
return index;
|
||||
});
|
||||
|
||||
function toResult(entry) {
|
||||
function toResult(entry, candidates = 1) {
|
||||
return {
|
||||
name: entry.name,
|
||||
featurecla: entry.featurecla,
|
||||
@@ -197,6 +197,7 @@ function toResult(entry) {
|
||||
polygons: entry.polygons,
|
||||
bboxDiagonalKm: entry.bboxDiagonalKm,
|
||||
areaKm2: entry.areaKm2,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,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);
|
||||
@@ -223,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;
|
||||
}
|
||||
@@ -270,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.
|
||||
*
|
||||
@@ -291,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;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -41,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,
|
||||
@@ -122,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);
|
||||
|
||||
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const memoryHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, fresh]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const inFlightHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk');
|
||||
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
||||
|
||||
const diskMemory = new Map();
|
||||
const diskHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map(),
|
||||
memoryCache: diskMemory,
|
||||
inFlight: new Map(),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskHit.payload, disk);
|
||||
assert.equal(diskReads, 1);
|
||||
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
|
||||
const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream });
|
||||
assert.equal(publishedDisk.source, 'HIT');
|
||||
|
||||
const upstreamMiss = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
|
||||
const t = c1 / c2;
|
||||
return Math.hypot(wx - t * vx, wy - t * vy);
|
||||
}
|
||||
|
||||
|
||||
test('cold disk lookup is single-flight through upstream publication and quota admission', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, admissions = 0, upstreams = 0;
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const options = {
|
||||
cacheKey: 'cold', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return disk; },
|
||||
allowUpstream: () => { admissions++; return true; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
};
|
||||
const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options));
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const results = await Promise.all(pending);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(admissions, 1);
|
||||
assert.equal(upstreams, 1);
|
||||
assert.ok(results.every((result) => result.payload === payload));
|
||||
assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']);
|
||||
assert.equal(inFlight.size, 0);
|
||||
const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } };
|
||||
const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]);
|
||||
assert.ok(rejected.every((result) => result.status === 'rejected'));
|
||||
assert.equal(inFlight.size, 0);
|
||||
await resolveOverpassPreflight({ ...options, readDisk: async () => null });
|
||||
assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry');
|
||||
});
|
||||
|
||||
test('a joined client retries admission with its own quota after another client is rate limited', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, upstreams = 0;
|
||||
const admissions = { a: 0, b: 0, c: 0 };
|
||||
const quota = { a: false, b: true, c: false };
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const client = (name) => resolveOverpassPreflight({
|
||||
cacheKey: 'quota', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return reads === 1 ? disk : null; },
|
||||
allowUpstream: () => { admissions[name]++; return quota[name]; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
});
|
||||
// A (quota exhausted) owns the cold reservation; B (quota available) and
|
||||
// C (quota exhausted) join it while the disk lookup is pending.
|
||||
const pending = [client('a'), client('b'), client('c')];
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const [a, b, c] = await Promise.all(pending);
|
||||
assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A");
|
||||
assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota');
|
||||
assert.equal(b.payload, payload);
|
||||
assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch");
|
||||
assert.equal(c.payload, payload);
|
||||
assert.deepEqual(admissions, { a: 1, b: 1, c: 0 });
|
||||
assert.equal(upstreams, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
* that belong to someone else and leave them alone.
|
||||
*/
|
||||
|
||||
import { isSurfacePick } from './scenePick.js';
|
||||
|
||||
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
|
||||
const _owners = new Map();
|
||||
|
||||
@@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a moving-contact click without letting map tiles hide a nearby touch
|
||||
* target. Exact picks always win; only a coarse primary pointer may expand a
|
||||
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
|
||||
* do not each choose their own contact under one tap.
|
||||
* Unowned non-surface objects remain barriers, not permission to pick through.
|
||||
*
|
||||
* @param {object} scene Cesium scene with pick and optional drillPick.
|
||||
* @param {{x: number, y: number}} position Screen position in CSS pixels.
|
||||
* @returns {object|null|undefined} Original pick or the first registered touch hit.
|
||||
*/
|
||||
export function pickContact(scene, position) {
|
||||
const picked = scene.pick(position);
|
||||
if (
|
||||
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
|
||||
findPickOwner(resolvePickId(picked)) ||
|
||||
!isSurfacePick(picked) ||
|
||||
typeof scene.drillPick !== 'function'
|
||||
)
|
||||
return picked;
|
||||
let hits;
|
||||
try {
|
||||
hits = scene.drillPick(position, 8, 14, 14) || [];
|
||||
} catch {
|
||||
return picked;
|
||||
}
|
||||
for (const hit of hits.slice(0, 8)) {
|
||||
if (findPickOwner(resolvePickId(hit))) return hit;
|
||||
if (!isSurfacePick(hit)) break;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
unregisterPickOwner,
|
||||
isOwnedByOtherLayer,
|
||||
resolvePickId,
|
||||
pickContact,
|
||||
} from './pickRegistry.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => {
|
||||
unregisterPickOwner('bikeshare');
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
|
||||
});
|
||||
|
||||
function primaryPointer(t, coarse) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }),
|
||||
});
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
|
||||
else delete globalThis.matchMedia;
|
||||
});
|
||||
}
|
||||
|
||||
function contactOwners(t) {
|
||||
registerPickOwner('flights', (id) => id === 'civil');
|
||||
registerPickOwner('military', (id) => id === 'military');
|
||||
registerPickOwner('ais-live-vessels', (id) => id === '123456789');
|
||||
t.after(() => {
|
||||
for (const id of ['flights', 'military', 'ais-live-vessels'])
|
||||
unregisterPickOwner(id);
|
||||
});
|
||||
}
|
||||
|
||||
const mapTile = { primitive: { isCesium3DTileset: true } };
|
||||
|
||||
test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
const position = { x: 30, y: 50 };
|
||||
const contact = { primitive: { id: 'civil' } };
|
||||
let drills = 0;
|
||||
const scene = {
|
||||
pick(point) {
|
||||
assert.equal(point, position);
|
||||
return mapTile;
|
||||
},
|
||||
drillPick(...args) {
|
||||
drills++;
|
||||
assert.deepEqual(args, [position, 8, 14, 14]);
|
||||
return [mapTile, contact];
|
||||
},
|
||||
};
|
||||
assert.equal(pickContact(scene, position), contact);
|
||||
assert.equal(drills, 1);
|
||||
});
|
||||
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), first);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const exact of [
|
||||
{ id: 'military' },
|
||||
{ id: 'custom-annotation' },
|
||||
{ primitive: { id: 'custom-gizmo' } },
|
||||
{ primitive: { id: 'civil', isCesium3DTileset: true } },
|
||||
]) {
|
||||
const scene = {
|
||||
pick: () => exact,
|
||||
drillPick: () => assert.fail('must keep exact interactive hit'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), exact);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch expansion stops at a non-surface object and respects the drill bound', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const hits of [
|
||||
[mapTile, { id: 'custom-annotation' }, { id: 'civil' }],
|
||||
[...Array(8).fill(mapTile), { id: 'civil' }],
|
||||
[mapTile],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact(
|
||||
{ pick: () => mapTile, drillPick: () => hits },
|
||||
{ x: 1, y: 1 },
|
||||
),
|
||||
mapTile,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => {
|
||||
primaryPointer(t, false);
|
||||
contactOwners(t);
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => assert.fail('mouse must not expand'),
|
||||
};
|
||||
assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile);
|
||||
});
|
||||
|
||||
test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const drillPick of [
|
||||
undefined,
|
||||
() => {
|
||||
throw new Error('context lost');
|
||||
},
|
||||
() => [],
|
||||
]) {
|
||||
assert.equal(
|
||||
pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
const latitudeScale = 111320;
|
||||
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
|
||||
// The short way round, so places either side of 180° stay close.
|
||||
let dLon = lonB - lonA;
|
||||
dLon -= 360 * Math.round(dLon / 360);
|
||||
return Math.round(
|
||||
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
|
||||
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand;
|
||||
export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay;
|
||||
export const getRadioAcceptedCatalogSnapshot =
|
||||
layer.getRadioAcceptedCatalogSnapshot;
|
||||
export const radioStationResolutionMatches =
|
||||
layer.radioStationResolutionMatches;
|
||||
export const getRadioUIState = layer.getRadioUIState;
|
||||
export const subscribeToRadio = layer.subscribeToRadio;
|
||||
export const subscribeToRadioPlaybackControls =
|
||||
layer.subscribeToRadioPlaybackControls;
|
||||
export const getRadioTunerStations = layer.getRadioTunerStations;
|
||||
export const beginRadioTuning = layer.beginRadioTuning;
|
||||
export const setRadioTuningStatic = layer.setRadioTuningStatic;
|
||||
export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed;
|
||||
export const previewRadioTuningStation = layer.previewRadioTuningStation;
|
||||
export const endRadioTuning = layer.endRadioTuning;
|
||||
export const cancelRadioTuning = layer.cancelRadioTuning;
|
||||
export const commitRadioTuningStation = layer.commitRadioTuningStation;
|
||||
export const playSelectedRadio = layer.playSelectedRadio;
|
||||
export const confirmRadioPlayback = layer.confirmRadioPlayback;
|
||||
export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice;
|
||||
export const stopRadioPlayback = layer.stopRadioPlayback;
|
||||
export const toggleRadioPlayback = layer.toggleRadioPlayback;
|
||||
export const pauseRadioPlayback = layer.pauseRadioPlayback;
|
||||
export const setRadioVolume = layer.setRadioVolume;
|
||||
export const setRadioParams = layer.setRadioParams;
|
||||
export const getRadioParams = layer.getRadioParams;
|
||||
export const setRadioVoiceDucking = layer.setRadioVoiceDucking;
|
||||
export const selectRadioStation = layer.selectRadioStation;
|
||||
export const cycleRadioStation = layer.cycleRadioStation;
|
||||
export const selectRequestedRadioStation = layer.selectRequestedRadioStation;
|
||||
export const setRadioFilter = layer.setRadioFilter;
|
||||
export const retainRadioClusterIdentitiesForStations =
|
||||
layer.retainRadioClusterIdentitiesForStations;
|
||||
export {
|
||||
@@ -86,10 +64,8 @@ export {
|
||||
RADIO_SINGLETON_MID_LIMIT,
|
||||
RADIO_SINGLETON_NEAR_LIMIT,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
} from '../layers/radio/index.js';
|
||||
export default layer;
|
||||
|
||||
|
||||
+53
-3
@@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().stationCount, 1);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 1);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioUIState().stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
|
||||
// Server-side structural rejections are combined with client rejections
|
||||
// after admission; the healthy generation is still accepted.
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 2,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 3);
|
||||
assert.equal(getRadioUIState().degraded, true);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount: 10_000,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [replacement], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
rejectedStationCount,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
}
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
stations: [{ id: 'malformed' }], stale: false, degraded: false,
|
||||
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
|
||||
}));
|
||||
await radioLayer.update();
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
|
||||
assert.equal(getRadioUIState().stale, true);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
|
||||
radioLayer.destroy();
|
||||
radioLayer.init(viewer);
|
||||
assert.equal(getRadioUIState().rejectedStationCount, 0);
|
||||
assert.equal(getRadioUIState().degraded, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
@@ -1779,7 +1831,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a
|
||||
});
|
||||
|
||||
test('voice playback confirmation accepts fallback buffering but times out safely', async () => {
|
||||
let listener = () => {};
|
||||
const state = {
|
||||
audioState: 'loading',
|
||||
playingStationId: 'fallback',
|
||||
@@ -1789,9 +1840,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel
|
||||
const confirmed = await confirmRadioPlayback({
|
||||
startPlayback: async () => false,
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
next(state);
|
||||
return () => { listener = () => {}; };
|
||||
return () => {};
|
||||
},
|
||||
getState: () => state,
|
||||
timeoutMs: 10,
|
||||
|
||||
@@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) {
|
||||
name: canonicalCountryName(code),
|
||||
});
|
||||
}
|
||||
|
||||
/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */
|
||||
export function isRadioCountryCode(value) {
|
||||
return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase());
|
||||
}
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from '../../vite.config.js';
|
||||
import { rankRadioStationsForRequest } from './radio.js';
|
||||
import {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
radioLayer,
|
||||
rankRadioStationsForRequest,
|
||||
} from './radio.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
|
||||
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
|
||||
|
||||
const UUID = '12345678-1234-4234-8234-123456789abc';
|
||||
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
@@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
|
||||
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
|
||||
});
|
||||
|
||||
|
||||
test('case expansion is capped after tag normalization in the portable and server station path', () => {
|
||||
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
|
||||
assert.equal(row.tags[0].length, 80);
|
||||
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
|
||||
assert.deepEqual(row.tags.slice(1), ['news radio']);
|
||||
});
|
||||
|
||||
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const middleware = createProxy({
|
||||
now: () => Date.now(),
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
|
||||
const rows = healthyRowsForQuery(url);
|
||||
if (queryTag(url)) return responseJson(rows);
|
||||
// 400 valid broad rows, one row missing its required name, and one
|
||||
// schema-valid row that normalization filters by policy (not counted).
|
||||
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
|
||||
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
|
||||
},
|
||||
});
|
||||
const response = await invoke(middleware, '/stations');
|
||||
assert.equal(response.status, 200);
|
||||
const body = JSON.parse(response.body);
|
||||
assert.equal(body.degraded, false);
|
||||
assert.equal(body.degradedReason, null);
|
||||
assert.equal(body.acceptedGeneration, 1);
|
||||
assert.equal(body.rejectedStationCount, 1);
|
||||
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
|
||||
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
const state = getRadioUIState();
|
||||
assert.equal(state.stationCount, body.stations.length);
|
||||
assert.equal(state.rejectedStationCount, 1);
|
||||
assert.equal(state.degraded, true);
|
||||
assert.equal(state.stale, false);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
|
||||
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,18 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
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;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
@@ -66,6 +78,18 @@ export function routePlausible({
|
||||
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(
|
||||
@@ -76,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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+25
-8
@@ -54,6 +54,21 @@ function direction(modifier) {
|
||||
return DIRECTION_WORDS[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* "on the left" only fits a bare side. "slight left" is the adverb
|
||||
* "slightly left", and "on the slightly left" is not an instruction.
|
||||
* @param {string} prefix "Take the ramp" or "Take the exit".
|
||||
* @param {string} modifier OSRM maneuver modifier.
|
||||
* @param {string} onto " onto …", or "".
|
||||
*/
|
||||
function directedWay(prefix, modifier, onto) {
|
||||
const dir = direction(modifier);
|
||||
if (dir === 'left' || dir === 'right')
|
||||
return `${prefix} on the ${dir}${onto}`;
|
||||
if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`;
|
||||
return `${prefix}${onto}`;
|
||||
}
|
||||
|
||||
function ordinal(n) {
|
||||
if (!Number.isInteger(n) || n < 1) return null;
|
||||
const suffix =
|
||||
@@ -103,13 +118,9 @@ export function instructionFor(step) {
|
||||
case 'merge':
|
||||
return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
|
||||
case 'on ramp':
|
||||
return dir
|
||||
? `Take the ramp on the ${dir}${onto}`
|
||||
: `Take the ramp${onto}`;
|
||||
return directedWay('Take the ramp', modifier, onto);
|
||||
case 'off ramp':
|
||||
return dir
|
||||
? `Take the exit on the ${dir}${onto}`
|
||||
: `Take the exit${onto}`;
|
||||
return directedWay('Take the exit', modifier, onto);
|
||||
case 'roundabout':
|
||||
case 'rotary': {
|
||||
const exit = ordinal(step?.exit);
|
||||
@@ -124,8 +135,14 @@ export function instructionFor(step) {
|
||||
case 'exit roundabout':
|
||||
case 'exit rotary':
|
||||
return `Exit the roundabout${onto}`;
|
||||
case 'use lane':
|
||||
return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`;
|
||||
case 'use lane': {
|
||||
const lane = modifier.includes('left')
|
||||
? 'left'
|
||||
: modifier.includes('right')
|
||||
? 'right'
|
||||
: '';
|
||||
return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`;
|
||||
}
|
||||
case 'notification':
|
||||
default:
|
||||
return `Continue${onto}`;
|
||||
|
||||
@@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () =>
|
||||
instructionFor({ type: 'on ramp', modifier: 'left' }),
|
||||
'Take the ramp on the left',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'on ramp', modifier: 'slight left', name: 'I-95' }),
|
||||
'Take the ramp slightly left onto I-95',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'off ramp', modifier: 'sharp right', ref: 'I-280' }),
|
||||
'Take the exit sharply right onto I-280',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'on ramp', modifier: 'straight', name: 'US 101' }),
|
||||
'Take the ramp onto US 101',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'use lane', modifier: 'slight left' }),
|
||||
'Use the left lane',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({ type: 'use lane', modifier: 'right', name: 'Main St' }),
|
||||
'Use the right lane onto Main St',
|
||||
);
|
||||
assert.equal(
|
||||
instructionFor({
|
||||
type: 'merge',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,4 +28,3 @@ export {
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
} from '../layers/submarineCables/index.js';
|
||||
export default createTeleGeographySubmarineCableLayer();
|
||||
@@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({
|
||||
for (let i = 0; i < missing.length; i += 1) {
|
||||
const result = fetched[i];
|
||||
if (validTerrainResult(result)) {
|
||||
// Re-insert rather than overwrite: a Map keeps an existing key in
|
||||
// its old position, and the proxy evicts from the front.
|
||||
cache.delete(missing[i].key);
|
||||
cache.set(missing[i].key, { at: fetchedAt, result });
|
||||
cacheChanged = true;
|
||||
} else if (absentTerrainDatum(result)) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
|
||||
*
|
||||
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
|
||||
* daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles
|
||||
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
|
||||
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
|
||||
* both sides can unit-test against it with node:test.
|
||||
*
|
||||
|
||||
Loaded 100 of 807 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user