Merge remote-tracking branch 'upstream/main' into fix/houston-cctv-pack

This commit is contained in:
Matt Van Horn committed 2026-10-30 19:00:31 -07:00
commit 78ebb7ae1f
402 files changed
+31421 -3031

No files matched your search

+47
View File
@@ -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);
});
+2
View File
@@ -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,
+2
View File
@@ -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;
+3 -2
View File
@@ -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 });
+226
View File
@@ -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);
});
+3
View File
@@ -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,
});
/**
+251
View File
@@ -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');
});
+7
View File
@@ -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:
+29
View File
@@ -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',
);
});
+226 -2
View File
@@ -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');
});
+15
View File
@@ -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);
+7 -6
View File
@@ -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'),
};
}
+20
View File
@@ -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 }] } },
+6 -1
View File
@@ -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),
);
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
});
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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
-1
View File
@@ -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,