mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/houston-cctv-pack
This commit is contained in:
402 files changed
+31421
-3031
No files matched your search
@@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||||
const sprite = (show) => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show,
|
||||
color: makeColor(1),
|
||||
});
|
||||
const visible = sprite(true);
|
||||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||||
const records = [visible, ...hidden].map((billboard) => ({
|
||||
position: billboard.position,
|
||||
billboard,
|
||||
}));
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
let projected = 0;
|
||||
let measured = 0;
|
||||
const apply = (nowMs) =>
|
||||
applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
nowMs,
|
||||
screenPositionFor: () => {
|
||||
projected += 1;
|
||||
return { x: 50, y: 50 };
|
||||
},
|
||||
cameraDistanceFor: () => {
|
||||
measured += 1;
|
||||
return 1200;
|
||||
},
|
||||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||||
});
|
||||
|
||||
apply(0);
|
||||
const result = apply(300);
|
||||
|
||||
// Only the on-screen sprite pays for projection and distance.
|
||||
assert.equal(projected, 2);
|
||||
assert.equal(measured, 2);
|
||||
assert.equal(visible.color.alpha, 0.25);
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_bumpSceneSeekGeneration:
|
||||
SceneDirector.prototype._bumpSceneSeekGeneration,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
|
||||
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
||||
export const processGeometryBatch = layer.processGeometryBatch;
|
||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||
export const clearProbeClampOnDeactivation =
|
||||
layer.clearProbeClampOnDeactivation;
|
||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
||||
|
||||
@@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||||
// Nothing active clears the pick; only the outside-view note may show.
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
a22WebcamToSource,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
A22_IMAGE_ORIGIN,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { staticFrameRefreshMs } from './cctvLod.js';
|
||||
|
||||
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
|
||||
const item = (
|
||||
patch = {},
|
||||
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
|
||||
) => ({
|
||||
Id: `A22_${km}`,
|
||||
Active: true,
|
||||
Source: 'a22',
|
||||
Shortname: 'ADS Sciliar',
|
||||
Webcamname: { it: 'ADS Sciliar' },
|
||||
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
|
||||
Mapping: { a22: { km } },
|
||||
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
|
||||
LicenseInfo: {
|
||||
ClosedData: false,
|
||||
LicenseHolder: 'https://www.autobrennero.it/',
|
||||
},
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('an A22 webcam maps to a source on the pinned operator frame', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(source.id, 'it-a22-km68');
|
||||
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
|
||||
assert.equal(source.city, 'A22 Brennero');
|
||||
assert.equal(source.cityId, 'a22');
|
||||
assert.equal(source.provider, 'Autostrada del Brennero');
|
||||
assert.equal(source.lat, 46.53591);
|
||||
assert.equal(source.lon, 11.49772);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'a22-opendatahub');
|
||||
assert.equal(source.code, 'ADS SCILIAR');
|
||||
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
|
||||
const brenner = a22WebcamToSource(
|
||||
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
|
||||
);
|
||||
const ponte = a22WebcamToSource(
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
);
|
||||
assert.ok(brenner.groundElevationM > 1300);
|
||||
assert.ok(ponte.groundElevationM < 50);
|
||||
// A kilometre the table does not know falls back to the corridor prior.
|
||||
const unknown = a22WebcamToSource(
|
||||
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
|
||||
);
|
||||
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
|
||||
});
|
||||
|
||||
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(a22WebcamToSource(item({ Active: false })), null);
|
||||
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame must be exactly the camera's own kilometre image.
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
|
||||
null,
|
||||
);
|
||||
// Milan: plausible, but off the Brenner–Modena corridor.
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
|
||||
assert.equal(a22WebcamToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
TotalResults: 3,
|
||||
Items: [
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
item(),
|
||||
item(),
|
||||
item(
|
||||
{ Shortname: 'Brennero' },
|
||||
{ km: '1', lat: 46.99682, lon: 11.50218 },
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadA22SourcesFromOpenDataHub();
|
||||
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.test/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_A22_CCTV_URL) {
|
||||
return Response.json({ Items: [item()] });
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_A22_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the A22 lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
|
||||
['it-a22-km68'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
|
||||
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
|
||||
const redirectTo = (location) => async (url) =>
|
||||
url === frame
|
||||
? new Response(null, { status: 302, headers: { location } })
|
||||
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/WebCamImg/km77.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
|
||||
});
|
||||
@@ -44,6 +44,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||||
// tracks the minute); 3 min matches the other highway packs.
|
||||
'autostrada del brennero': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -349,6 +349,13 @@ export const DATA_CREDITS = [
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'a22-cctv',
|
||||
html:
|
||||
'Motorway webcams (A22 Brennero–Modena): images © ' +
|
||||
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
|
||||
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -875,6 +875,13 @@ 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м');
|
||||
|
||||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||||
setLocale('ja');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39秒');
|
||||
assert.equal(routeDurationText(3900), '1時間5分');
|
||||
assert.equal(routeDurationText(7200), '2時間');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'ja',
|
||||
'ランプを斜め左に進んでI-95に入る',
|
||||
'出口を急角度で右に出る',
|
||||
'左車線を通行',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
@@ -919,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const title = (id) =>
|
||||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
title('mode-foot'),
|
||||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||||
);
|
||||
assert.equal(
|
||||
title('mode-bike'),
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import dns from 'node:dns/promises';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||
import {
|
||||
describeFetchError,
|
||||
fetchFirms,
|
||||
fetchIpv4,
|
||||
isAddressSelectionFailure,
|
||||
} from '../../server/providers/firms.js';
|
||||
|
||||
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
||||
const start = config.indexOf(' async function refreshUpstream(key) {');
|
||||
@@ -17,8 +28,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
// Inject only its upstream, clock and filter dependencies; keep its aggregation
|
||||
// and source-status code intact, including failures while consuming records.
|
||||
function createRefresh(fetchSource, filter = filterTrailing24h) {
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
|
||||
}
|
||||
|
||||
test('FIRMS retains large sources in order and filters expired rows', async () => {
|
||||
@@ -78,3 +89,216 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
|
||||
assert.deepEqual(result.fires, []);
|
||||
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
|
||||
});
|
||||
|
||||
// #68: Node's network-family auto-selection gives each address a short attempt
|
||||
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
|
||||
// reports only `fetch failed`. This is the shape Node 24 throws for it.
|
||||
function addressSelectionFailure() {
|
||||
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
|
||||
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
|
||||
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
|
||||
function wrappedAddressSelectionFailure() {
|
||||
const inner = addressSelectionFailure().cause;
|
||||
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
assert.equal(described,
|
||||
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
|
||||
assert.ok(!described.includes('SECRET'));
|
||||
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
|
||||
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
|
||||
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
|
||||
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
|
||||
});
|
||||
|
||||
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
|
||||
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
|
||||
const retried = [];
|
||||
await fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw shape(); },
|
||||
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
|
||||
warn() {},
|
||||
});
|
||||
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
|
||||
}
|
||||
const signal = new AbortController().signal;
|
||||
const ok = new Response('csv');
|
||||
const retried = [];
|
||||
const warnings = [];
|
||||
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
assert.equal(response, ok);
|
||||
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
|
||||
assert.ok(!warnings[0].includes('SECRET'));
|
||||
|
||||
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async () => { throw failure; },
|
||||
warn() {},
|
||||
}), (err) => err === failure, 'a failed retry reports its own error');
|
||||
});
|
||||
|
||||
test('FIRMS does not retry anything but an address-selection failure', async () => {
|
||||
const deps = (fetchImpl) => ({
|
||||
fetchImpl,
|
||||
fetchIpv4Impl: async () => assert.fail('must not retry'),
|
||||
warn: () => assert.fail('must not warn'),
|
||||
});
|
||||
const serverError = new Response('down', { status: 503 });
|
||||
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
|
||||
for (const thrown of [
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
|
||||
new TypeError('fetch failed', { cause: new AggregateError([]) }),
|
||||
new TypeError('fetch failed'),
|
||||
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
|
||||
]) {
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const aborted = addressSelectionFailure();
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
|
||||
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
|
||||
});
|
||||
|
||||
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
|
||||
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
|
||||
// same port and record which one the retry reaches; the TLS handshake then
|
||||
// fails against a plain socket, which is fine — only the connection matters.
|
||||
const reached = [];
|
||||
const listen = (host, port) => new Promise((resolve, reject) => {
|
||||
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
|
||||
server.once('error', reject).listen(port, host, () => resolve(server));
|
||||
});
|
||||
const v4 = await listen('127.0.0.1', 0);
|
||||
t.after(() => v4.close());
|
||||
const { port } = v4.address();
|
||||
let v6;
|
||||
try {
|
||||
v6 = await listen('::1', port);
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
t.after(() => v6.close());
|
||||
const order = await dns.lookup('localhost', { all: true });
|
||||
if (order[0]?.family !== 6) {
|
||||
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
||||
assert.deepEqual(reached, ['127.0.0.1']);
|
||||
});
|
||||
|
||||
// Answer the IPv4 retry with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of crashing the test runner.
|
||||
function mockIpv4Status(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'text/csv' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
|
||||
});
|
||||
|
||||
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||
// a Node or undici change that stops matching fails here instead of silently
|
||||
// disabling the retry. A closed port on `localhost` refuses both addresses.
|
||||
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||
const probe = net.createServer();
|
||||
try {
|
||||
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host');
|
||||
return;
|
||||
}
|
||||
const { port } = probe.address();
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
const v4 = net.createServer();
|
||||
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||
await new Promise((resolve) => v4.close(resolve));
|
||||
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||
assert.ok(err, 'a closed port refuses the connection');
|
||||
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||
});
|
||||
|
||||
// #68 only stays fixed while both upstream calls go through fetchFirms; the
|
||||
// refresh harness injects fetchSource, so guard the call sites directly.
|
||||
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
|
||||
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
|
||||
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
|
||||
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
|
||||
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
|
||||
const calls = [];
|
||||
const response = new Response('latitude,longitude\n');
|
||||
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
|
||||
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
|
||||
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
|
||||
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
|
||||
() => [],
|
||||
1024,
|
||||
AbortSignal,
|
||||
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
|
||||
);
|
||||
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0][0], 'fetchFirms');
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
|
||||
return _focusEvidenceNowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a sprite holds no dimming state, or has settled at full emphasis
|
||||
* with nothing pending. Advancing such a sprite without a screen position (a
|
||||
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
|
||||
* skip it.
|
||||
* @param {object} sprite
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function spriteFocusAtRest(sprite) {
|
||||
const state = _spriteStates.get(sprite);
|
||||
return (
|
||||
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
|
||||
);
|
||||
}
|
||||
|
||||
/** Forget state when a primitive is removed from its owning collection. */
|
||||
export function forgetSpriteFocus(sprite) {
|
||||
if (sprite) _spriteStates.delete(sprite);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
|
||||
/**
|
||||
* Summarize sorted nearby results without promoting missing/stale feeds to a
|
||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
||||
* claim in this MVP.
|
||||
* claim in this MVP. `reason` is English data; the awareness panel shows it
|
||||
* in the UI language.
|
||||
*/
|
||||
export function summarizeAwarenessCohort(
|
||||
items,
|
||||
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('feed unavailable'),
|
||||
reason: msg('feed unavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('feed stale'),
|
||||
reason: msg('feed stale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? t('observed or mapped nearby context')
|
||||
: t('no observed or mapped objects in current feeds'),
|
||||
? msg('observed or mapped nearby context')
|
||||
: msg('no observed or mapped objects in current feeds'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
AWARENESS_RELATIONSHIP,
|
||||
findByDoublingRadius,
|
||||
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
|
||||
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
|
||||
});
|
||||
|
||||
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const reasons = [
|
||||
summarizeAwarenessCohort([], { available: false }).reason,
|
||||
summarizeAwarenessCohort([], { stale: true }).reason,
|
||||
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
|
||||
summarizeAwarenessCohort([]).reason,
|
||||
];
|
||||
assert.deepEqual(reasons, [
|
||||
'feed unavailable',
|
||||
'feed stale',
|
||||
'observed or mapped nearby context',
|
||||
'no observed or mapped objects in current feeds',
|
||||
]);
|
||||
// The awareness panel shows a reason through translateKnown().
|
||||
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
|
||||
});
|
||||
|
||||
test('navigation prioritizes nearby targets from the selected cohort', () => {
|
||||
const cohorts = [
|
||||
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
|
||||
|
||||
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
|
||||
/**
|
||||
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
|
||||
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
|
||||
* Retry sleeps and retry attempts share a 10s budget after attempt one.
|
||||
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
|
||||
* Retry-After longer than the budget left ends the retries at once: sleeping
|
||||
* out the budget cannot reach the time the upstream asked for, and it would
|
||||
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
|
||||
*
|
||||
* Dependencies are injectable for deterministic offline tests.
|
||||
* @param {Array<[number, number]>} points
|
||||
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
|
||||
const res = await fetchImpl(url, signal ? { signal } : {});
|
||||
if (!res.ok) {
|
||||
const error = new Error(`HTTP ${res.status}`);
|
||||
error.status = res.status;
|
||||
error.retryable = res.status === 429 || res.status >= 500;
|
||||
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
|
||||
throw error;
|
||||
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
|
||||
if (retryAfterMs != null && retryAfterMs > remaining) break;
|
||||
const backoffMs = Math.round(
|
||||
350 * 2 ** attempt * (0.75 + random() * 0.5),
|
||||
);
|
||||
|
||||
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
|
||||
assert.equal(results[0].id, 'recovered');
|
||||
});
|
||||
|
||||
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
|
||||
let clock = 3_000_000;
|
||||
let attempts = 0;
|
||||
const sleeps = [];
|
||||
await assert.rejects(
|
||||
fetchTerrainChunkWithRetry([[1, 1]], {
|
||||
now: () => clock,
|
||||
random: () => 0.5,
|
||||
makeSignal: () => undefined,
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
clock += ms;
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
attempts += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
(error) => error.status === 429 && error.retryAfter === '30',
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
|
||||
});
|
||||
|
||||
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
|
||||
let clock = 2_000_000;
|
||||
let attempts = 0;
|
||||
|
||||
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
|
||||
{
|
||||
getFlowSessionStats: () => ({ tilesFetched: 0 }),
|
||||
resetFlowTileCache: () => {},
|
||||
// The layer reads stale cached tiles through this; a source without it
|
||||
// answers with fresh segments only.
|
||||
fetchFlowDetail: async (...args) => ({
|
||||
segments: await sourceSlot.source.fetchFlowForBounds(...args),
|
||||
stale: false,
|
||||
oldestTileAt: null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
export const configureTrafficSource = sourceSlot.configure;
|
||||
/** The source the traffic layer reads, as configured. */
|
||||
export const trafficSource = sourceSlot.source;
|
||||
const layer = createApplicationTraffic({
|
||||
source: sourceSlot.source,
|
||||
});
|
||||
|
||||
+105
-19
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
|
||||
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
|
||||
|
||||
test('a superseded flow fetch is not an outage', () => {
|
||||
assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null);
|
||||
assert.equal(
|
||||
deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(deriveTrafficFlowError(null), null);
|
||||
assert.equal(deriveTrafficFlowError(undefined), null);
|
||||
});
|
||||
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
|
||||
test('flow failures map onto short, specific reasons', () => {
|
||||
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)');
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 429'),
|
||||
'TomTom daily budget reached',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 502'),
|
||||
'TomTom upstream unreachable',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 504'),
|
||||
'TomTom upstream unreachable',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 418'),
|
||||
'TomTom flow error (HTTP 418)',
|
||||
);
|
||||
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
|
||||
});
|
||||
|
||||
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
|
||||
trafficFeedPresentation({ liveMode: false, fetching: false }),
|
||||
trafficFeedPresentation({ liveMode: false, fetching: true }),
|
||||
trafficFeedPresentation({ statusUnavailable: true }),
|
||||
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
fetching: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
].map((feed) => feed.loadingLabel);
|
||||
for (const label of labels) {
|
||||
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
|
||||
assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`);
|
||||
assert.ok(
|
||||
label.startsWith('SIMULATED'),
|
||||
`fallback label must lead with the mode: ${label}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('simulating because the status probe failed reads differently from keyless by design', () => {
|
||||
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
|
||||
assert.equal(probeDown.mode, 'sim');
|
||||
assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable');
|
||||
assert.equal(
|
||||
probeDown.loadingLabel,
|
||||
'SIMULATED — traffic service unreachable',
|
||||
);
|
||||
});
|
||||
|
||||
test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
assert.deepEqual(idle, {
|
||||
mode: 'live',
|
||||
error: null,
|
||||
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
loadingLabel:
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
});
|
||||
assert.equal(
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
||||
@@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag
|
||||
fetching: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
});
|
||||
assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight');
|
||||
assert.deepEqual(
|
||||
busy,
|
||||
down,
|
||||
'the degraded state reads the same whether or not a load is in flight',
|
||||
);
|
||||
});
|
||||
|
||||
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
assert.equal(
|
||||
mgr._buildMetaText({
|
||||
source: 'OpenStreetMap',
|
||||
stats: stats(trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
})),
|
||||
stats: stats(
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
|
||||
);
|
||||
@@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
|
||||
const settled = { count: 4200, lastUpdate: Date.now() };
|
||||
assert.equal(
|
||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: false }),
|
||||
}),
|
||||
'fallback',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: true }),
|
||||
}),
|
||||
'nominal',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
||||
...trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
}),
|
||||
'degraded',
|
||||
);
|
||||
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
|
||||
const stats = trafficLayer.getStats();
|
||||
assert.equal(stats.mode, 'sim');
|
||||
assert.equal(stats.error, null);
|
||||
assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
|
||||
assert.ok(
|
||||
!LIVE_CLAIM.test(stats.loadingLabel),
|
||||
`boot label implies live data: ${stats.loadingLabel}`,
|
||||
);
|
||||
assert.equal(layerFeedState(stats), 'fallback');
|
||||
});
|
||||
|
||||
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
|
||||
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
||||
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
||||
});
|
||||
|
||||
test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => {
|
||||
const { trafficSource, configureTrafficSource } =
|
||||
await import('./traffic.js');
|
||||
assert.equal(typeof trafficSource.fetchFlowDetail, 'function');
|
||||
const base = {
|
||||
requestRoads: async () => ({}),
|
||||
getStatus: async () => ({}),
|
||||
fetchFlowForBounds: async () => ['segment'],
|
||||
};
|
||||
let release = configureTrafficSource({
|
||||
...base,
|
||||
fetchFlowDetail: async () => ({
|
||||
segments: ['segment'],
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||
segments: ['segment'],
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
// A source without fetchFlowDetail answers with fresh segments.
|
||||
release = configureTrafficSource(base);
|
||||
try {
|
||||
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||
segments: ['segment'],
|
||||
stale: false,
|
||||
oldestTileAt: null,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
* jam keeps a size bump so queues read as fat beads.
|
||||
* - `crt` (retro): saturated primaries that survive 10-level posterization
|
||||
* plus an all-bucket size boost to out-shout the pixel grid.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
|
||||
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
|
||||
* the shipped palette and sizing apply untouched.
|
||||
*
|
||||
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
|
||||
|
||||
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
const BUCKETS = ['free', 'slow', 'jam'];
|
||||
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
|
||||
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* - `normal` (normal/anime/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
|
||||
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
|
||||
Reference in new issue
Block a user