mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into fix/alpha5-norad-id
# Conflicts: # src/layers/satellites/ingestion.js
This commit is contained in:
commit
6ae854c121
543 files changed
+42557
-4887
No files matched your search
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'The Presidio (former Army base)',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina District, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'next to the Marina',
|
||||
},
|
||||
],
|
||||
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina Green, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'next to the Marina',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'The Presidio — a former Army base',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'red',
|
||||
},
|
||||
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'route',
|
||||
color: 'amber',
|
||||
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||
label: 'Crissy Field shoreline',
|
||||
points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Manual whiteboard drawing: the pure half.
|
||||
*
|
||||
@@ -284,20 +286,26 @@ export function closeRing(pairs) {
|
||||
|
||||
/** One line of guidance for the person drawing, by state. */
|
||||
export function drawHint(session) {
|
||||
if (!session) return 'Pick a shape, then click the map.';
|
||||
if (!session) return t('Pick a shape, then click the map.');
|
||||
const n = session.vertices.length;
|
||||
if (session.shape === 'pin')
|
||||
return n
|
||||
? 'Enter to place the pin, Esc to cancel.'
|
||||
: 'Click where the pin goes.';
|
||||
? t('Enter to place the pin, Esc to cancel.')
|
||||
: t('Click where the pin goes.');
|
||||
const need = MIN_VERTICES[session.shape] - n;
|
||||
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
|
||||
if (need > 0)
|
||||
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
|
||||
if (finishReason(session) === 'degenerate') {
|
||||
return session.shape === 'area'
|
||||
? 'Those points are in a line — move one off it to enclose an area.'
|
||||
: 'That line has no length — click somewhere further away.';
|
||||
? t('Those points are in a line — move one off it to enclose an area.')
|
||||
: t('That line has no length — click somewhere further away.');
|
||||
}
|
||||
const full =
|
||||
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
|
||||
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
|
||||
n >= MAX_VERTICES
|
||||
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
|
||||
: '';
|
||||
return t(
|
||||
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
|
||||
{ measure: formatMeasure(session), full },
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
import {
|
||||
DRAW_SHAPES,
|
||||
MAX_VERTICES,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -100,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
target.addEventListener(type, listener, options);
|
||||
domListeners.push([target, type, listener, options]);
|
||||
};
|
||||
// The board lives only in memory: switching language (a reload) asks first
|
||||
// while it holds a lasting mark or a shape is half drawn.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() => {
|
||||
if (destroyed) return null;
|
||||
const lasting = (annotations.list?.() || []).some(
|
||||
(mark) => mark && mark.ttlMs == null && !mark.expiring,
|
||||
);
|
||||
return lasting || session?.vertices?.length > 0
|
||||
? msg('drawings on the map')
|
||||
: null;
|
||||
});
|
||||
|
||||
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
||||
@@ -197,10 +210,13 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint('That point is off the globe — click on the world.');
|
||||
setHint(t('That point is off the globe — click on the world.'));
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!session || session.shape === 'pin' || destroyed) return;
|
||||
@@ -236,11 +252,15 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
flyTo: false,
|
||||
});
|
||||
if (destroyed || attempt !== generation) return result;
|
||||
if (result?.drawn === 0) setHint('That shape could not be placed.');
|
||||
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (destroyed || attempt !== generation) return null;
|
||||
setHint(`Could not place the shape: ${error?.message || error}`);
|
||||
setHint(
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -258,7 +278,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
if (session) session = createDrawSession(shape);
|
||||
annotations.clear();
|
||||
syncPreview();
|
||||
if (!destroyed && !active) setHint('Board cleared.');
|
||||
if (!destroyed && !active) setHint(t('Board cleared.'));
|
||||
};
|
||||
|
||||
// ---- keys: only while drawing, never while typing in another field ----
|
||||
@@ -324,7 +344,11 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// Somebody else is using the pointer — possibly an older instance of
|
||||
// this same tool that has not finished tearing down. Say so instead of
|
||||
// half-starting.
|
||||
setHint(`${pointerOwner()} is using the pointer — close it first.`);
|
||||
setHint(
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||
}),
|
||||
);
|
||||
return active;
|
||||
}
|
||||
}
|
||||
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
generation += 1;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { initDrawTool } from './drawTool.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import {
|
||||
isPointerFree,
|
||||
pointerOwner,
|
||||
@@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
|
||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
||||
|
||||
// First in the file: tools left by later tests would answer the same registry.
|
||||
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
let marks = [];
|
||||
h.annotations.list = () => marks;
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
|
||||
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
|
||||
marks = [{ id: 'b', ttlMs: null, expiring: false }];
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
|
||||
marks = [];
|
||||
h.element('draw-toggle').emit('click');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
|
||||
marks = [{ id: 'c', ttlMs: null, expiring: false }];
|
||||
await h.tool.destroy();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
@@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
||||
h.element('draw-toggle').emit('click');
|
||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||
assert.match(h.element('draw-hint').textContent, /directions/);
|
||||
// Named by its tool, not by the internal owner id.
|
||||
assert.equal(
|
||||
h.element('draw-hint').textContent,
|
||||
'Directions is using the pointer — close it first.',
|
||||
);
|
||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||
|
||||
releasePointer(other);
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
getActiveTrackedReadoutId,
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
return t('{label} · Detailed outline unavailable', { label: label || '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
-2
@@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js';
|
||||
import { StyleManager } from '../ui/composition.js';
|
||||
import { flyToAustin } from '../camera.js';
|
||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the existing controls and camera presentation. */
|
||||
export function createApplicationControls({
|
||||
@@ -41,10 +42,10 @@ export function createApplicationControls({
|
||||
|
||||
// If no share link state, do default fly-to Austin
|
||||
if (!styleManager.hasShareState) {
|
||||
loaderStatus.textContent = 'Flying to Austin, TX...';
|
||||
loaderStatus.textContent = t('Flying to Austin, TX...');
|
||||
defer(flyToAustin(viewer));
|
||||
} else {
|
||||
loaderStatus.textContent = 'Restoring shared view...';
|
||||
loaderStatus.textContent = t('Restoring shared view...');
|
||||
}
|
||||
|
||||
return { styleManager, weatherEffects, cockpitCloudEffects };
|
||||
|
||||
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'status';
|
||||
notice.textContent =
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||
"View panels in this conversation hold them. Use Open in God's " +
|
||||
'Eye View above, or show it in a new conversation.';
|
||||
@@ -568,13 +569,16 @@ export function panelRuntime(config) {
|
||||
queued = view;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view).catch((error) =>
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (!ready)
|
||||
say(
|
||||
// i18n-ignore: see above
|
||||
"God's Eye View did not load here" +
|
||||
(blocked ? ` (${blocked})` : '') +
|
||||
". Use Open in God's Eye View above.",
|
||||
@@ -616,6 +620,7 @@ export function panelRuntime(config) {
|
||||
if (context?.displayMode) displayMode = context.displayMode;
|
||||
if (context?.availableDisplayModes)
|
||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||
};
|
||||
expand.addEventListener('click', () => {
|
||||
@@ -640,6 +645,7 @@ export function panelRuntime(config) {
|
||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||
appInfo: {
|
||||
name: 'gods-eye-view',
|
||||
// i18n-ignore: product name sent to the MCP host
|
||||
title: "God's Eye View",
|
||||
version: '1.0.0',
|
||||
},
|
||||
@@ -655,6 +661,7 @@ export function panelRuntime(config) {
|
||||
},
|
||||
(error) =>
|
||||
say(
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
"This client did not accept the God's Eye View panel" +
|
||||
(error?.message ? `: ${error.message}` : '.'),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as activation from '../../cctvFocusRequest.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import * as locations from '../../locations.js';
|
||||
import { CITY_POIS } from '../../cityPois.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
|
||||
sprites,
|
||||
activation,
|
||||
overlays,
|
||||
locations,
|
||||
locations: { CITY_POIS },
|
||||
picking,
|
||||
terrain,
|
||||
ground,
|
||||
|
||||
+11
-5
@@ -14,6 +14,7 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
export async function createApplicationScene({
|
||||
@@ -42,7 +43,7 @@ export async function createApplicationScene({
|
||||
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
|
||||
});
|
||||
}
|
||||
loaderStatus.textContent = 'Configuring viewer...';
|
||||
loaderStatus.textContent = t('Configuring viewer...');
|
||||
// Provider attribution stays visible, including clean-view and recording.
|
||||
const creditContainer = document.createElement('div');
|
||||
creditContainer.id = 'cesium-credits';
|
||||
@@ -61,8 +62,8 @@ export async function createApplicationScene({
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
? t('Loading Google 3D Tiles...')
|
||||
: t('Loading the keyless globe...');
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
@@ -90,12 +91,17 @@ export async function createApplicationScene({
|
||||
tileError,
|
||||
);
|
||||
const tileErrorDetail = describeError(tileError);
|
||||
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
|
||||
loaderStatus.textContent = t(
|
||||
'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
|
||||
{
|
||||
detail: tileErrorDetail,
|
||||
},
|
||||
);
|
||||
}
|
||||
viewer.scene.globe.show = true;
|
||||
}
|
||||
|
||||
loaderStatus.textContent = 'Initializing systems...';
|
||||
loaderStatus.textContent = t('Initializing systems...');
|
||||
|
||||
const mapStackController = new MapController(viewer, {
|
||||
requestRender: governorRequestRender,
|
||||
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* @module cityPois
|
||||
* @description The preset cities and their landmarks — plain data with no
|
||||
* Cesium, so modules that only need the names (voice cards) can import it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Points of Interest per city.
|
||||
* Each city has 5 POIs; the first is the default fly-to landmark.
|
||||
*
|
||||
* Field reference:
|
||||
* alt — RANGE (distance from target in meters), NOT absolute altitude
|
||||
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
|
||||
* pitch — camera tilt in degrees (negative = looking down)
|
||||
* buildingHeight — estimated height of landmark center above ground (meters)
|
||||
*/
|
||||
export const CITY_POIS = {
|
||||
austin: {
|
||||
name: 'Austin',
|
||||
groundElevation: 150, // meters above WGS84 ellipsoid
|
||||
viewBounds: {
|
||||
southwest: { lat: 30.1, lng: -97.95 },
|
||||
northeast: { lat: 30.52, lng: -97.55 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Texas State Capitol',
|
||||
lat: 30.2747,
|
||||
lon: -97.7403,
|
||||
alt: 550,
|
||||
pitch: -28,
|
||||
heading: 180,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
{
|
||||
name: 'Frost Bank Tower',
|
||||
lat: 30.2674,
|
||||
lon: -97.7434,
|
||||
alt: 550,
|
||||
pitch: -22,
|
||||
heading: 30,
|
||||
buildingHeight: 80,
|
||||
},
|
||||
{
|
||||
name: 'Pennybacker Bridge',
|
||||
lat: 30.3451,
|
||||
lon: -97.7951,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'The Jenga Tower',
|
||||
lat: 30.2642,
|
||||
lon: -97.75,
|
||||
alt: 500,
|
||||
pitch: -18,
|
||||
heading: 45,
|
||||
buildingHeight: 60,
|
||||
},
|
||||
{
|
||||
name: 'UT Tower',
|
||||
lat: 30.2862,
|
||||
lon: -97.7394,
|
||||
alt: 500,
|
||||
pitch: -22,
|
||||
heading: 180,
|
||||
buildingHeight: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
sf: {
|
||||
name: 'San Francisco',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 37.7, lng: -122.53 },
|
||||
northeast: { lat: 37.84, lng: -122.35 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Golden Gate Bridge',
|
||||
lat: 37.8199,
|
||||
lon: -122.4783,
|
||||
alt: 1400,
|
||||
pitch: -20,
|
||||
heading: 45,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Transamerica Pyramid',
|
||||
lat: 37.7952,
|
||||
lon: -122.4028,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 30,
|
||||
buildingHeight: 85,
|
||||
},
|
||||
{
|
||||
name: 'Salesforce Tower',
|
||||
lat: 37.7897,
|
||||
lon: -122.3972,
|
||||
alt: 680,
|
||||
pitch: -25,
|
||||
heading: 330,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Alcatraz Island',
|
||||
lat: 37.8267,
|
||||
lon: -122.423,
|
||||
alt: 800,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Coit Tower',
|
||||
lat: 37.8024,
|
||||
lon: -122.4058,
|
||||
alt: 420,
|
||||
pitch: -30,
|
||||
heading: 45,
|
||||
buildingHeight: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
nyc: {
|
||||
name: 'New York',
|
||||
groundElevation: 10,
|
||||
viewBounds: {
|
||||
southwest: { lat: 40.477, lng: -74.259 },
|
||||
northeast: { lat: 40.918, lng: -73.7 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Statue of Liberty',
|
||||
lat: 40.6892,
|
||||
lon: -74.0445,
|
||||
alt: 450,
|
||||
pitch: -25,
|
||||
heading: 315,
|
||||
buildingHeight: 45,
|
||||
},
|
||||
{
|
||||
name: 'Empire State Building',
|
||||
lat: 40.7484,
|
||||
lon: -73.9857,
|
||||
alt: 850,
|
||||
pitch: -12,
|
||||
heading: 30,
|
||||
buildingHeight: 130,
|
||||
buildingBounds: { height: 443, width: 130, depth: 75 },
|
||||
},
|
||||
{
|
||||
name: 'One World Trade Center',
|
||||
lat: 40.7127,
|
||||
lon: -74.0134,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 0,
|
||||
buildingHeight: 170,
|
||||
},
|
||||
{
|
||||
name: 'Brooklyn Bridge',
|
||||
lat: 40.7061,
|
||||
lon: -73.9969,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 45,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Chrysler Building',
|
||||
lat: 40.7516,
|
||||
lon: -73.9755,
|
||||
alt: 700,
|
||||
pitch: -20,
|
||||
heading: 225,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
tokyo: {
|
||||
name: 'Tokyo',
|
||||
groundElevation: 40,
|
||||
viewBounds: {
|
||||
southwest: { lat: 35.52, lng: 139.55 },
|
||||
northeast: { lat: 35.9, lng: 139.92 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Tokyo Tower',
|
||||
lat: 35.6586,
|
||||
lon: 139.7454,
|
||||
alt: 850,
|
||||
pitch: -25,
|
||||
heading: 0,
|
||||
buildingHeight: 110,
|
||||
},
|
||||
{
|
||||
name: 'Tokyo Skytree',
|
||||
lat: 35.7101,
|
||||
lon: 139.8107,
|
||||
alt: 900,
|
||||
pitch: -25,
|
||||
heading: 30,
|
||||
buildingHeight: 200,
|
||||
},
|
||||
{
|
||||
name: 'Imperial Palace',
|
||||
lat: 35.6852,
|
||||
lon: 139.7528,
|
||||
alt: 900,
|
||||
pitch: -35,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Senso-ji Temple',
|
||||
lat: 35.7148,
|
||||
lon: 139.7967,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Mode Gakuen Cocoon Tower',
|
||||
lat: 35.6929,
|
||||
lon: 139.6925,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
london: {
|
||||
name: 'London',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 51.28, lng: -0.51 },
|
||||
northeast: { lat: 51.7, lng: 0.33 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Tower Bridge',
|
||||
lat: 51.5055,
|
||||
lon: -0.0754,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 65,
|
||||
},
|
||||
{
|
||||
name: 'The Shard',
|
||||
lat: 51.5045,
|
||||
lon: -0.0865,
|
||||
alt: 850,
|
||||
pitch: -20,
|
||||
heading: 0,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Big Ben / Parliament',
|
||||
lat: 51.5007,
|
||||
lon: -0.1246,
|
||||
alt: 600,
|
||||
pitch: -25,
|
||||
heading: 180,
|
||||
buildingHeight: 50,
|
||||
},
|
||||
{
|
||||
name: "St. Paul's Cathedral",
|
||||
lat: 51.5138,
|
||||
lon: -0.0984,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 270,
|
||||
buildingHeight: 55,
|
||||
},
|
||||
{
|
||||
name: 'The Gherkin',
|
||||
lat: 51.5145,
|
||||
lon: -0.0803,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
paris: {
|
||||
name: 'Paris',
|
||||
groundElevation: 35,
|
||||
viewBounds: {
|
||||
southwest: { lat: 48.815, lng: 2.224 },
|
||||
northeast: { lat: 48.902, lng: 2.47 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Eiffel Tower',
|
||||
lat: 48.8584,
|
||||
lon: 2.2945,
|
||||
alt: 750,
|
||||
pitch: -25,
|
||||
heading: 315,
|
||||
buildingHeight: 150,
|
||||
},
|
||||
{
|
||||
name: 'Arc de Triomphe',
|
||||
lat: 48.8738,
|
||||
lon: 2.295,
|
||||
alt: 400,
|
||||
pitch: -28,
|
||||
heading: 45,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Notre-Dame',
|
||||
lat: 48.853,
|
||||
lon: 2.3499,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 225,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
{
|
||||
name: 'Sacré-Cœur',
|
||||
lat: 48.8867,
|
||||
lon: 2.3431,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Louvre Pyramid',
|
||||
lat: 48.8606,
|
||||
lon: 2.3376,
|
||||
alt: 500,
|
||||
pitch: -35,
|
||||
heading: 0,
|
||||
buildingHeight: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
dubai: {
|
||||
name: 'Dubai',
|
||||
groundElevation: 5,
|
||||
viewBounds: {
|
||||
southwest: { lat: 24.95, lng: 54.9 },
|
||||
northeast: { lat: 25.35, lng: 55.55 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Burj Khalifa',
|
||||
lat: 25.1972,
|
||||
lon: 55.2744,
|
||||
alt: 600,
|
||||
pitch: -20,
|
||||
heading: 200,
|
||||
buildingHeight: 270,
|
||||
},
|
||||
{
|
||||
name: 'Burj Al Arab',
|
||||
lat: 25.1412,
|
||||
lon: 55.1853,
|
||||
alt: 500,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Palm Jumeirah',
|
||||
lat: 25.1124,
|
||||
lon: 55.139,
|
||||
alt: 1200,
|
||||
pitch: -40,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Dubai Frame',
|
||||
lat: 25.235,
|
||||
lon: 55.3003,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 75,
|
||||
},
|
||||
{
|
||||
name: 'Museum of the Future',
|
||||
lat: 25.2197,
|
||||
lon: 55.2806,
|
||||
alt: 350,
|
||||
pitch: -20,
|
||||
heading: 30,
|
||||
buildingHeight: 35,
|
||||
},
|
||||
],
|
||||
},
|
||||
dc: {
|
||||
name: 'Washington DC',
|
||||
groundElevation: 10,
|
||||
viewBounds: {
|
||||
southwest: { lat: 38.79, lng: -77.12 },
|
||||
northeast: { lat: 38.995, lng: -76.91 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'US Capitol',
|
||||
lat: 38.8897,
|
||||
lon: -77.0091,
|
||||
alt: 550,
|
||||
pitch: -25,
|
||||
heading: 270,
|
||||
buildingHeight: 45,
|
||||
},
|
||||
{
|
||||
name: 'Washington Monument',
|
||||
lat: 38.8895,
|
||||
lon: -77.0353,
|
||||
alt: 500,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 85,
|
||||
},
|
||||
{
|
||||
name: 'Lincoln Memorial',
|
||||
lat: 38.8893,
|
||||
lon: -77.0502,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 90,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Pentagon',
|
||||
lat: 38.8711,
|
||||
lon: -77.0559,
|
||||
alt: 800,
|
||||
pitch: -40,
|
||||
heading: 0,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Jefferson Memorial',
|
||||
lat: 38.8814,
|
||||
lon: -77.0365,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 0,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
tallinn: {
|
||||
name: 'Tallinn',
|
||||
groundElevation: 15,
|
||||
viewBounds: {
|
||||
southwest: { lat: 59.36, lng: 24.6 },
|
||||
northeast: { lat: 59.52, lng: 24.92 },
|
||||
},
|
||||
pois: [
|
||||
{
|
||||
name: 'Viru Square',
|
||||
lat: 59.4366,
|
||||
lon: 24.7527,
|
||||
alt: 450,
|
||||
pitch: -28,
|
||||
heading: 60,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Old Town / Raekoja plats',
|
||||
lat: 59.4372,
|
||||
lon: 24.7452,
|
||||
alt: 400,
|
||||
pitch: -30,
|
||||
heading: 180,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Teatri väljak',
|
||||
lat: 59.4344,
|
||||
lon: 24.7514,
|
||||
alt: 400,
|
||||
pitch: -25,
|
||||
heading: 220,
|
||||
buildingHeight: 25,
|
||||
},
|
||||
{
|
||||
name: 'Port of Tallinn',
|
||||
lat: 59.4445,
|
||||
lon: 24.7675,
|
||||
alt: 700,
|
||||
pitch: -30,
|
||||
heading: 90,
|
||||
buildingHeight: 20,
|
||||
},
|
||||
{
|
||||
name: 'Ülemiste',
|
||||
lat: 59.421,
|
||||
lon: 24.792,
|
||||
alt: 600,
|
||||
pitch: -28,
|
||||
heading: 45,
|
||||
buildingHeight: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -8,7 +8,8 @@
|
||||
// say nothing about whether a destination is framed well.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CITY_POIS, LOCATIONS } from './locations.js';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { LOCATIONS } from './locations.js';
|
||||
|
||||
const DESTINATIONS = Object.entries(CITY_POIS);
|
||||
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
|
||||
|
||||
+647
-183
File diff suppressed because it is too large.
Load diff
+45
-11
@@ -83,31 +83,65 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
||||
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
||||
}
|
||||
|
||||
/** The English emergency line; see formatCockpitEmergency. */
|
||||
const ENGLISH_EMERGENCY = Object.freeze({
|
||||
squawk: ({ squawk, label }) => `SQUAWK ${squawk} · ${label}`,
|
||||
adsb: ({ priority, label }) =>
|
||||
`ADS-B ${priority ? 'PRIORITY' : 'EMERGENCY'} · ${label}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* HUD wording for a tracked aircraft's transponder emergency, or null.
|
||||
* States what was broadcast, never a conclusion about the flight: the Mode A
|
||||
* code when it names the status, else the ADS-B field it came from (an
|
||||
* unrelated squawk is dropped).
|
||||
* @param {{label?: string, squawk?: string|null, source?: string,
|
||||
* severity?: string}|null|undefined} emergency
|
||||
* The cockpit passes its own formatter so the page shows it in the active
|
||||
* language; this module stays i18n-free.
|
||||
* @param {{label?: string, squawk?: string|null,
|
||||
* source?: string, severity?: string}|null|undefined} emergency
|
||||
* @param {{squawk: Function, adsb: Function}} [format] Builds the line from
|
||||
* `{ name, label, squawk }` and `{ name, label, priority }`, where
|
||||
* `label` is the upper-cased `name`.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatCockpitEmergency(emergency) {
|
||||
export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) {
|
||||
const label = String(emergency?.label || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!label) return null;
|
||||
const name = String(emergency.label).trim();
|
||||
if (emergency.source === 'squawk' && emergency.squawk)
|
||||
return `SQUAWK ${emergency.squawk} · ${label}`;
|
||||
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
|
||||
return `ADS-B ${field} · ${label}`;
|
||||
return format.squawk({ name, label, squawk: emergency.squawk });
|
||||
return format.adsb({
|
||||
name,
|
||||
label,
|
||||
priority: emergency.severity === 'priority',
|
||||
});
|
||||
}
|
||||
|
||||
/** Format the cockpit Context scope without overstating installation coverage. */
|
||||
/** The English Context scope line; see formatCockpitContextScope. */
|
||||
const ENGLISH_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
|
||||
installations: ({ scope, coverage }) =>
|
||||
`${scope} · INSTALLATIONS ${coverage}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Format the cockpit Context scope without overstating installation coverage.
|
||||
* @param {string|number} subjectLabel
|
||||
* @param {number} radiusM
|
||||
* @param {string|null} [installationCoverage]
|
||||
* @param {{window: Function, installations: Function}} [format] Builds the
|
||||
* line from `{ label, radiusKm }` (radiusKm is null when unknown) and
|
||||
* `{ scope, coverage }`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCockpitContextScope(
|
||||
subjectLabel,
|
||||
radiusM,
|
||||
installationCoverage = null,
|
||||
format = ENGLISH_CONTEXT_SCOPE,
|
||||
) {
|
||||
const normalizedLabel =
|
||||
typeof subjectLabel === 'string'
|
||||
@@ -117,12 +151,12 @@ export function formatCockpitContextScope(
|
||||
: '—';
|
||||
const radiusKm =
|
||||
Number.isFinite(radiusM) && radiusM >= 0
|
||||
? String(Math.round(radiusM / 1000))
|
||||
: '—';
|
||||
? Math.round(radiusM / 1000)
|
||||
: null;
|
||||
const coverage =
|
||||
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
||||
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
|
||||
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
|
||||
const base = format.window({ label: normalizedLabel, radiusKm });
|
||||
return coverage ? format.installations({ scope: base, coverage }) : base;
|
||||
}
|
||||
|
||||
/** Return seven 30-degree compass divisions centered on a heading. */
|
||||
|
||||
+206
-53
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
|
||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
const previousDocument = globalThis.document;
|
||||
afterEach(() => { globalThis.document = previousDocument; });
|
||||
afterEach(() => {
|
||||
globalThis.document = previousDocument;
|
||||
});
|
||||
|
||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||
// object reference alone is insufficient if reconciliation disconnects it.
|
||||
@@ -22,23 +24,60 @@ function fixture() {
|
||||
this.className = '';
|
||||
this._text = '';
|
||||
this.classList = {
|
||||
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
|
||||
add: (...names) => {
|
||||
this.className = [
|
||||
...new Set([
|
||||
...this.className.split(/\s+/).filter(Boolean),
|
||||
...names,
|
||||
]),
|
||||
].join(' ');
|
||||
},
|
||||
};
|
||||
}
|
||||
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
|
||||
get firstElementChild() { return this.children[0] || null; }
|
||||
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
|
||||
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
|
||||
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
|
||||
get isConnected() {
|
||||
return this === document.body || Boolean(this.parentNode?.isConnected);
|
||||
}
|
||||
get firstElementChild() {
|
||||
return this.children[0] || null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
return (
|
||||
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
get textContent() {
|
||||
return (
|
||||
this._text + this.children.map((node) => node.textContent).join('')
|
||||
);
|
||||
}
|
||||
set textContent(value) {
|
||||
this.replaceChildren();
|
||||
this._text = String(value);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
contains(node) {
|
||||
return (
|
||||
this === node || this.children.some((child) => child.contains(node))
|
||||
);
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.insertBefore(node, null);
|
||||
}
|
||||
insertBefore(node, anchor) {
|
||||
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
|
||||
assert.ok(
|
||||
anchor === null || anchor.parentNode === this,
|
||||
'insert anchor belongs to its parent',
|
||||
);
|
||||
if (node === anchor) return node;
|
||||
node.remove();
|
||||
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
const index =
|
||||
anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
this.children.splice(index, 0, node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
@@ -49,7 +88,10 @@ function fixture() {
|
||||
document.activeElement = document.body;
|
||||
focusLosses += 1;
|
||||
}
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode.children.splice(
|
||||
this.parentNode.children.indexOf(this),
|
||||
1,
|
||||
);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
@@ -59,20 +101,32 @@ function fixture() {
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
||||
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
|
||||
return (
|
||||
this.tagName === 'BUTTON' &&
|
||||
this.dataset.signalLayer != null &&
|
||||
this.dataset.signalId != null
|
||||
);
|
||||
}
|
||||
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
return selector.startsWith('.')
|
||||
? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
: this.tagName === selector.toUpperCase();
|
||||
}
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
|
||||
for (let node = this; node; node = node.parentNode)
|
||||
if (node.matches(selector)) return node;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
|
||||
return this.children.flatMap((child) => [
|
||||
...(child.matches(selector) ? [child] : []),
|
||||
...child.querySelectorAll(selector),
|
||||
]);
|
||||
}
|
||||
focus() {
|
||||
assert.ok(this.isConnected, 'a focus destination must still be connected');
|
||||
assert.ok(
|
||||
this.isConnected,
|
||||
'a focus destination must still be connected',
|
||||
);
|
||||
focusCalls += 1;
|
||||
document.activeElement = this;
|
||||
}
|
||||
@@ -85,7 +139,11 @@ function fixture() {
|
||||
controller.services = { formatAwarenessLabel };
|
||||
controller.signalList = new Node('ol');
|
||||
controller.signalToggle = new Node('button');
|
||||
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
|
||||
controller.briefTabs = [
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
];
|
||||
controller.briefPageIndex = 0;
|
||||
controller.signalItems = [];
|
||||
controller.signalSignatures = new Map();
|
||||
@@ -93,64 +151,131 @@ function fixture() {
|
||||
const display = new Node('button');
|
||||
const radio = new Node('button');
|
||||
const outside = new Node('button');
|
||||
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
|
||||
document.body.append(
|
||||
controller.signalToggle,
|
||||
controller.signalList,
|
||||
...controller.briefTabs,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
);
|
||||
const selected = [];
|
||||
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
|
||||
const click = event => controller.handleSignalClick(event);
|
||||
controller.services.militaryAwarenessLayer = {
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
};
|
||||
const click = (event) => controller.handleSignalClick(event);
|
||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||
const tab = () => {
|
||||
const nodes = document.body.querySelectorAll('button');
|
||||
const index = nodes.indexOf(document.activeElement);
|
||||
nodes[(index + 1) % nodes.length].focus();
|
||||
};
|
||||
return { controller, document, buttons, display, radio, outside, selected, click, tab,
|
||||
losses: () => focusLosses, focusCalls: () => focusCalls };
|
||||
return {
|
||||
controller,
|
||||
document,
|
||||
buttons,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
selected,
|
||||
click,
|
||||
tab,
|
||||
losses: () => focusLosses,
|
||||
focusCalls: () => focusCalls,
|
||||
};
|
||||
}
|
||||
|
||||
const item = (id, overrides = {}) => ({
|
||||
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
|
||||
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
|
||||
key: `flight:flights:${id}`,
|
||||
tone: 'nearby',
|
||||
title: `Flight ${id}`,
|
||||
detail: 'COMMERCIAL FLIGHT · 2 KM',
|
||||
target: { layerId: 'flights', id },
|
||||
timestamp: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
|
||||
const render = (f, items) => {
|
||||
f.controller.signalItems = items;
|
||||
f.controller.renderCockpitSignals();
|
||||
};
|
||||
const snapshot = (distanceM = 2000) => ({
|
||||
evaluatedAt: 1_700_000_000_000,
|
||||
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
|
||||
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 1,
|
||||
nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||
const f = fixture();
|
||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
for (let tick = 0; tick < 8; tick += 1)
|
||||
f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
assert.equal(f.buttons()[1], target);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
|
||||
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
|
||||
assert.equal(
|
||||
f.focusCalls(),
|
||||
calls,
|
||||
'refresh must preserve focus without repeatedly restoring it',
|
||||
);
|
||||
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
|
||||
});
|
||||
|
||||
test('signal content and accessible name update without replacing the active target', () => {
|
||||
const f = fixture(); render(f, [item('a')]);
|
||||
const target = f.buttons()[0]; target.focus();
|
||||
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
|
||||
const f = fixture();
|
||||
render(f, [item('a')]);
|
||||
const target = f.buttons()[0];
|
||||
target.focus();
|
||||
render(f, [
|
||||
item('a', {
|
||||
title: 'RENAMED',
|
||||
tone: 'track',
|
||||
detail: 'CURRENT',
|
||||
timestamp: 1_700_000_001_000,
|
||||
}),
|
||||
]);
|
||||
assert.equal(f.buttons()[0], target);
|
||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||
assert.match(target.textContent, /RENAMED/);
|
||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
||||
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
|
||||
assert.equal(
|
||||
f.controller.signalList.children[0].className,
|
||||
'track actionable',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
||||
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
const f = fixture();
|
||||
render(
|
||||
f,
|
||||
['a', 'b', 'c', 'd'].map((id) => item(id)),
|
||||
);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
|
||||
render(f, order.map((id) => item(id)));
|
||||
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
|
||||
for (const order of [
|
||||
['d', 'c', 'b', 'a'],
|
||||
['b', 'c', 'd', 'a'],
|
||||
['a', 'd', 'c', 'b'],
|
||||
]) {
|
||||
render(
|
||||
f,
|
||||
order.map((id) => item(id)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.buttons().map((button) => button.dataset.signalId),
|
||||
order,
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
}
|
||||
@@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
||||
});
|
||||
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
f.controller.pushCockpitSignal(
|
||||
'status',
|
||||
'warning',
|
||||
'INPUT UNKNOWN',
|
||||
'SOURCE UNAVAILABLE',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
|
||||
const event = {
|
||||
target: target.children[0],
|
||||
prevented: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
};
|
||||
f.click(event);
|
||||
assert.equal(event.prevented, true);
|
||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1]; removed.focus();
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1];
|
||||
removed.focus();
|
||||
const calls = f.focusCalls();
|
||||
render(f, [item('a')]);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.equal(f.focusCalls(), calls + 1);
|
||||
f.tab(); f.tab(); f.tab();
|
||||
f.tab();
|
||||
f.tab();
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.display);
|
||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
|
||||
assert.equal(
|
||||
f.document.activeElement,
|
||||
f.display,
|
||||
'later refresh must not pull focus back',
|
||||
);
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.radio);
|
||||
});
|
||||
|
||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0]; removed.focus();
|
||||
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
|
||||
const f = fixture();
|
||||
render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0];
|
||||
removed.focus();
|
||||
render(f, [
|
||||
item('b', {
|
||||
key: 'shared-status-key',
|
||||
target: { layerId: 'military', id: 'b' },
|
||||
}),
|
||||
]);
|
||||
const target = f.buttons()[0];
|
||||
assert.notEqual(target, removed);
|
||||
assert.equal(removed.isConnected, false);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { msg } from './i18n/marker.js';
|
||||
|
||||
const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||
flights: new Set([
|
||||
'military-awareness',
|
||||
@@ -9,6 +11,12 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||
});
|
||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
||||
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
|
||||
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
|
||||
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
|
||||
);
|
||||
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
|
||||
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
|
||||
/** Return whether an origin represents a direct user choice on this route. */
|
||||
export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
||||
return origin === 'user' || origin === 'voice';
|
||||
@@ -336,7 +344,30 @@ export function contextLayerEnableBlockReason({
|
||||
return null;
|
||||
}
|
||||
const label = String(layerName || change.layerId || 'that layer');
|
||||
return `Space Missions isolates replay data. Exit the mode to enable ${label}.`;
|
||||
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer name from a Space Missions refusal, so the UI can rebuild the
|
||||
* sentence in its language.
|
||||
* @param {string} reason
|
||||
* @returns {{layer: string}|null} The blocked layer's name, or null when the
|
||||
* reason is not a Space Missions refusal.
|
||||
*/
|
||||
export function parseContextLayerBlockReason(reason) {
|
||||
if (
|
||||
typeof reason !== 'string' ||
|
||||
!reason.startsWith(BLOCK_REASON_PREFIX) ||
|
||||
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
|
||||
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
layer: reason.slice(
|
||||
BLOCK_REASON_PREFIX.length,
|
||||
reason.length - BLOCK_REASON_SUFFIX.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-1
@@ -2,6 +2,8 @@
|
||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||
*/
|
||||
import { getLocale, t } from './i18n/index.js';
|
||||
|
||||
export function configureCreditKeyboardAccess(root = document) {
|
||||
const expand = root?.querySelector?.(
|
||||
'#cesium-credits .cesium-credit-expand-link',
|
||||
@@ -11,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
if (!expand || !lightbox || !close) return false;
|
||||
const overlay = lightbox.parentElement;
|
||||
|
||||
// Cesium writes these two texts in English once, when it builds the credit
|
||||
// display, and never rewrites them; English keeps Cesium's own text.
|
||||
if (getLocale() !== 'en') {
|
||||
expand.textContent = t('Data attribution');
|
||||
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
|
||||
if (title) title.textContent = t('Data provided by:');
|
||||
}
|
||||
|
||||
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||
expand.setAttribute('role', 'button');
|
||||
expand.setAttribute('tabindex', '0');
|
||||
@@ -19,7 +29,7 @@ export function configureCreditKeyboardAccess(root = document) {
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
close.setAttribute('role', 'button');
|
||||
close.setAttribute('tabindex', '0');
|
||||
close.setAttribute('aria-label', 'Close data attribution');
|
||||
close.setAttribute('aria-label', t('Close data attribution'));
|
||||
|
||||
if (expand.dataset.gevKeyboardReady === 'true') return true;
|
||||
expand.dataset.gevKeyboardReady = 'true';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
function fakeElement() {
|
||||
const attributes = new Map();
|
||||
@@ -186,3 +187,37 @@ test('credit keyboard setup is idempotent and safely declines incomplete markup'
|
||||
assert.equal(f.overlay.listenerCount('click'), 1);
|
||||
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
|
||||
});
|
||||
|
||||
function textFixture() {
|
||||
const f = fixture();
|
||||
const title = fakeElement();
|
||||
title.textContent = 'Data provided by:';
|
||||
f.expand.textContent = 'Data attribution';
|
||||
f.lightbox.querySelector = (selector) =>
|
||||
selector === '.cesium-credit-lightbox-title' ? title : f.close;
|
||||
return { ...f, title };
|
||||
}
|
||||
|
||||
test('English keeps Cesium attribution texts', () => {
|
||||
setLocale('en');
|
||||
const f = textFixture();
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, 'Data attribution');
|
||||
assert.equal(f.title.textContent, 'Data provided by:');
|
||||
});
|
||||
|
||||
for (const [locale, link, title] of [
|
||||
['ru', 'Источники данных', 'Данные предоставлены:'],
|
||||
['es', 'Atribución de datos', 'Datos proporcionados por:'],
|
||||
])
|
||||
test(`${locale}: Cesium attribution link and lightbox title are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const f = textFixture();
|
||||
configureCreditKeyboardAccess(f.root);
|
||||
assert.equal(f.expand.textContent, link);
|
||||
assert.equal(f.title.textContent, title);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+13
-11
@@ -7,6 +7,8 @@
|
||||
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
|
||||
*/
|
||||
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
const HELI = new Set([
|
||||
'EC20',
|
||||
'EC25',
|
||||
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts. */
|
||||
/** Readable name of each class, for cards and readouts (English data). */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: 'Light aircraft',
|
||||
glider: 'Glider',
|
||||
turboprop: 'Turboprop',
|
||||
airliner: 'Airliner',
|
||||
widebody: 'Widebody',
|
||||
quadjet: 'Four-engine jet',
|
||||
helicopter: 'Helicopter',
|
||||
fastjet: 'Fast jet',
|
||||
bizjet: 'Business jet',
|
||||
uav: 'Drone',
|
||||
light: msg('Light aircraft'),
|
||||
glider: msg('Glider'),
|
||||
turboprop: msg('Turboprop'),
|
||||
airliner: msg('Airliner'),
|
||||
widebody: msg('Widebody'),
|
||||
quadjet: msg('Four-engine jet'),
|
||||
helicopter: msg('Helicopter'),
|
||||
fastjet: msg('Fast jet'),
|
||||
bizjet: msg('Business jet'),
|
||||
uav: msg('Drone'),
|
||||
});
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
|
||||
@@ -13,18 +13,23 @@
|
||||
* broadcast, which does not confirm an incident or its resolution.
|
||||
*/
|
||||
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
|
||||
export const EMERGENCY_TINT_CSS = '#FF4444';
|
||||
|
||||
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
||||
|
||||
const KINDS = {
|
||||
general: { label: 'General emergency', severity: 'emergency' },
|
||||
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
|
||||
nordo: { label: 'Radio failure', severity: 'emergency' },
|
||||
downed: { label: 'Downed aircraft', severity: 'emergency' },
|
||||
minfuel: { label: 'Minimum fuel', severity: 'priority' },
|
||||
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
|
||||
general: { label: msg('General emergency'), severity: 'emergency' },
|
||||
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
|
||||
nordo: { label: msg('Radio failure'), severity: 'emergency' },
|
||||
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
|
||||
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
|
||||
lifeguard: {
|
||||
label: msg('Lifeguard (medical priority)'),
|
||||
severity: 'priority',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!env.hasKey) {
|
||||
const actions = terminateOwned('key-absent');
|
||||
status = 'missing-key';
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = 'AISSTREAM_API_KEY is not set';
|
||||
reconnectAttempt = 0;
|
||||
keyFingerprint = null;
|
||||
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (env.hasTransport === false) {
|
||||
const actions = terminateOwned('no-transport');
|
||||
status = 'unsupported';
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = 'Node WebSocket transport is unavailable';
|
||||
return actions;
|
||||
}
|
||||
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
|
||||
if (!silenceWatchArmed) return [];
|
||||
const silentFor = monoNow - silenceSinceMono;
|
||||
if (silentFor >= recycleAfterMs) {
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
|
||||
const actions = terminateOwned('silent');
|
||||
scheduleRetry('transport');
|
||||
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
|
||||
function onClose(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) return [];
|
||||
release();
|
||||
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||
if (!error) error = 'AISStream websocket closed';
|
||||
scheduleRetry('transport');
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
|
||||
* short way round, so points either side of 180° stay close.
|
||||
*/
|
||||
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * 111.32;
|
||||
let dLonDeg = lon2 - lon1;
|
||||
dLonDeg -= 360 * Math.round(dLonDeg / 360);
|
||||
const dLon =
|
||||
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
||||
facebook: Object.freeze(['https://www.facebook.com']),
|
||||
});
|
||||
|
||||
// Known English from public/events/bhote-koshi-2026/event.json, translated at
|
||||
// display (whole or by " · " parts). Anything else shows as authored.
|
||||
const EVENT_PACK_TEXT = new Set([
|
||||
// Field reports: our labels (credits after " · " stay) and their statuses.
|
||||
msg('Rasuwagadhi witness video'),
|
||||
msg('Bhote Koshi valley footage'),
|
||||
msg('Flood footage'),
|
||||
msg('Compilation and casualty claims'),
|
||||
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
|
||||
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
|
||||
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
|
||||
msg('SOURCE GEOCONFIRMED'),
|
||||
// Reconstruction caveat.
|
||||
msg(
|
||||
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
|
||||
),
|
||||
// Evidence titles that are more than a place name.
|
||||
msg('Immediate collapse viewpoint'),
|
||||
msg('Collapse viewpoint'),
|
||||
msg('Debris-dammed lake'),
|
||||
msg('Dammed lake'),
|
||||
msg('Second landslide'),
|
||||
msg('Gyirong border gate'),
|
||||
msg('Border gate'),
|
||||
msg('Timure evidence cluster'),
|
||||
msg('Mailung / Upper Trishuli bridge'),
|
||||
msg('Dandagaun viewpoint restaurant'),
|
||||
msg('Dandagaun viewpoint'),
|
||||
msg('Bidur - Trishuli bridge'),
|
||||
msg('Bidur bridge'),
|
||||
msg('Devighat / Taadi Khola Bridge'),
|
||||
msg('Devighat bridge'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display text for an authored event-pack string.
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bhoteEventText(value) {
|
||||
const text = value == null ? '' : String(value);
|
||||
if (EVENT_PACK_TEXT.has(text)) return t(text);
|
||||
if (!text.includes(' · ')) return text;
|
||||
return text
|
||||
.split(' · ')
|
||||
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
let _xWidgetsPromise = null;
|
||||
let _facebookSdkPromise = null;
|
||||
let _facebookEmbedSequence = 0;
|
||||
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
prior?.();
|
||||
} finally {
|
||||
if (facebookSdkReady(globalRef.FB)) finish();
|
||||
else finish(new Error('Facebook player API did not initialize.'));
|
||||
else finish(new Error(msg('Facebook player API did not initialize.')));
|
||||
}
|
||||
};
|
||||
globalRef.fbAsyncInit = ready;
|
||||
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('Facebook player API could not load.')),
|
||||
() => finish(new Error(msg('Facebook player API could not load.'))),
|
||||
{ once: true },
|
||||
);
|
||||
timer =
|
||||
globalRef.setTimeout?.(
|
||||
() => finish(new Error('Facebook player API timed out.')),
|
||||
() => finish(new Error(msg('Facebook player API timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
) ?? null;
|
||||
}).catch((error) => {
|
||||
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
};
|
||||
const ready = () => {
|
||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
||||
else finish(new Error('X embed API did not initialize.'));
|
||||
else finish(new Error(msg('X embed API did not initialize.')));
|
||||
};
|
||||
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||
if (!script) {
|
||||
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('X embed could not load.')),
|
||||
() => finish(new Error(msg('X embed could not load.'))),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
},
|
||||
);
|
||||
timer = setTimeout(
|
||||
() => finish(new Error('X embed timed out.')),
|
||||
() => finish(new Error(msg('X embed timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
);
|
||||
}).catch((error) => {
|
||||
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
return _xWidgetsPromise;
|
||||
}
|
||||
|
||||
// Outcomes a trimmed YouTube clip reports to the card status.
|
||||
const YOUTUBE_PHASE_LABELS = Object.freeze({
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
timeout: msg('TIMEOUT'),
|
||||
blocked: msg('BLOCKED'),
|
||||
});
|
||||
|
||||
function providerLabel(provider) {
|
||||
return (
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
|
||||
t('SOURCE')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
globalRef.setTimeout?.(() => {
|
||||
if (!session.active || session.ready) return;
|
||||
session.error = new Error(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
msg(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
),
|
||||
);
|
||||
session.onError?.(session.error);
|
||||
}, EMBED_TIMEOUT_MS) ?? null;
|
||||
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = 'LOADING X POST…';
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
);
|
||||
if (token !== generation || record !== current) return;
|
||||
if (!widget)
|
||||
throw new Error('The X post is unavailable or cannot be embedded.');
|
||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
else if (session.error) session.onError(session.error);
|
||||
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
}),
|
||||
);
|
||||
const leader = documentRef.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
player.className = 'bhote-embedded-callout-player';
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = String(
|
||||
observation?.shortTitle || observation?.title || 'Witness source',
|
||||
title.textContent = bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = 'OPEN ORIGINAL ↗';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
});
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+262
-73
@@ -19,11 +19,13 @@ import {
|
||||
SOURCE_PATH_BEAT_IDS,
|
||||
} from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
bhoteEventText,
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
|
||||
return Math.max(0, Math.min(beats.length - 1, current + delta));
|
||||
}
|
||||
|
||||
// Evidence phases from the event pack, shown as the beat meta prefix.
|
||||
const EVIDENCE_PHASE_LABELS = Object.freeze({
|
||||
initiation: msg('INITIATION'),
|
||||
damming: msg('DAMMING'),
|
||||
'secondary failure': msg('SECONDARY FAILURE'),
|
||||
'border arrival': msg('BORDER ARRIVAL'),
|
||||
propagation: msg('PROPAGATION'),
|
||||
'downstream passage': msg('DOWNSTREAM PASSAGE'),
|
||||
consequence: msg('CONSEQUENCE'),
|
||||
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
|
||||
});
|
||||
|
||||
/** Display label for an evidence phase; unknown phases show as authored. */
|
||||
function evidencePhaseLabel(phase) {
|
||||
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
|
||||
if (label) return t(label);
|
||||
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
|
||||
}
|
||||
|
||||
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
|
||||
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
|
||||
const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized name and date of an imagery endpoint for display.
|
||||
* @param {{label?: string, observedAt?: string}} entry
|
||||
* @returns {{name: string, date: string}}
|
||||
*/
|
||||
export function imageryEndpointLabel(entry) {
|
||||
const label = String(entry?.label || '');
|
||||
const split = label.lastIndexOf(' · ');
|
||||
const authoredName = split >= 0 ? label.slice(0, split) : label;
|
||||
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
|
||||
let name = authoredName;
|
||||
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
|
||||
const match = pattern.exec(authoredName);
|
||||
if (match) name = t(message, { year: match[1] });
|
||||
}
|
||||
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
|
||||
}
|
||||
|
||||
function imageryEndpointText(entry) {
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
return [name, date].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||
function renderImageryEndpoint(element, entry) {
|
||||
if (!element) return;
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
element.textContent = date ? `${name} · ` : name;
|
||||
if (!date) return;
|
||||
const dateElement = document.createElement('span');
|
||||
dateElement.className = 'bhote-event-date';
|
||||
dateElement.textContent = date;
|
||||
element.appendChild(dateElement);
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
|
||||
return count > 1
|
||||
? tn(
|
||||
count,
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
|
||||
)
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Interpolate headings across north using the shortest angular path. */
|
||||
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
|
||||
}
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
||||
ctx.fillText('NO PREVIEW', 20, 30);
|
||||
ctx.fillText(t('NO PREVIEW'), 20, 30);
|
||||
}
|
||||
|
||||
function ellipsizeEvidenceText(value, limit) {
|
||||
@@ -650,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
|
||||
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
// The pack names the kind of source in `media.publisher` (event.json); the
|
||||
// frame shows it in the UI language. Any other publisher shows as given.
|
||||
const EVIDENCE_PUBLISHER_TEXT = new Set([
|
||||
msg('YouTube source'),
|
||||
msg('Facebook source'),
|
||||
]);
|
||||
|
||||
/** An evidence item's publisher as its frame shows it. */
|
||||
function evidencePublisherText(publisher) {
|
||||
const text = String(publisher);
|
||||
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
|
||||
}
|
||||
|
||||
/** Repaint one stable frame with a poster or the current source-video frame. */
|
||||
export function updateEvidenceFrame(
|
||||
canvas,
|
||||
@@ -704,25 +796,78 @@ export function updateEvidenceFrame(
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
||||
ctx.fillText(
|
||||
ellipsizeEvidenceText(
|
||||
observation.shortTitle || observation.title,
|
||||
bhoteEventText(observation.shortTitle || observation.title),
|
||||
layout.titleChars,
|
||||
).toUpperCase(),
|
||||
16,
|
||||
infoY + 27,
|
||||
);
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||
const publisher = String(
|
||||
observation.media?.publisher || 'SOURCE LINK',
|
||||
const publisher = (
|
||||
observation.media?.publisher
|
||||
? evidencePublisherText(observation.media.publisher)
|
||||
: t('SOURCE LINK')
|
||||
).toUpperCase();
|
||||
ctx.fillText(
|
||||
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
|
||||
fitEvidenceSourceLine(
|
||||
ctx,
|
||||
publisher,
|
||||
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
|
||||
canvas.width - 32,
|
||||
),
|
||||
16,
|
||||
infoY + 53,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The smallest the source line's font may shrink to before the publisher is
|
||||
// shortened instead.
|
||||
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
|
||||
|
||||
/**
|
||||
* Set the font for the «{publisher} / OPEN» line and return a line that fits
|
||||
* `maxWidth`. Translations run longer: first shrink the font (to 80%), then
|
||||
* shorten the publisher; «/ OPEN» always stays whole.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} publisher Display publisher, already upper-cased.
|
||||
* @param {string} font CSS font with a px size.
|
||||
* @param {number} maxWidth
|
||||
* @returns {string}
|
||||
*/
|
||||
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
|
||||
const line = (limit) =>
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, limit),
|
||||
});
|
||||
let limit = 24;
|
||||
let text = line(limit);
|
||||
ctx.font = font;
|
||||
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
|
||||
const width = () => Number(ctx.measureText(text)?.width) || 0;
|
||||
if (width() <= maxWidth) return text;
|
||||
const size = /(\d+(?:\.\d+)?)px/.exec(font);
|
||||
if (size) {
|
||||
const base = Number(size[1]);
|
||||
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
|
||||
// Width scales with the font size; step down until it fits or bottoms out.
|
||||
let next = Math.max(
|
||||
floor,
|
||||
Math.floor(((base * maxWidth) / width()) * 10) / 10,
|
||||
);
|
||||
for (;;) {
|
||||
ctx.font = font.replace(size[0], `${next}px`);
|
||||
if (width() <= maxWidth || next <= floor) break;
|
||||
next = Math.max(floor, next - 0.5);
|
||||
}
|
||||
}
|
||||
while (width() > maxWidth && limit > 4) {
|
||||
limit = Math.min(limit, publisher.length) - 1;
|
||||
text = line(limit);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Compose source imagery and caveats into one stable renderer-owned frame. */
|
||||
export function createEvidenceFrame(observation, poster = null, options = {}) {
|
||||
const canvas = evidenceFrameCanvas(observation);
|
||||
@@ -813,28 +958,40 @@ function collectPanelReferences(panel) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
|
||||
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
|
||||
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
@@ -842,44 +999,44 @@ function createPanel(event, handlers) {
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
|
||||
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
|
||||
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
|
||||
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">▶ PLAY</button>
|
||||
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
|
||||
<button type="button" data-action="story-replay">↺ FULL STORY</button>
|
||||
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
|
||||
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
|
||||
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
refs.beforeDate.textContent = event.imagery.before.label;
|
||||
refs.afterDate.textContent = event.imagery.after.label;
|
||||
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||
refs.caveat.textContent = event.reconstruction.caveat;
|
||||
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||
|
||||
for (const report of event.fieldReports) {
|
||||
const button = document.createElement('button');
|
||||
@@ -887,8 +1044,8 @@ function createPanel(event, handlers) {
|
||||
const status = document.createElement('small');
|
||||
button.type = 'button';
|
||||
button.className = 'bhote-event-report';
|
||||
label.textContent = report.label;
|
||||
status.textContent = report.status;
|
||||
label.textContent = bhoteEventText(report.label);
|
||||
status.textContent = bhoteEventText(report.status);
|
||||
button.append(label, status);
|
||||
button.addEventListener('click', () => openExternal(report.url));
|
||||
refs.reportList.appendChild(button);
|
||||
@@ -1205,7 +1362,7 @@ export function createBhoteKoshiEventLayer({
|
||||
_embeddedMedia?.show?.({
|
||||
observation: {
|
||||
title: witness.title,
|
||||
shortTitle: 'Rasuwagadhi witness',
|
||||
shortTitle: t('Rasuwagadhi witness'),
|
||||
media: { sourceUrl: witness.sourceUrl },
|
||||
},
|
||||
anchor,
|
||||
@@ -2320,7 +2477,10 @@ export function createBhoteKoshiEventLayer({
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 60,
|
||||
interactive: true,
|
||||
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
|
||||
accessibilityLabel: t(
|
||||
'Open original source for {source}; capture time unverified',
|
||||
{ source: bhoteEventText(observation.title) },
|
||||
),
|
||||
activate: () => openExternal(observation.media.sourceUrl),
|
||||
minDistance: 0,
|
||||
maxDistance: 220000,
|
||||
@@ -2353,7 +2513,7 @@ export function createBhoteKoshiEventLayer({
|
||||
position: evidencePositionGetter(index, true),
|
||||
cullPosition: anchor,
|
||||
variant: 'label',
|
||||
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
|
||||
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
|
||||
details: [],
|
||||
accent: WORLD_OVERLAY_STYLE.accent,
|
||||
priority: 80_000 - index,
|
||||
@@ -2559,11 +2719,14 @@ export function createBhoteKoshiEventLayer({
|
||||
id: 'bhote-koshi-split-line',
|
||||
handleClass: 'bhote-koshi-split-handle',
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: event.imagery.before.label,
|
||||
afterTitle: event.imagery.after.label,
|
||||
ariaLabel: 'Historical reference and post-event image divider',
|
||||
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||
afterTitle: imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
{ before: beforePercent, after: afterPercent },
|
||||
),
|
||||
onChange: setSplit,
|
||||
});
|
||||
}
|
||||
@@ -2595,7 +2758,7 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
}
|
||||
@@ -2961,13 +3124,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.progressInput,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? 'Seek Nepal scene clock'
|
||||
: 'Schematic downstream progression',
|
||||
? t('Seek Nepal scene clock')
|
||||
: t('Schematic downstream progression'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.timelineLabel,
|
||||
'textContent',
|
||||
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
|
||||
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
|
||||
);
|
||||
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
||||
setElementProperty(
|
||||
@@ -2980,22 +3143,22 @@ export function createBhoteKoshiEventLayer({
|
||||
);
|
||||
const playLabel = sceneDirected
|
||||
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
||||
? '… PLAYING SHOT'
|
||||
: '▶ PLAY SHOT'
|
||||
? t('… PLAYING SHOT')
|
||||
: t('▶ PLAY SHOT')
|
||||
: _playing
|
||||
? 'Ⅱ PAUSE'
|
||||
? t('Ⅱ PAUSE')
|
||||
: _progress >= 1
|
||||
? _cinematicReplayArmed
|
||||
? '↺ REPLAY CINEMATIC'
|
||||
: '↺ REPLAY'
|
||||
: '▶ PLAY';
|
||||
? t('↺ REPLAY CINEMATIC')
|
||||
: t('↺ REPLAY')
|
||||
: t('▶ PLAY');
|
||||
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
'textContent',
|
||||
_sceneActionPending?.type === 'scene'
|
||||
? '… PLAYING SCENE'
|
||||
: '▶ PLAY SCENE',
|
||||
? t('… PLAYING SCENE')
|
||||
: t('▶ PLAY SCENE'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
@@ -3011,19 +3174,24 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementAttribute(
|
||||
_panelRefs.playSceneButton,
|
||||
'aria-label',
|
||||
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
|
||||
t('Continue {scene} from next shot', {
|
||||
scene:
|
||||
_sceneContext?.sceneDisplayTitle ||
|
||||
_sceneContext?.sceneTitle ||
|
||||
t('current scene'),
|
||||
}),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.storyReplayButton,
|
||||
'textContent',
|
||||
'↺ FULL STORY',
|
||||
t('↺ FULL STORY'),
|
||||
);
|
||||
if (!sceneDirected) {
|
||||
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
||||
setElementAttribute(
|
||||
_panelRefs.storyReplayButton,
|
||||
'aria-label',
|
||||
'Replay the full reconstruction',
|
||||
t('Replay the full reconstruction'),
|
||||
);
|
||||
}
|
||||
const beatIndex = currentEvidenceIndex();
|
||||
@@ -3047,18 +3215,26 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatTitle,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? _sceneContext?.shotTitle || 'SCENE SHOT'
|
||||
: observation?.shortTitle || observation?.title || 'EVIDENCE',
|
||||
? _sceneContext?.shotDisplayTitle ||
|
||||
_sceneContext?.shotTitle ||
|
||||
t('SCENE SHOT')
|
||||
: observation?.shortTitle || observation?.title
|
||||
? bhoteEventText(observation.shortTitle || observation.title)
|
||||
: t('EVIDENCE'),
|
||||
);
|
||||
const sourceCount = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
const beatMeta = [
|
||||
String(observation?.phase || 'EVIDENCE').toUpperCase(),
|
||||
'TIME UNVERIFIED',
|
||||
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
|
||||
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
|
||||
evidencePhaseLabel(observation?.phase),
|
||||
t('TIME UNVERIFIED'),
|
||||
sourceCount > 1
|
||||
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
|
||||
: '',
|
||||
observation?.imageryCoverage === 'outside'
|
||||
? t('OUTSIDE IMAGERY SWIPE')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
@@ -3066,7 +3242,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatMeta,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
|
||||
? t('{scene} / AUTHORED SHOT', {
|
||||
scene: String(
|
||||
_sceneContext?.sceneDisplayTitle ||
|
||||
_sceneContext?.sceneTitle ||
|
||||
t('NEPAL FLOOD INCIDENT'),
|
||||
).toUpperCase(),
|
||||
})
|
||||
: beatMeta,
|
||||
);
|
||||
setElementProperty(
|
||||
@@ -3093,18 +3275,23 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.playButton,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
|
||||
: 'Play reconstruction',
|
||||
? t('Play {shot}', {
|
||||
shot:
|
||||
_sceneContext?.shotDisplayTitle ||
|
||||
_sceneContext?.shotTitle ||
|
||||
t('current scene shot'),
|
||||
})
|
||||
: t('Play reconstruction'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.previousButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
|
||||
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.nextButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load next scene shot' : 'Next story beat',
|
||||
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.openSourceButton,
|
||||
@@ -3153,8 +3340,8 @@ export function createBhoteKoshiEventLayer({
|
||||
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
||||
if (_panelRefs?.caveat) {
|
||||
_panelRefs.caveat.textContent = imageryAvailable
|
||||
? _event?.reconstruction?.caveat || ''
|
||||
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
|
||||
? bhoteEventText(_event?.reconstruction?.caveat || '')
|
||||
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
|
||||
}
|
||||
if (_panelRefs?.imageryCloudNote)
|
||||
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
||||
@@ -3717,7 +3904,9 @@ export function createBhoteKoshiEventLayer({
|
||||
count: _enabled ? 1 : 0,
|
||||
status: _enabled ? 'nominal' : 'idle',
|
||||
source: 'Vantor + GeoPera',
|
||||
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
|
||||
coverage: _enabled
|
||||
? t('Rasuwa · 26 Aug 2026')
|
||||
: t('Event reconstruction'),
|
||||
};
|
||||
},
|
||||
getPlaybackState() {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
|
||||
// a long translation shrinks to fit the portrait card, action word whole.
|
||||
// English is drawn at full size.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
// JetBrains Mono advances every glyph by 0.6 em.
|
||||
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
|
||||
const measure = (text, font) => text.length * 0.6 * fontPx(font);
|
||||
|
||||
function drawSourceLine(observation, publisher) {
|
||||
const layout = evidenceCardLayout(observation);
|
||||
const drawn = [];
|
||||
const context = {
|
||||
font: '',
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
drawImage() {},
|
||||
measureText(text) {
|
||||
return { width: measure(String(text), this.font) };
|
||||
},
|
||||
fillText(value, x) {
|
||||
drawn.push({ text: String(value), font: this.font, x });
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
width: layout.canvasWidth,
|
||||
height: layout.canvasHeight,
|
||||
_bhoteEvidenceLayout: layout,
|
||||
getContext: () => context,
|
||||
};
|
||||
const subject = publisher
|
||||
? { ...observation, media: { ...observation.media, publisher } }
|
||||
: observation;
|
||||
assert.equal(updateEvidenceFrame(canvas, subject), true);
|
||||
const line = drawn.find((entry) => entry.text.includes(' / '));
|
||||
assert.ok(line, 'source line drawn');
|
||||
return { ...line, maxWidth: layout.canvasWidth - 32 };
|
||||
}
|
||||
|
||||
async function portraitObservations() {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const portrait = event.evidenceSpine.filter(
|
||||
(o) => evidenceCardLayout(o).kind === 'portrait',
|
||||
);
|
||||
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
|
||||
const facebook = portrait.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube, 'a portrait YouTube card exists');
|
||||
return [youtube, facebook].filter(Boolean);
|
||||
}
|
||||
|
||||
test('English source lines draw unchanged on every card', async () => {
|
||||
setLocale('en');
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
for (const observation of event.evidenceSpine) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
|
||||
assert.match(
|
||||
line.text,
|
||||
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ru/es source lines fit the portrait card with the action word whole', async () => {
|
||||
const observations = await portraitObservations();
|
||||
try {
|
||||
for (const [locale, action] of [
|
||||
['ru', '/ ОТКРЫТЬ'],
|
||||
['es', '/ ABRIR'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
for (const observation of observations) {
|
||||
const line = drawSourceLine(observation);
|
||||
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
|
||||
assert.ok(
|
||||
measure(line.text, line.font) <= line.maxWidth,
|
||||
`${locale}: «${line.text}» at ${line.font} overflows`,
|
||||
);
|
||||
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
|
||||
}
|
||||
}
|
||||
setLocale('ru');
|
||||
const ru = drawSourceLine(observations[0]);
|
||||
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
|
||||
const [observation] = await portraitObservations();
|
||||
try {
|
||||
setLocale('ru');
|
||||
const line = drawSourceLine(
|
||||
observation,
|
||||
'A very long regional broadcaster',
|
||||
);
|
||||
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
|
||||
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
|
||||
assert.equal(fontPx(line.font), 21 * 0.8);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
// The publisher on the evidence frame ("YouTube source") is pack text shown
|
||||
// in the UI language; English stays "YOUTUBE SOURCE / OPEN".
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
|
||||
|
||||
const eventUrl = new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function captureFrameText(observation) {
|
||||
const originalDocument = globalThis.document;
|
||||
const text = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const context = {
|
||||
beginPath() {},
|
||||
clearRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
fillRect() {},
|
||||
fillText(value) {
|
||||
text.push(String(value));
|
||||
},
|
||||
drawImage() {},
|
||||
};
|
||||
return { width: 0, height: 0, getContext: () => context };
|
||||
},
|
||||
};
|
||||
try {
|
||||
assert.ok(createEvidenceFrame(observation));
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
test('evidence card source line is fully in the UI language (es, ru)', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
const facebook = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'Facebook source',
|
||||
);
|
||||
assert.ok(youtube && facebook);
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const observation of [youtube, facebook]) {
|
||||
const lines = captureFrameText(observation);
|
||||
const sourceLine = lines.find((line) => line.includes(' / '));
|
||||
assert.ok(sourceLine, 'source line drawn');
|
||||
assert.doesNotMatch(
|
||||
sourceLine,
|
||||
/SOURCE/,
|
||||
`${locale}: English "SOURCE" left in "${sourceLine}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence card source line keeps its English wording', async () => {
|
||||
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
|
||||
const youtube = event.evidenceSpine.find(
|
||||
(o) => o.media?.publisher === 'YouTube source',
|
||||
);
|
||||
setLocale('en');
|
||||
const sourceLine = captureFrameText(youtube).find((line) =>
|
||||
line.includes(' / '),
|
||||
);
|
||||
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg, getLocale } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
@@ -51,23 +52,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
|
||||
const TRIGGER_SOURCE_URL =
|
||||
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
|
||||
|
||||
// Descriptive pin titles translate at display (same entries as the event
|
||||
// panel); place names stay Latin, anything else shows as authored.
|
||||
const PLACE_TITLE_TEXT = new Map(
|
||||
[
|
||||
msg('Collapse viewpoint'),
|
||||
msg('Dammed lake'),
|
||||
msg('Second landslide'),
|
||||
msg('Border gate'),
|
||||
msg('Dandagaun viewpoint'),
|
||||
msg('Bidur bridge'),
|
||||
msg('Devighat bridge'),
|
||||
].map((text) => [text.toUpperCase(), text]),
|
||||
);
|
||||
|
||||
function placeTitle(title) {
|
||||
const text = PLACE_TITLE_TEXT.get(title);
|
||||
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
|
||||
}
|
||||
|
||||
const INCIDENT = Object.freeze({
|
||||
lat: 28.3632333,
|
||||
lon: 85.4423639,
|
||||
heightM: 5200,
|
||||
title: 'BHOTE KOSHI FLOOD INCIDENT',
|
||||
subtitle: 'NEPAL · 26 AUG 2026',
|
||||
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
|
||||
subtitle: msg('NEPAL · 26 AUG 2026'),
|
||||
});
|
||||
const TRIGGER_RECORD = Object.freeze({
|
||||
lat: 28.271,
|
||||
lon: 85.515,
|
||||
heightM: 4200,
|
||||
title: 'USGS INITIAL REPORT',
|
||||
title: msg('USGS INITIAL REPORT'),
|
||||
details: Object.freeze([
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
'28.2710° N · 85.5150° E',
|
||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
||||
msg('26 AUG 2026 · 08:37 NPT'),
|
||||
msg('28.2710° N · 85.5150° E'),
|
||||
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
|
||||
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
|
||||
]),
|
||||
});
|
||||
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
||||
@@ -90,11 +110,13 @@ const NEPAL = Object.freeze({
|
||||
lat: 28.3949,
|
||||
lon: 84.124,
|
||||
heightM: 6800,
|
||||
// i18n-ignore: country name, shown as is
|
||||
title: 'NEPAL',
|
||||
});
|
||||
const NEARBY_CITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'kathmandu',
|
||||
// i18n-ignore: city name
|
||||
title: 'KATHMANDU',
|
||||
lat: 27.7017,
|
||||
lon: 85.3206,
|
||||
@@ -103,6 +125,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'bhaktapur',
|
||||
// i18n-ignore: city name
|
||||
title: 'BHAKTAPUR',
|
||||
lat: 27.673,
|
||||
lon: 85.43,
|
||||
@@ -111,6 +134,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'dhulikhel',
|
||||
// i18n-ignore: city name
|
||||
title: 'DHULIKHEL',
|
||||
lat: 27.6221,
|
||||
lon: 85.5428,
|
||||
@@ -394,8 +418,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: subject.title,
|
||||
details: [subject.subtitle],
|
||||
title: t(subject.title),
|
||||
details: [t(subject.subtitle)],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -536,8 +560,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: TRIGGER_RECORD.title,
|
||||
details: [...TRIGGER_RECORD.details],
|
||||
title: t(TRIGGER_RECORD.title),
|
||||
details: TRIGGER_RECORD.details.map((line) => t(line)),
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -549,8 +573,9 @@ export function createBhoteKoshiLocatorLayer({
|
||||
protected: true,
|
||||
active: true,
|
||||
interactive: true,
|
||||
accessibilityLabel:
|
||||
accessibilityLabel: t(
|
||||
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
|
||||
),
|
||||
activate: () => {
|
||||
const opened = window.open(
|
||||
TRIGGER_SOURCE_URL,
|
||||
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
|
||||
details: [],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: progress,
|
||||
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'nepal-context',
|
||||
// i18n-ignore: country name
|
||||
label: 'NEPAL',
|
||||
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Show the Nepal context callout without a border or country fill',
|
||||
),
|
||||
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-regional',
|
||||
// i18n-ignore: river name (Bhote Koshi)
|
||||
label: 'BHOTE',
|
||||
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-city-context',
|
||||
label: 'CITIES',
|
||||
label: t('CITIES'),
|
||||
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Keep the regional incident context and introduce nearby city labels',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-incident-places',
|
||||
label: 'PLACES',
|
||||
label: t('PLACES'),
|
||||
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce the reconstruction scene key places from trigger to downstream',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-flood-path',
|
||||
label: 'PATH',
|
||||
label: t('PATH'),
|
||||
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-path-overview',
|
||||
label: 'OVERVIEW',
|
||||
label: t('OVERVIEW'),
|
||||
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
title: 'Keep the completed flood path and location labels visible',
|
||||
title: t(
|
||||
'Keep the completed flood path and location labels visible',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-trigger-record',
|
||||
label: 'RECORD',
|
||||
label: t('RECORD'),
|
||||
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Reveal the trigger record while making a slow camera approach',
|
||||
),
|
||||
params: {
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
preview: true,
|
||||
|
||||
+408
-121
@@ -1,9 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import {
|
||||
INCIDENT_OVERVIEW_HOLD_SEC,
|
||||
INCIDENT_OVERVIEW_PLACES,
|
||||
} from './bhoteKoshiIncidentPlaces.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { createPlaybackClock } from '../director/clock.js';
|
||||
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
||||
@@ -29,8 +33,12 @@ function viewerFixture() {
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
add(source) {
|
||||
added.push(source);
|
||||
},
|
||||
remove(source) {
|
||||
removed.push(source);
|
||||
},
|
||||
},
|
||||
_test: { added, removed },
|
||||
};
|
||||
@@ -38,7 +46,9 @@ function viewerFixture() {
|
||||
|
||||
function nonRunningAnimation() {
|
||||
return {
|
||||
scheduleFrame() { return 1; },
|
||||
scheduleFrame() {
|
||||
return 1;
|
||||
},
|
||||
cancelFrame() {},
|
||||
};
|
||||
}
|
||||
@@ -58,24 +68,68 @@ function overlayHostFixture() {
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
||||
[80, 26], [88, 26], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
]),
|
||||
[
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 26],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[80, 26],
|
||||
]),
|
||||
[
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[80, 26],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
['bad', 27],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
||||
const island = [[82, 25], [83, 25], [82, 25]];
|
||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
||||
assert.equal(largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}), main);
|
||||
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
|
||||
const main = [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
[80, 26],
|
||||
];
|
||||
const island = [
|
||||
[82, 25],
|
||||
[83, 25],
|
||||
[82, 25],
|
||||
];
|
||||
assert.equal(
|
||||
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
|
||||
main,
|
||||
);
|
||||
assert.equal(
|
||||
largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}),
|
||||
main,
|
||||
);
|
||||
assert.deepEqual(
|
||||
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
new Cesium.Cartesian3(10, 0, 0),
|
||||
new Cesium.Cartesian3(20, 0, 0),
|
||||
];
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [
|
||||
positions[0],
|
||||
positions[0],
|
||||
]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||
positions[0],
|
||||
new Cesium.Cartesian3(5, 0, 0),
|
||||
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
overlayHost,
|
||||
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(publication.entries.length, 2);
|
||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const nepal = publication.entries.find(
|
||||
({ id }) => id === 'nepal-context-callout',
|
||||
);
|
||||
const incident = publication.entries.find(
|
||||
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||
);
|
||||
assert.equal(nepal.variant, 'label');
|
||||
assert.equal(nepal.title, 'NEPAL');
|
||||
assert.equal(nepal.anchorDot, true);
|
||||
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(incident.presentationScale(), 1);
|
||||
assert.equal(layer.getStats().status, 'nominal');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
});
|
||||
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||
|
||||
assert.equal(await layer.disable(), true);
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(
|
||||
overlayHost._test.clears.at(-1),
|
||||
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
|
||||
);
|
||||
assert.equal(layer.getStats().status, 'idle');
|
||||
});
|
||||
|
||||
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||
['nepal-context-halo'],
|
||||
);
|
||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||
assert.equal(nepalLabel.variant, 'label');
|
||||
assert.equal(nepalLabel.placement, 'right');
|
||||
assert.equal(nepalLabel.anchorDot, true);
|
||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
|
||||
[
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
],
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
overlayHost._test.publications.at(-1).entries
|
||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
'card',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||
['nepal-context-halo'],
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
||||
let nowMs = 0;
|
||||
let pulseFrame = null;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null, requestRender() {},
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
||||
scheduleFrame(callback) {
|
||||
pulseFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame() {},
|
||||
});
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
const event = JSON.parse(
|
||||
await readFile(
|
||||
new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(event.evidenceSpine.length, 16);
|
||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||
assert.equal(source.entities.values.length, 15);
|
||||
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
||||
const id = `bhote-koshi-place-${point.id}`;
|
||||
const pin = labels.find((entry) => entry.id === id);
|
||||
assert.ok(pin, point.id);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
|
||||
assert.equal(
|
||||
pin.title,
|
||||
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
|
||||
);
|
||||
assert.equal(pin.variant, 'label');
|
||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
|
||||
);
|
||||
nowMs = 349 + index * 650;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
nowMs += 1;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
|
||||
assert.equal(
|
||||
source.entities.values.filter(
|
||||
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
nowMs += 520;
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||
// A repeat halo cycle must not erase already revealed labels.
|
||||
nowMs = 350 + 15 * 650;
|
||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
||||
assert.equal(
|
||||
source.entities
|
||||
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
|
||||
.show.getValue(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
labels
|
||||
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
|
||||
.sourceAlpha(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
|
||||
false,
|
||||
);
|
||||
assert.equal(typeof pulseFrame, 'function');
|
||||
pulseFrame();
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
|
||||
assert.equal(
|
||||
source.entities.values.some((entity) =>
|
||||
entity.id.startsWith('bhote-koshi-place-'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => 0,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const titles = new Map(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.map(({ id, title }) => [id, title]),
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
|
||||
'01 · ВИД НА ОБРУШЕНИЕ',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-gyirong-border-gate'),
|
||||
'04 · ПОГРАНПЕРЕХОД',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
|
||||
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
|
||||
'14 · МОСТ BIDUR',
|
||||
);
|
||||
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
||||
const trigger = labels.find(
|
||||
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
|
||||
);
|
||||
const bidur = labels.find(
|
||||
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
|
||||
);
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
}
|
||||
assert.equal(path.polyline.clampToGround.getValue(), true);
|
||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
));
|
||||
assert.ok(
|
||||
path.polyline.material.color
|
||||
.getValue()
|
||||
.equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
),
|
||||
);
|
||||
assert.equal(path.show.getValue(), false);
|
||||
assert.equal(trigger.sourceAlpha(), 1);
|
||||
assert.equal(bidur.sourceAlpha(), 1);
|
||||
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
const halfwayCount = path.polyline.positions.getValue().length;
|
||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||
nowMs = 4250;
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.equal(
|
||||
path.polyline.positions.getValue().length,
|
||||
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||
);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||
// More observation pins must not extend or straighten the sourced route.
|
||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
||||
)));
|
||||
assert.deepEqual(
|
||||
path.polyline.positions.getValue(),
|
||||
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
||||
assert.equal(labels.length, 15);
|
||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
|
||||
assert.equal(
|
||||
pin.title,
|
||||
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||
);
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
||||
assert.equal(
|
||||
path.polyline.positions.getValue().length,
|
||||
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equals(
|
||||
path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
|
||||
),
|
||||
);
|
||||
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(
|
||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
||||
source.entities.getById(
|
||||
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
const record = publication.entries[0];
|
||||
|
||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
||||
'bhote-koshi-flood-path',
|
||||
'bhote-koshi-trigger-record-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
source.entities.values.map(({ id }) => id),
|
||||
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
|
||||
);
|
||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||
assert.deepEqual(record.details, [
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
viewer.camera = {
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights[0].duration, 4.6);
|
||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
|
||||
);
|
||||
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||
flights[0].complete();
|
||||
assert.equal(flights.length, 2);
|
||||
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
viewer.camera = {
|
||||
moveStart, moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
cancelDelay(id) { timers.delete(id); },
|
||||
cancelDelay(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const director = {
|
||||
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
|
||||
_clock: createPlaybackClock({
|
||||
isRunning: () => false,
|
||||
timingForShot: () => null,
|
||||
onProgress() {},
|
||||
}),
|
||||
_scenePacks: createDefaultScenePacks(),
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
await layer.enable(viewer);
|
||||
if (phase === 'move-end') moveStart.raiseEvent();
|
||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
||||
assert.equal(
|
||||
flights.length,
|
||||
oldFlights.length,
|
||||
'no post-Stop approach or orbit',
|
||||
);
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
1,
|
||||
'Stop preserves the visible locator',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
callbacks.at(-1)();
|
||||
const successor = flights.at(-1);
|
||||
const count = flights.length;
|
||||
callbacks[0]();
|
||||
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
|
||||
for (const flight of oldFlights) {
|
||||
flight.cancel();
|
||||
flight.complete();
|
||||
}
|
||||
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||
successor.complete();
|
||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
||||
} finally { await layer.destroy(); }
|
||||
assert.equal(
|
||||
flights.length,
|
||||
count + 1,
|
||||
'successor retains its own orbit',
|
||||
);
|
||||
} finally {
|
||||
await layer.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
const source = viewer._test.added[0];
|
||||
const borderFill = source.entities.getById('nepal-border-fill');
|
||||
const entries = overlayHost._test.publications.at(-1).entries;
|
||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
||||
const incident = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||
);
|
||||
const kathmandu = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-kathmandu',
|
||||
);
|
||||
const bhaktapur = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-bhaktapur',
|
||||
);
|
||||
const dhulikhel = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-dhulikhel',
|
||||
);
|
||||
|
||||
assert.equal(borderFill.show.getValue(), true);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
||||
variant === 'label' && anchorDot === true
|
||||
)));
|
||||
assert.ok(
|
||||
[kathmandu, bhaktapur, dhulikhel].every(
|
||||
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
|
||||
),
|
||||
);
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
assert.equal(dhulikhel.sourceAlpha(), 0);
|
||||
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
nowMs = 1300;
|
||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
|
||||
});
|
||||
try {
|
||||
await layer.enable(viewer);
|
||||
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
|
||||
const ids = viewer._test.added[0].entities.values.map(
|
||||
(entity) => entity.id,
|
||||
);
|
||||
assert.ok(ids.includes('nepal-context-halo'));
|
||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||
);
|
||||
assert.equal(layer.getStats().status, 'degraded');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const boundary = new Promise((resolve) => {
|
||||
releaseBoundary = resolve;
|
||||
});
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
||||
|
||||
const enabling = layer.enable(viewer);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('nepal-border-highlight'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(overlayHost._test.publications.length, 1);
|
||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications[0].entries.map(({ id }) => id),
|
||||
['nepal-context-callout'],
|
||||
);
|
||||
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
|
||||
releaseBoundary({
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
});
|
||||
await enabling;
|
||||
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
|
||||
assert.ok(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||
);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(overlayHost._test.publications.length, 2);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
||||
const viewer = viewerFixture();
|
||||
const controller = new AbortController();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const boundary = new Promise((resolve) => {
|
||||
releaseBoundary = resolve;
|
||||
});
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
||||
});
|
||||
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||
controller.abort();
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
|
||||
releaseBoundary({
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
],
|
||||
});
|
||||
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,683 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
parseCataloniaXml,
|
||||
normalizeCataloniaImageUrl,
|
||||
cataloniaCameraName,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<?xml'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One `<gml:featureMember>` block matching the real feed's shape. */
|
||||
function cataloniaFeature({
|
||||
fid,
|
||||
lon,
|
||||
lat,
|
||||
carretera = '',
|
||||
municipi = '',
|
||||
pk = '',
|
||||
link = '',
|
||||
font = '',
|
||||
}) {
|
||||
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
|
||||
return (
|
||||
`<gml:featureMember><cite:cameres fid="${fid}">` +
|
||||
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
|
||||
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
|
||||
`</gml:Point></cite:geom>` +
|
||||
`<cite:carretera>${carretera}</cite:carretera>` +
|
||||
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
|
||||
`<cite:link>${link}</cite:link>` +
|
||||
`<cite:font>${font}</cite:font>` +
|
||||
`</cite:cameres></gml:featureMember>`
|
||||
);
|
||||
}
|
||||
|
||||
function cataloniaFeed(features) {
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
|
||||
features.join('') +
|
||||
'</wfs:FeatureCollection>'
|
||||
);
|
||||
}
|
||||
|
||||
test('parseCataloniaXml extracts every field from a feature block', () => {
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
const rows = parseCataloniaXml(xml);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: 2.1849528,
|
||||
lat: 41.45989301,
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
|
||||
// RenderService always 302s to a hardcoded http:// Location (which
|
||||
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
|
||||
// setup fails the handshake with Node's fetch regardless — TransitCamera
|
||||
// over plain http is the only transport that actually works here, so we
|
||||
// build that URL from RenderService's sctidcam id instead of registering
|
||||
// the redirector.
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
|
||||
'an already-https RenderService link is rewritten the same way',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
|
||||
'',
|
||||
'a RenderService link with no sctidcam id is rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
|
||||
'https://www.bcn.cat/transit/imatges/a.gif',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
),
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
|
||||
'',
|
||||
'a look-alike host is rejected',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
|
||||
'',
|
||||
'any host outside the allowlist is rejected',
|
||||
);
|
||||
assert.equal(normalizeCataloniaImageUrl(''), '');
|
||||
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
|
||||
// The hostname matches the allowlist, so only an explicit userinfo check
|
||||
// stops catalog data injecting credentials into the frame request.
|
||||
for (const link of [
|
||||
'https://user:[email protected]/transit/imatges/a.gif',
|
||||
'http://[email protected]/transit/imatges/a.gif',
|
||||
'https://:[email protected]/it_terrassa/cam02.jpeg',
|
||||
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
|
||||
// An allowlisted hostname alone would let catalog data aim the frame
|
||||
// request at another service or path on that host.
|
||||
for (const link of [
|
||||
'http://www.bcn.cat:8080/transit/imatges/a.gif',
|
||||
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
|
||||
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
'http://www.bcn.cat/admin/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/',
|
||||
'http://www.bcn.cat/transit/imatges/sub/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
|
||||
'https://emap.terrassa.cat/other/cam02.jpeg',
|
||||
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
|
||||
for (const camId of [
|
||||
'../../etc/passwd',
|
||||
'..%2F..%2Fetc%2Fpasswd',
|
||||
'nc87.png',
|
||||
'nc87',
|
||||
'nc87.gif/../x.gif',
|
||||
'nc87.gif&nom=other.gif',
|
||||
'nc-87.gif',
|
||||
]) {
|
||||
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
|
||||
);
|
||||
});
|
||||
|
||||
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
assert.equal(decodeNumericEntities('Lluís 🚦'), 'Lluís 🚦');
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
for (const raw of [
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
]) {
|
||||
assert.equal(decodeNumericEntities(raw), raw, raw);
|
||||
}
|
||||
});
|
||||
|
||||
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
|
||||
assert.equal(
|
||||
cataloniaCameraName('C-58', 'Nus Trinitat'),
|
||||
'C-58 (Nus Trinitat)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
|
||||
'Plaça Urquinaona (Barcelona)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
|
||||
'Barcelona Ronda Litoral',
|
||||
);
|
||||
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
|
||||
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
|
||||
});
|
||||
|
||||
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-2',
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-3',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
carretera: 'Cam02',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
// Rejected: link host not in the allowlist.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-4',
|
||||
lon: '2.0',
|
||||
lat: '41.5',
|
||||
link: 'https://example.com/fake.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
// Rejected: outside the Catalonia bounding box.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-5',
|
||||
lon: '-3.7',
|
||||
lat: '40.4',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadCataloniaSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.ok(
|
||||
CATALONIA_CAMERAS_URL.startsWith('https://'),
|
||||
'the catalog is fetched over https',
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id).sort(),
|
||||
[
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
],
|
||||
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
|
||||
);
|
||||
|
||||
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
|
||||
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
|
||||
assert.equal(sct.provider, 'Servei Català de Trànsit');
|
||||
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
|
||||
assert.equal(
|
||||
sct.url,
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
|
||||
);
|
||||
assert.equal(sct.groundElevationM, 80);
|
||||
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
|
||||
|
||||
const bcn = cameras.find(
|
||||
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
);
|
||||
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
|
||||
assert.equal(bcn.city, 'Barcelona');
|
||||
assert.equal(bcn.groundElevationM, 20);
|
||||
|
||||
const terrassa = cameras.find(
|
||||
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
);
|
||||
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
|
||||
assert.equal(terrassa.groundElevationM, 270);
|
||||
});
|
||||
|
||||
test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => {
|
||||
quiet(t);
|
||||
let respond;
|
||||
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered or
|
||||
// downgraded. Its body is a live stream, so the test also proves the
|
||||
// refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// An HTTP error body is released too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||
|
||||
const validFeed = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
|
||||
// A body that declares a size over the cap is refused rather than buffered,
|
||||
// even though its actual content would parse.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(validFeed, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body with no declared size that only turns out too long while
|
||||
// streaming — a valid feed padded past the cap.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized =
|
||||
validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_CATALONIA_ENABLED: undefined,
|
||||
CCTV_CATALONIA_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
if (String(url) !== CATALONIA_CAMERAS_URL) {
|
||||
return new Response('unavailable', { status: 503 });
|
||||
}
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, { status: 200 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['cat-sct-nc87'],
|
||||
);
|
||||
|
||||
process.env.CCTV_CATALONIA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
|
||||
* under one upstream run's `fid` prefix. */
|
||||
function snapshotFeed(fidPrefix) {
|
||||
return cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_386f`,
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_b48`,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c2`,
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c3`,
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 ',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadFromFeed(t, xml) {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
return await loadCataloniaSourcesFromOpenData();
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
}
|
||||
|
||||
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The fid embeds the upstream server-start stamp: the PR fixture's
|
||||
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
|
||||
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
|
||||
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
|
||||
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
|
||||
assert.deepEqual(ids(before), [
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
|
||||
'cat-sct-nc85',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
]);
|
||||
assert.deepEqual(ids(after), ids(before));
|
||||
});
|
||||
|
||||
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
|
||||
// different fids.
|
||||
const row = (fid, camId) =>
|
||||
cataloniaFeature({
|
||||
fid,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
|
||||
font: 'SCT',
|
||||
});
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
row('cameres.fid-x_1', 'nc85.gif'),
|
||||
row('cameres.fid-x_2', 'cb5.gif'),
|
||||
row('cameres.fid-x_3', 'nc85.gif'),
|
||||
row('cameres.fid-x_4', 'cb5.gif'),
|
||||
row('cameres.fid-x_5', 'cb10.gif'),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'cat-sct-cb10',
|
||||
'cat-sct-cb5',
|
||||
'cat-sct-nc85',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.17',
|
||||
lat: '41.39',
|
||||
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_3',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_4',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => [camera.id, camera.url]),
|
||||
[
|
||||
[
|
||||
'cat-sct-nc87',
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31 �',
|
||||
municipi: 'Bad�',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'Plaça Catalunya',
|
||||
municipi: 'Llinars del Vallès',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(cameras.length, 2);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc85').name,
|
||||
'C-31 � (Bad�)',
|
||||
'an invalid reference is kept as written',
|
||||
);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc87').name,
|
||||
'Plaça Catalunya (Llinars del Vallès)',
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
DEFAULT_CATALONIA_MAX_SOURCES,
|
||||
DRIVEBC_WEBCAMS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
@@ -221,7 +222,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_KING_COUNTY_MAX_SOURCES,
|
||||
KING_COUNTY_CAMERAS_URL,
|
||||
KING_COUNTY_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** A FeatureServer GeoJSON feature carrying the fields the loader reads. */
|
||||
function kingCountyFeature(id, lat, lon, overrides = {}) {
|
||||
const { properties = {}, geometry } = overrides;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry:
|
||||
geometry === undefined
|
||||
? { type: 'Point', coordinates: [lon, lat] }
|
||||
: geometry,
|
||||
properties: {
|
||||
AssetID: id,
|
||||
Location: `Road ${id} at Test Ave`,
|
||||
ImageURL: `http://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam${id}.jpg`,
|
||||
Live: 1,
|
||||
Model: '3950',
|
||||
Manufacturer: 'Cohu',
|
||||
Owner: 'King County',
|
||||
CurrentStatus: 'Active',
|
||||
...properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('King County loader keeps live county cameras and upgrades frame URLs to HTTPS', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: {
|
||||
Location: 'NE Novelty Hill Road at Trilogy Parkway NE',
|
||||
// The live layer carries stray whitespace in model strings.
|
||||
Model: 'Q6135-LE\n',
|
||||
Manufacturer: 'Axis',
|
||||
},
|
||||
}),
|
||||
// City-owned camera the county hosts: kept, owner becomes the credit.
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Owner: 'City of Newcastle', Model: null },
|
||||
}),
|
||||
// WSDOT-owned row: its frames are WSDOT-hosted, so it is skipped.
|
||||
kingCountyFeature(70, 47.6, -122.3, { properties: { Owner: 'WSDOT' } }),
|
||||
// Off-host frame, not live, retired, bad id, bad geometry, out of county.
|
||||
kingCountyFeature(71, 47.6, -122.3, {
|
||||
properties: { ImageURL: 'https://example.com/cam71.jpg' },
|
||||
}),
|
||||
kingCountyFeature(72, 47.6, -122.3, { properties: { Live: 0 } }),
|
||||
kingCountyFeature(73, 47.6, -122.3, {
|
||||
properties: { CurrentStatus: 'Retired' },
|
||||
}),
|
||||
kingCountyFeature('74', 47.6, -122.3),
|
||||
kingCountyFeature(75, 47.6, -122.3, { geometry: null }),
|
||||
kingCountyFeature(76, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [KING_COUNTY_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
|
||||
const novelty = cameras.find((camera) => camera.id === 'kingcounty-56');
|
||||
assert.equal(novelty.name, 'NE Novelty Hill Road at Trilogy Parkway NE');
|
||||
assert.equal(novelty.provider, 'King County Road Services');
|
||||
assert.equal(
|
||||
novelty.url,
|
||||
'https://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam56.jpg',
|
||||
);
|
||||
assert.equal(novelty.snapshotUrl, novelty.url);
|
||||
assert.equal(novelty.feedType, 'image');
|
||||
// Published hardware model rides along, whitespace-collapsed.
|
||||
assert.equal(novelty.model, 'Q6135-LE');
|
||||
assert.equal(novelty.manufacturer, 'Axis');
|
||||
assert.equal(novelty.credit, '');
|
||||
// No facing in the layer: id-hash fallback at low confidence.
|
||||
assert.equal(novelty.headingConfidence, 'low');
|
||||
assert.equal(novelty.fovDeg, 44);
|
||||
assert.ok(novelty.headingDeg >= 0 && novelty.headingDeg < 360);
|
||||
|
||||
const newcastle = cameras.find((camera) => camera.id === 'kingcounty-90');
|
||||
assert.equal(newcastle.credit, 'City of Newcastle');
|
||||
assert.equal(newcastle.model, undefined);
|
||||
// A manufacturer without a model identifies nothing, so it is dropped too.
|
||||
assert.equal(newcastle.manufacturer, undefined);
|
||||
});
|
||||
|
||||
test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => {
|
||||
const withModel = normalizeSourceItem({
|
||||
id: 'kingcounty-56',
|
||||
lat: 47.7,
|
||||
lon: -122.0,
|
||||
model: ' Q6135-LE ',
|
||||
manufacturer: ' Axis ',
|
||||
});
|
||||
assert.equal(withModel.model, 'Q6135-LE');
|
||||
assert.equal(withModel.manufacturer, 'Axis');
|
||||
|
||||
const withoutModel = normalizeSourceItem({
|
||||
id: 'austin-1',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
});
|
||||
assert.equal(withoutModel.model, undefined);
|
||||
assert.equal(withoutModel.manufacturer, undefined);
|
||||
assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined);
|
||||
});
|
||||
|
||||
/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */
|
||||
async function requestSources(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url: '/sources', method: 'GET' }, res);
|
||||
assert.equal(res.status, 200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
/** The browser catalog with the pose math stubbed out: only field
|
||||
* passthrough is under test here. */
|
||||
function browserCatalog() {
|
||||
const model = {
|
||||
safeNumber: (value, fallback = NaN) =>
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback,
|
||||
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
|
||||
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
|
||||
headingFromId: () => 0,
|
||||
normalizeFeedType: (type) => type,
|
||||
ensureCameraPose: () => {},
|
||||
};
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: {} } },
|
||||
parts: { model },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// No file/env catalog, so the live packs load; every pack but King County
|
||||
// sees an upstream failure and contributes nothing.
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_KINGCOUNTY_ENABLED: undefined,
|
||||
CCTV_KINGCOUNTY_MAX_SOURCES: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) !== KING_COUNTY_CAMERAS_URL)
|
||||
return new Response('', { status: 503 });
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' },
|
||||
}),
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Model: null, Manufacturer: 'Cohu' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: root }));
|
||||
const served = new Map(body.sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...served.keys()].sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
assert.equal(served.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(served.get('kingcounty-56').manufacturer, 'Axis');
|
||||
// Absent hardware is an explicit empty string on the wire, like
|
||||
// headingConfidence, so consumers never have to probe for the key.
|
||||
assert.equal(served.get('kingcounty-90').model, '');
|
||||
assert.equal(served.get('kingcounty-90').manufacturer, '');
|
||||
|
||||
const cameras = browserCatalog().buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis');
|
||||
assert.equal(byId.get('kingcounty-90').model, '');
|
||||
assert.equal(byId.get('kingcounty-90').manufacturer, '');
|
||||
});
|
||||
|
||||
test('King County loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(
|
||||
kingCountyFeature(100 + i, 47.2 + i * 0.02, -122.4 + i * 0.02),
|
||||
);
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('King County loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_KING_COUNTY_MAX_SOURCES, 125);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader does not follow a redirected catalog', async (t) => {
|
||||
quiet(t);
|
||||
let init;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
init = options;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [kingCountyFeature(56, 47.70059, -122.02443)],
|
||||
}),
|
||||
{ status: 302, headers: { Location: 'https://example.com/cams' } },
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
assert.equal(init.redirect, 'manual');
|
||||
});
|
||||
|
||||
test('King County loader rejects an oversized catalog', async (t) => {
|
||||
quiet(t);
|
||||
const features = [kingCountyFeature(56, 47.70059, -122.02443)];
|
||||
const body = JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
padding: 'x'.repeat(KING_COUNTY_MAX_CATALOG_BYTES),
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response(body));
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader drops duplicate ids before the per-pack cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
// Repeats of the camera nearest the anchor would fill an 8-camera cap
|
||||
// before the second, farther camera if duplicates reached prioritization.
|
||||
const features = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
features.push(kingCountyFeature(56, 47.4829, -122.2171));
|
||||
}
|
||||
features.push(kingCountyFeature(57, 47.70059, -122.02443));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-57',
|
||||
]);
|
||||
});
|
||||
|
||||
test('King County loader omits vendor-generic model strings', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(61, 47.5, -122.2, {
|
||||
properties: { Model: 'PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(62, 47.51, -122.2, {
|
||||
properties: { Model: 'PTZ IP', Manufacturer: 'Cohu' },
|
||||
}),
|
||||
kingCountyFeature(63, 47.52, -122.2, {
|
||||
properties: { Model: 'BOSCH/PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(64, 47.53, -122.2, {
|
||||
properties: { Model: 'AUTODOME IP 5000', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(
|
||||
(await loadKingCountySourcesFromOpenData()).map((camera) => [
|
||||
camera.id,
|
||||
camera,
|
||||
]),
|
||||
);
|
||||
for (const id of ['kingcounty-61', 'kingcounty-62', 'kingcounty-63']) {
|
||||
assert.equal(byId.get(id).model, undefined, id);
|
||||
assert.equal(byId.get(id).manufacturer, undefined, id);
|
||||
}
|
||||
assert.equal(byId.get('kingcounty-64').model, 'AUTODOME IP 5000');
|
||||
assert.equal(byId.get('kingcounty-64').manufacturer, 'Bosch');
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lithuaniaCameraToSource,
|
||||
loadLithuaniaSources,
|
||||
LITHUANIA_CAMERAS_URL,
|
||||
LITHUANIA_LOCATIONS_URL,
|
||||
LITHUANIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/lithuania.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const row = {
|
||||
id: 72,
|
||||
name: 'Vilnius A1 10,04',
|
||||
x: 576154,
|
||||
y: 6056867,
|
||||
image:
|
||||
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
};
|
||||
const feature = {
|
||||
id: '72',
|
||||
points: [{ point: [54.642550940833566, 25.17981587877114] }],
|
||||
};
|
||||
const layers = [{ layer: 'VKR', features: [feature] }];
|
||||
|
||||
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
|
||||
const camera = lithuaniaCameraToSource(row, feature);
|
||||
assert.equal(camera.id, 'lithuania-72');
|
||||
assert.equal(camera.lat, 54.642550940833566);
|
||||
assert.equal(camera.lon, 25.17981587877114);
|
||||
assert.equal(camera.name, 'Vilnius A1 10,04');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.url, row.image);
|
||||
assert.match(camera.credit, /Via Lietuva/);
|
||||
});
|
||||
|
||||
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...feature, id: '8' },
|
||||
{ ...feature, points: [] },
|
||||
{ ...feature, points: [{ point: [25.18, 54.64] }] },
|
||||
{ ...feature, points: [{ point: [576154, 6056867] }] },
|
||||
{ ...feature, points: [{ point: [null, 25] }] },
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource(row, invalid), null);
|
||||
}
|
||||
for (const image of [
|
||||
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
|
||||
row.image.replace('https://', 'https://user:pass@'),
|
||||
row.image.replace('id=72', 'id=8'),
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
|
||||
}
|
||||
assert.equal(
|
||||
lithuaniaCameraToSource(
|
||||
{ ...row, image: row.image + '&extra=ignored' },
|
||||
feature,
|
||||
).url,
|
||||
row.image,
|
||||
);
|
||||
assert.equal(lithuaniaCameraToSource(null, feature), null);
|
||||
});
|
||||
|
||||
function mockCatalogs(t) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return Response.json([row, row, { ...row, id: 999 }]);
|
||||
}
|
||||
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
|
||||
const requested = mockCatalogs(t);
|
||||
assert.deepEqual(
|
||||
(await loadLithuaniaSources()).map((c) => c.id),
|
||||
['lithuania-72'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requested.sort(),
|
||||
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
/** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
|
||||
* (undefined leaves it unset), restoring the environment afterwards. */
|
||||
async function runCatalogWithLithuaniaFlag(t, flag) {
|
||||
const requested = mockCatalogs(t);
|
||||
const saved = process.env.CCTV_LITHUANIA_ENABLED;
|
||||
try {
|
||||
if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = saved;
|
||||
}
|
||||
}
|
||||
|
||||
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
|
||||
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'lithuania-72')?.cityId,
|
||||
'lithuania',
|
||||
);
|
||||
});
|
||||
|
||||
// The camera endpoints are the eismoinfo.lt website backend, not a service the
|
||||
// open-data statement lists, so an install that has not opted in must never
|
||||
// contact them.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_LITHUANIA_ENABLED', undefined],
|
||||
['an empty CCTV_LITHUANIA_ENABLED', ''],
|
||||
['CCTV_LITHUANIA_ENABLED=0', '0'],
|
||||
['CCTV_LITHUANIA_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no Lithuania request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('eismoinfo.lt')),
|
||||
false,
|
||||
'the pack never reaches its upstream',
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let siblingSignal;
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
siblingSignal = options.signal;
|
||||
// Never settles on its own; only an abort ends it.
|
||||
return new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () =>
|
||||
reject(options.signal.reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.equal(siblingSignal?.aborted, true);
|
||||
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
|
||||
});
|
||||
|
||||
test('a redirected catalog is reported as not followed', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
|
||||
});
|
||||
|
||||
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const makeResponse of [
|
||||
() => new Response('', { status: 503 }),
|
||||
() =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
() => new Response('invalid json'),
|
||||
() => Response.json({ unexpected: true }),
|
||||
() =>
|
||||
new Response('[]', {
|
||||
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
|
||||
}),
|
||||
]) {
|
||||
t.mock.method(globalThis, 'fetch', async () => makeResponse());
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
}
|
||||
});
|
||||
@@ -38,6 +38,10 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
// Measured empirically (2026-09-15): polling the same SCT/Terrassa frame
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -641,6 +641,10 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// /api/cctv/media is the live-feed relay. A still camera used to be relayed
|
||||
// through it as well, by an ordinary redirect-following fetch with no content
|
||||
// type check and no body cap, which side-stepped everything /api/cctv/frame
|
||||
// enforces for the same URL. These pin that a still is refused there and only
|
||||
// reachable through /frame, while live feeds keep their relay.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const STILL = {
|
||||
id: 'still-fixture',
|
||||
name: 'Still fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'image',
|
||||
url: 'https://cams.upstream.invalid/still.jpg',
|
||||
};
|
||||
// 'jpg' normalizes to 'image'; the alias must not reopen the relay.
|
||||
const STILL_ALIAS = {
|
||||
...STILL,
|
||||
id: 'still-alias',
|
||||
feedType: 'jpg',
|
||||
url: 'https://cams.upstream.invalid/alias.jpg',
|
||||
};
|
||||
const VIDEO = {
|
||||
id: 'video-fixture',
|
||||
name: 'Video fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://cams.upstream.invalid/live.mp4',
|
||||
};
|
||||
const MJPEG = {
|
||||
id: 'mjpeg-fixture',
|
||||
name: 'MJPEG fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'mjpeg',
|
||||
url: 'https://cams.upstream.invalid/live.mjpg',
|
||||
};
|
||||
|
||||
/** A minimal upstream Response with a streaming body. */
|
||||
function upstreamResponse({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const store = new Map(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
|
||||
body: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects what a handler wrote, the way a Node ServerResponse would. */
|
||||
function recordingResponse() {
|
||||
const chunks = [];
|
||||
let finished;
|
||||
const done = new Promise((resolve) => {
|
||||
finished = resolve;
|
||||
});
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
writableEnded: false,
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
res.writableEnded = true;
|
||||
res.body = Buffer.concat(chunks).toString('utf8');
|
||||
finished();
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
emit() {},
|
||||
destroy() {},
|
||||
};
|
||||
return { res, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the real provider over the fixture cameras and answer every upstream
|
||||
* request from the supplied function.
|
||||
*/
|
||||
function mount(t, respond) {
|
||||
const before = {
|
||||
json: process.env.CCTV_SOURCES_JSON,
|
||||
file: process.env.CCTV_SOURCES_FILE,
|
||||
austin: process.env.CCTV_FORCE_AUSTIN,
|
||||
serverKey: process.env.GOOGLE_MAPS_SERVER_API_KEY,
|
||||
browserKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
};
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify([
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
process.env.CCTV_FORCE_AUSTIN = '0';
|
||||
// No Street View key: a /frame miss lands on the synthetic billboard rather
|
||||
// than a network call this file does not model.
|
||||
delete process.env.GOOGLE_MAPS_SERVER_API_KEY;
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
const nativeFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url: String(url), redirect: init.redirect });
|
||||
return respond({ url: String(url), init });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch;
|
||||
for (const [name, value] of [
|
||||
['CCTV_SOURCES_JSON', before.json],
|
||||
['CCTV_SOURCES_FILE', before.file],
|
||||
['CCTV_FORCE_AUSTIN', before.austin],
|
||||
['GOOGLE_MAPS_SERVER_API_KEY', before.serverKey],
|
||||
['GOOGLE_MAPS_API_KEY', before.browserKey],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let handler = null;
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use: (_route, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
const health = async (id) => {
|
||||
const res = await call('/health');
|
||||
return JSON.parse(res.body).cameras.find((entry) => entry.id === id);
|
||||
};
|
||||
return { call, health, requests };
|
||||
}
|
||||
|
||||
test('a still camera is refused by /media without any upstream request', async (t) => {
|
||||
// Everything the old relay would have done with this camera: redirect to a
|
||||
// private address, then answer with HTML.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.includes('169.254.169.254')
|
||||
? upstreamResponse({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: '<html>metadata</html>',
|
||||
})
|
||||
: upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
}),
|
||||
);
|
||||
for (const camera of [STILL, STILL_ALIAS]) {
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 404, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'No live media for this camera; use the frame endpoint',
|
||||
});
|
||||
assert.doesNotMatch(res.body, /metadata/);
|
||||
// Being asked on the wrong route is not a camera fault.
|
||||
assert.equal(await app.health(camera.id), undefined);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a still camera reached its upstream');
|
||||
});
|
||||
|
||||
test('an unregistered camera id is refused by /media the same way', async (t) => {
|
||||
const app = mount(t, () => {
|
||||
throw new Error('an unknown camera must not fetch');
|
||||
});
|
||||
const res = await app.call('/media/not-a-camera');
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.equal(app.requests.length, 0);
|
||||
});
|
||||
|
||||
test('a video camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4', 'content-length': '5' },
|
||||
body: 'video',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'video/mp4');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'live-media');
|
||||
assert.equal(res.body, 'video');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[VIDEO.url],
|
||||
);
|
||||
assert.equal((await app.health(VIDEO.id)).status, 'ok');
|
||||
});
|
||||
|
||||
test('an MJPEG camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'multipart/x-mixed-replace; boundary=frame' },
|
||||
body: '--frame',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${MJPEG.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers['Content-Type'], /^multipart\/x-mixed-replace/);
|
||||
assert.equal(res.body, '--frame');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[MJPEG.url],
|
||||
);
|
||||
});
|
||||
|
||||
test('a still camera is still served through /frame with its bounded fetch', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'jpeg-bytes',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(res.body, 'jpeg-bytes');
|
||||
assert.deepEqual(app.requests, [{ url: STILL.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
// The protection /media used to bypass, shown on the route that keeps it.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === STILL.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'private',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.doesNotMatch(res.body, /private/);
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
isLikelyCataloniaCoordinate,
|
||||
toFiniteNumber,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
loadCaltransSourcesFromOpenData,
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTflSourcesFromOpenData,
|
||||
parseCataloniaXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.js';
|
||||
import { createModel } from '../layers/cctv/model.js';
|
||||
|
||||
/** Values `Number` reads as 0 (or 1) although they carry no number. */
|
||||
const ABSENT = [null, undefined, '', ' ', false, true];
|
||||
|
||||
/** Set env vars for one test and restore them afterwards. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** The production browser model; only the catalog's pose pass is stubbed. */
|
||||
function browserModel() {
|
||||
const parts = {};
|
||||
parts.model = createModel({
|
||||
state: {},
|
||||
services: { focus: {} },
|
||||
parts,
|
||||
source: {},
|
||||
});
|
||||
return parts.model;
|
||||
}
|
||||
|
||||
/** The browser catalog over the production model. */
|
||||
function browserCatalog(model, cityPois = {}) {
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: cityPois } },
|
||||
parts: { model: { ...model, ensureCameraPose: () => {} } },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/cctv/sources through the real proxy middleware. */
|
||||
async function requestSources(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url: '/sources', method: 'GET' }, res);
|
||||
assert.equal(res.status, 200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
|
||||
const { safeNumber } = browserModel();
|
||||
for (const value of ABSENT) {
|
||||
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
|
||||
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
|
||||
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
|
||||
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
|
||||
}
|
||||
for (const [value, expected] of [
|
||||
[0, 0],
|
||||
['0', 0],
|
||||
[-17, -17],
|
||||
[' 12.5 ', 12.5],
|
||||
]) {
|
||||
assert.equal(toFiniteNumber(value, 99), expected);
|
||||
assert.equal(safeNumber(value, 99), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
|
||||
quiet(t);
|
||||
const pose = {
|
||||
headingDeg: null,
|
||||
pitchDeg: null,
|
||||
fovDeg: null,
|
||||
rangeM: null,
|
||||
mountHeightM: null,
|
||||
groundElevationM: null,
|
||||
};
|
||||
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
|
||||
CCTV_SOURCES_JSON: JSON.stringify([
|
||||
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
|
||||
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
|
||||
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
|
||||
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
|
||||
]),
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
|
||||
const model = browserModel();
|
||||
const cameras = browserCatalog(model, {
|
||||
austin: { name: 'Austin', groundElevation: 150 },
|
||||
}).buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
|
||||
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
|
||||
const camera = byId.get(id);
|
||||
assert.ok(camera, id);
|
||||
assert.equal(camera.headingDeg, model.headingFromId(id), id);
|
||||
assert.equal(camera.pitchDeg, -17, id);
|
||||
assert.equal(camera.fovDeg, 74, id);
|
||||
assert.equal(camera.rangeM, 700, id);
|
||||
assert.equal(camera.mountHeightM, 24, id);
|
||||
assert.equal(camera.groundElevationM, 150, id);
|
||||
}
|
||||
// No position is not a camera in the Gulf of Guinea.
|
||||
assert.equal(byId.has('pack-coords-null'), false);
|
||||
assert.equal(byId.has('pack-coords-blank'), false);
|
||||
});
|
||||
|
||||
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, 'tallinn.json');
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'tln-null-heading',
|
||||
lat: 59.437,
|
||||
lon: 24.753,
|
||||
headingDeg: null,
|
||||
groundElevationM: null,
|
||||
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
|
||||
},
|
||||
]),
|
||||
);
|
||||
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
|
||||
|
||||
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
|
||||
assert.equal(camera.groundElevationM, 15);
|
||||
});
|
||||
|
||||
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
|
||||
quiet(t);
|
||||
const jamCam = (id, lat, lon) => ({
|
||||
id: `JamCams_${id}`,
|
||||
commonName: `JamCam ${id}`,
|
||||
lat,
|
||||
lon,
|
||||
additionalProperties: [
|
||||
{ key: 'available', value: 'true' },
|
||||
{
|
||||
key: 'imageUrl',
|
||||
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
|
||||
},
|
||||
],
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json([
|
||||
jamCam('00001.00001', 51.5072, -0.1276),
|
||||
jamCam('00001.00002', null, null),
|
||||
jamCam('00001.00003', 51.5072, ''),
|
||||
]),
|
||||
);
|
||||
|
||||
const cameras = await loadTflSourcesFromOpenData();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['tfl-00001.00001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
|
||||
const feature = (coordinates) => `<gml:featureMember>
|
||||
<cite:cameres fid="cameres.1">
|
||||
<gml:coordinates>${coordinates}</gml:coordinates>
|
||||
<cite:link>https://example.org/cam.jpg</cite:link>
|
||||
</cite:cameres>
|
||||
</gml:featureMember>`;
|
||||
const [blank, valid] = parseCataloniaXml(
|
||||
feature(',41.5') + feature('2.17,41.39'),
|
||||
);
|
||||
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
|
||||
assert.ok(Number.isNaN(blank.lon));
|
||||
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
|
||||
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
|
||||
});
|
||||
|
||||
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
cctv: {
|
||||
inService: 'true',
|
||||
location: {
|
||||
latitude: '37.8044',
|
||||
longitude: '-122.2712',
|
||||
elevation: '',
|
||||
locationName: 'TV101 -- I-880 : Test Ave',
|
||||
},
|
||||
imageData: {
|
||||
static: {
|
||||
currentImageURL:
|
||||
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [camera] = await loadCaltransSourcesFromOpenData();
|
||||
assert.ok(camera);
|
||||
assert.equal(camera.groundElevationM, 150);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseRescuCameraList,
|
||||
torontoCameraToSource,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('j'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One RESCU row, shaped like the live `tmcearthcameras.json` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
Number: '8048',
|
||||
Name: 'BATHURST ST & BLOOR ST W',
|
||||
Latitude: '43.665249',
|
||||
Longitude: '-79.41114',
|
||||
D1: 'n',
|
||||
D2: 'e',
|
||||
D3: 's',
|
||||
D4: 'w',
|
||||
Group: 'Arterial',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** The city wraps the list in a JSONP callback; this is that envelope. */
|
||||
const jsonp = (rows) =>
|
||||
`jsonTMCEarthCamerasCallback(${JSON.stringify({ Data: rows })});`;
|
||||
|
||||
test('a RESCU row maps to a source on the constructed frame URL', () => {
|
||||
const source = torontoCameraToSource(row());
|
||||
assert.equal(source.id, 'toronto-8048');
|
||||
assert.equal(source.name, 'BATHURST ST & BLOOR ST W');
|
||||
assert.equal(source.city, 'Toronto');
|
||||
assert.equal(source.cityId, 'toronto');
|
||||
assert.equal(source.provider, 'City of Toronto');
|
||||
assert.equal(source.lat, 43.665249);
|
||||
assert.equal(source.lon, -79.41114);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'toronto-rescu-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Contains information licensed under the Open Government Licence – Toronto',
|
||||
);
|
||||
assert.equal(
|
||||
source.url,
|
||||
`${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`,
|
||||
'the documented loc####.jpg convention',
|
||||
);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(TORONTO_RESCU_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('the frame URL is built from the camera number, never read from the payload', () => {
|
||||
// The list carries no URL field at all, so an added one must stay inert: the
|
||||
// frame proxy is pinned to the city origin by construction.
|
||||
const planted = torontoCameraToSource(
|
||||
row({
|
||||
url: 'https://evil.example/x.jpg',
|
||||
snapshotUrl: 'https://evil.example/x.jpg',
|
||||
Url: 'https://evil.example/x.jpg',
|
||||
}),
|
||||
);
|
||||
assert.equal(planted.url, `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`);
|
||||
assert.equal(planted.snapshotUrl, planted.url);
|
||||
|
||||
// A number that is not plain digits cannot reach the URL builder: path
|
||||
// traversal and absolute URLs are refused outright rather than escaped.
|
||||
for (const Number_ of [
|
||||
'../../etc/passwd',
|
||||
'8048/../../x',
|
||||
'https://evil.example/1',
|
||||
'80 48',
|
||||
'8048.jpg',
|
||||
'-8048',
|
||||
'',
|
||||
' ',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Number: Number_ })),
|
||||
null,
|
||||
`refused: ${String(Number_)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the comparison directions never become a camera heading', () => {
|
||||
// D1-D4 are the city's *comparison image* directions (static 2016 reference
|
||||
// stills served from ComparisonImages/), not a facing for the live frame:
|
||||
// these cameras pan. The letters read like a compass and map trivially onto
|
||||
// one (n -> 0, w -> 270), so the temptation to wire them up is real — and a
|
||||
// heading derived from them would be confidently wrong for every camera in
|
||||
// the city. Nobody may "fix" this.
|
||||
const base = torontoCameraToSource(row());
|
||||
for (const dirs of [
|
||||
{ D1: 'n', D2: 'e', D3: 's', D4: 'w' },
|
||||
{ D1: '', D2: 'e', D3: '', D4: 'w' },
|
||||
{ D1: '', D2: '', D3: '', D4: '' },
|
||||
{ D1: 's', D2: '', D3: '', D4: '' },
|
||||
]) {
|
||||
const source = torontoCameraToSource(row(dirs));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
// Same id, same fallback heading, whatever the directions say.
|
||||
assert.equal(source.headingDeg, base.headingDeg);
|
||||
}
|
||||
});
|
||||
|
||||
test('the two cameras at one intersection are kept apart', () => {
|
||||
// 8047 and 8048 are both "BATHURST ST & BLOOR ST W", five metres apart. They
|
||||
// are distinct units, so both survive, and the id-hash fallback fans their
|
||||
// gizmos apart instead of stacking them on one bearing.
|
||||
const a = torontoCameraToSource(
|
||||
row({ Number: '8047', Latitude: '43.665223', Longitude: '-79.411083' }),
|
||||
);
|
||||
const b = torontoCameraToSource(row());
|
||||
assert.equal(a.id, 'toronto-8047');
|
||||
assert.equal(b.id, 'toronto-8048');
|
||||
assert.notEqual(a.url, b.url);
|
||||
assert.notEqual(a.headingDeg, b.headingDeg);
|
||||
});
|
||||
|
||||
test('rows outside Toronto and unusable geometry are dropped', () => {
|
||||
// Calgary: a plausible lat/lon, but not a Toronto camera.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '51.0453', Longitude: '-114.1413' })),
|
||||
null,
|
||||
);
|
||||
// Null island, and the empty strings that would become it.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '0', Longitude: '0' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '', Longitude: '' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: 'north', Longitude: 'west' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(torontoCameraToSource({}), null);
|
||||
assert.equal(torontoCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
const source = torontoCameraToSource(row({ Name: ' ' }));
|
||||
assert.equal(source.name, 'Toronto Camera 8048');
|
||||
});
|
||||
|
||||
test('the JSONP envelope is unwrapped, and only that envelope', () => {
|
||||
assert.deepEqual(parseRescuCameraList(jsonp([row()])), [row()]);
|
||||
// Whitespace and a missing trailing semicolon are both tolerated.
|
||||
assert.equal(
|
||||
parseRescuCameraList(`\n ${jsonp([row()]).replace(/;$/, '')} \n`).length,
|
||||
1,
|
||||
);
|
||||
// Should the city ever drop the wrapper, the bare document still parses.
|
||||
assert.equal(
|
||||
parseRescuCameraList(JSON.stringify({ Data: [row()] })).length,
|
||||
1,
|
||||
);
|
||||
|
||||
// Anything else yields no cameras rather than a thrown loader.
|
||||
for (const text of [
|
||||
'otherCallback({"Data":[]})',
|
||||
'jsonTMCEarthCamerasCallback(',
|
||||
'jsonTMCEarthCamerasCallback({"Data":',
|
||||
'{"Data":"not-an-array"}',
|
||||
'{"nope":[]}',
|
||||
'<html>503</html>',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.deepEqual(parseRescuCameraList(text), [], `rejected: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the loader reads the keyless list and collapses duplicate numbers', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
jsonp([
|
||||
row(),
|
||||
// Same camera number twice: one camera, not two.
|
||||
row({ Name: 'BATHURST ST & BLOOR ST W (duplicate)' }),
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row({ Number: '../evil' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
const cameras = await loadTorontoSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [TORONTO_RESCU_LIST_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest downtown first: York & Bremner is at the core, Bathurst & Bloor
|
||||
// is three kilometres out.
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
);
|
||||
assert.equal(cameras[1].name, 'BATHURST ST & BLOOR ST W');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A 503 can still arrive with a streaming body; returning without cancelling
|
||||
// it would hold the connection until the socket times out.
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the list fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered. Its body is
|
||||
// a live stream, so the test also proves the refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[TORONTO_RESCU_LIST_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// A body over the cap is refused rather than buffered.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(jsonp([row()]), {
|
||||
headers: {
|
||||
'content-length': String(TORONTO_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only declares its size once it is already too long.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(TORONTO_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(torontoCameraToSource(row()).code, 'BATHURST ST & BLOOR ST W');
|
||||
const long = torontoCameraToSource(
|
||||
row({
|
||||
Name: 'BAYVIEW AVE & POTTERY RD / PRIVATE ACCESS @ 620m SOUTH OF - 550 BAYVIEW AVE (EVERGREEN)',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BAYVIEW AVE & POTTERY'));
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping Toronto', () => {
|
||||
// Toronto merges last in LIVE_PACKS, so a positional slice would delete it
|
||||
// outright. The round-robin allocation must give it its share.
|
||||
const lane = (name, count) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
|
||||
});
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[lane('austin', 250), lane('calgary', 215), lane('toronto', 336)],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['toronto', 10],
|
||||
],
|
||||
);
|
||||
// Toronto contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('toronto-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `toronto-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the RESCU list to the Toronto endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === TORONTO_RESCU_LIST_URL) {
|
||||
return new Response(
|
||||
jsonp([
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the pack is registered, so its cameras reach the served catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_TORONTO_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
'the registered lane reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto').map((s) => s.id),
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
'Toronto cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_TORONTO_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_WSDOT_MAX_SOURCES,
|
||||
WSDOT_CAMERAS_URL,
|
||||
WSDOT_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadWsdotSourcesFromOpenData,
|
||||
webMercatorToWgs84,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */
|
||||
function mercator(lat, lon) {
|
||||
const R = 6378137;
|
||||
return {
|
||||
x: (lon * Math.PI * R) / 180,
|
||||
y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A `Cameras.json` point feature carrying the fields the loader reads. */
|
||||
function wsdotFeature(id, lat, lon, overrides = {}) {
|
||||
const { attributes = {}, geometry } = overrides;
|
||||
return {
|
||||
attributes: {
|
||||
CameraID: id,
|
||||
CameraTitle: `Camera ${id}`,
|
||||
CompassDirection: 'N',
|
||||
ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`,
|
||||
...attributes,
|
||||
},
|
||||
geometry: geometry === undefined ? mercator(lat, lon) : geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */
|
||||
function cp1252Response(payload) {
|
||||
const text = JSON.stringify(payload);
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
// U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships.
|
||||
bytes[i] = code === 0x2013 ? 0x96 : code;
|
||||
}
|
||||
return new Response(bytes, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => {
|
||||
// Real feature from the live layer: I-5 at the Interstate Bridge.
|
||||
const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545);
|
||||
assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`);
|
||||
assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`);
|
||||
});
|
||||
|
||||
test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return cp1252Response({
|
||||
features: [
|
||||
wsdotFeature(1001, 47.6097, -122.3331, {
|
||||
attributes: {
|
||||
// The 0x2013 below is serialized as the feed's 0x96 byte.
|
||||
CameraTitle: 'I-5 – Seattle, looking south',
|
||||
CompassDirection: 'S',
|
||||
},
|
||||
}),
|
||||
wsdotFeature(9059, 47.66, -117.43, {
|
||||
attributes: { CompassDirection: 'B' },
|
||||
}),
|
||||
// Partner-hosted frame (Oregon DOT): skipped, never proxied.
|
||||
wsdotFeature(1002, 45.62, -122.675, {
|
||||
attributes: {
|
||||
ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg',
|
||||
},
|
||||
}),
|
||||
// Bad rows: non-integer id, missing geometry, out-of-state point.
|
||||
wsdotFeature('1003', 47.6, -122.3),
|
||||
wsdotFeature(1004, 47.6, -122.3, { geometry: null }),
|
||||
wsdotFeature(1005, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [WSDOT_CAMERAS_URL]);
|
||||
// Exactly the two valid official-host cameras survive; the partner-hosted
|
||||
// frame and the malformed rows are dropped.
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'wsdot-1001',
|
||||
'wsdot-9059',
|
||||
]);
|
||||
|
||||
const seattle = cameras.find((camera) => camera.id === 'wsdot-1001');
|
||||
assert.equal(seattle.name, 'I-5 – Seattle, looking south');
|
||||
assert.equal(seattle.provider, 'WSDOT');
|
||||
assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg');
|
||||
assert.equal(seattle.snapshotUrl, seattle.url);
|
||||
assert.equal(seattle.feedType, 'image');
|
||||
assert.equal(seattle.headingDeg, 180);
|
||||
assert.equal(seattle.headingConfidence, 'high');
|
||||
assert.equal(seattle.fovDeg, 56);
|
||||
assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001);
|
||||
assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001);
|
||||
|
||||
// `B` (both directions) carries no single facing: id-hash fallback at low
|
||||
// confidence, with the low-confidence pose personality.
|
||||
const spokane = cameras.find((camera) => camera.id === 'wsdot-9059');
|
||||
assert.equal(spokane.headingConfidence, 'low');
|
||||
assert.equal(spokane.fovDeg, 44);
|
||||
assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360);
|
||||
});
|
||||
|
||||
test('WSDOT loader maps every CompassDirection code', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const codes = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
// Lower-case and padded codes are the same code.
|
||||
' nw ': 315,
|
||||
};
|
||||
// The live feed's no-facing codes, plus an unknown one.
|
||||
const noFacing = ['B', 'O', null, 'BW'];
|
||||
const entries = [...Object.keys(codes), ...noFacing];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
cp1252Response({
|
||||
features: entries.map((code, i) =>
|
||||
wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, {
|
||||
attributes: { CompassDirection: code },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
entries.forEach((code, i) => {
|
||||
const camera = byId.get(`wsdot-${3000 + i}`);
|
||||
if (code in codes) {
|
||||
assert.equal(camera.headingDeg, codes[code], `heading for ${code}`);
|
||||
assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 56, `pose personality for ${code}`);
|
||||
} else {
|
||||
assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 44, `pose personality for ${code}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('WSDOT loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05));
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features }));
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('WSDOT loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a redirect and cancels its body', async (t) => {
|
||||
quiet(t);
|
||||
// A redirect is never followed, and its still-open body is cancelled.
|
||||
const seen = [];
|
||||
const state = { cancelled: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/Cameras.json' },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[WSDOT_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(state.cancelled, true, 'the redirect body is cancelled');
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a body over the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
// A valid catalog padded past the cap is refused rather than buffered.
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const text = JSON.stringify({
|
||||
features: [wsdotFeature(1001, 47.6097, -122.3331)],
|
||||
});
|
||||
const padded = text + ' '.repeat(WSDOT_MAX_CATALOG_BYTES);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(padded));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV_WSDOT_ENABLED=0 skips the WSDOT request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_WSDOT_ENABLED: undefined,
|
||||
CCTV_WSDOT_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only WSDOT answers.
|
||||
return String(url) === WSDOT_CAMERAS_URL
|
||||
? cp1252Response({ features: [wsdotFeature(1001, 47.6097, -122.3331)] })
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['wsdot-1001'],
|
||||
);
|
||||
|
||||
process.env.CCTV_WSDOT_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.includes(WSDOT_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
@@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat
|
||||
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
|
||||
});
|
||||
|
||||
test('advisories published before their nominal time are accepted, far-future ones are not', () => {
|
||||
// NHC wrote the 03:00Z advisory at 02:34Z.
|
||||
const early = Date.UTC(2026, 8, 16, 2, 34);
|
||||
assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
|
||||
/invalid_cyclone_data/,
|
||||
);
|
||||
});
|
||||
|
||||
test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
|
||||
const MINUTE = 60_000;
|
||||
const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
|
||||
const fields = {
|
||||
issuedAt: (value) =>
|
||||
status({
|
||||
forecastAdvisory: {
|
||||
advNum: '010',
|
||||
issuance: value,
|
||||
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
},
|
||||
}),
|
||||
positionAt: (value) => status({ lastUpdate: value }),
|
||||
};
|
||||
for (const [field, payload] of Object.entries(fields)) {
|
||||
for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
|
||||
const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
|
||||
assert.equal(storm[field], at(offset), `${field} ${offset}`);
|
||||
}
|
||||
for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(payload(at(offset)), NOW),
|
||||
/invalid_cyclone_data/,
|
||||
`${field} ${offset}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
|
||||
const storms = parseCycloneStatus(status(), NOW);
|
||||
const parts = collections();
|
||||
|
||||
+44
-1
@@ -248,12 +248,38 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'lithuania-cctv',
|
||||
html:
|
||||
// Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
|
||||
// among the services Via Lietuva's open-data statement lists (its
|
||||
// conditions: attribution, reuse on the same terms).
|
||||
'Road-camera stills (Lithuania, when enabled): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Catalonia): ' +
|
||||
'<a href="https://transit.gencat.cat/" target="_blank" rel="noopener">Servei Català de Trànsit</a> ' +
|
||||
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
|
||||
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
|
||||
},
|
||||
{
|
||||
key: 'kingcounty-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (King County, WA): King County Road Services — ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/local-services/roads" target="_blank" rel="noopener">kingcounty.gov</a>, ' +
|
||||
'per the ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
|
||||
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
@@ -268,11 +294,18 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'toronto-cctv',
|
||||
html:
|
||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority)',
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
@@ -371,6 +404,16 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
|
||||
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'wsdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Washington State): ' +
|
||||
'<a href="https://wsdot.wa.gov/" target="_blank" rel="noopener">Washington State Department of Transportation</a> ' +
|
||||
'(<a href="https://wsdot.com/travel/real-time/cameras" target="_blank" rel="noopener">Travel Center</a>), ' +
|
||||
'used per the ' +
|
||||
'<a href="https://wsdot.wa.gov/about/policies/travel-information-disclaimer" target="_blank" rel="noopener">WSDOT travel information disclaimer</a> ' +
|
||||
'(low-volume public feed; catalog cached, frames proxied on demand)',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* Formats a speed in knots as a short rounded label in the UI language.
|
||||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||
* vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
|
||||
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
|
||||
}
|
||||
});
|
||||
|
||||
test('formatKnots writes the unit in the UI language', async () => {
|
||||
const { setLocale } = await import('../i18n/index.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(formatKnots(9.5), '10 уз');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
assert.equal(formatKnots(9.5), '10 kn');
|
||||
});
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
createApplicationDirectionsLayer,
|
||||
} from './directions.js';
|
||||
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
|
||||
import { instructionFor } from './routeSteps.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
directionsInstruction,
|
||||
routeDistanceText,
|
||||
routeDurationText,
|
||||
} from '../layers/directions/index.js';
|
||||
import {
|
||||
claimPointer,
|
||||
isPointerFree,
|
||||
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
|
||||
lastUpdate: 5,
|
||||
});
|
||||
assert.equal(ready.count, 3);
|
||||
assert.equal(ready.coverage, '21 km · 25 min · Drive');
|
||||
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
|
||||
assert.equal(
|
||||
ready.loadingLabel,
|
||||
'21 km · 25 min · Drive',
|
||||
'21\u00a0km · 25\u00a0min · Drive',
|
||||
'the summary is the row detail line',
|
||||
);
|
||||
assert.equal(ready.loading, undefined);
|
||||
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
|
||||
const copy = directionsStepCopy(route.steps, 1);
|
||||
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
||||
assert.deepEqual(copy.details, [
|
||||
'Step 2 of 3 · then 240 m · 39 s',
|
||||
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
|
||||
'Then: Arrive at your destination',
|
||||
]);
|
||||
const last = directionsStepCopy(route.steps, 2);
|
||||
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.items.map((item) => item.lead),
|
||||
['8 m', '240 m', '0 m'],
|
||||
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.items.map((item) => item.params),
|
||||
@@ -733,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
|
||||
pointerBlockedMessage('measure', 'a'),
|
||||
'measure is active — turn measure off, then set A',
|
||||
);
|
||||
// A registered owner without an exit is named by its tool, not its id.
|
||||
assert.equal(
|
||||
pointerBlockedMessage('recent-imagery-box', 'a'),
|
||||
'Recent imagery is active — turn Recent imagery off, then set A',
|
||||
);
|
||||
// Nothing to say when nothing is in the way, or when we are the holder.
|
||||
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
||||
assert.equal(pointerBlockedMessage('', 'a'), null);
|
||||
@@ -773,3 +785,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
|
||||
assert.equal(toasts.length, 1, 'a successful arming is silent');
|
||||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||||
});
|
||||
|
||||
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
|
||||
const types = [
|
||||
'depart',
|
||||
'arrive',
|
||||
'turn',
|
||||
'new name',
|
||||
'continue',
|
||||
'end of road',
|
||||
'fork',
|
||||
'merge',
|
||||
'on ramp',
|
||||
'off ramp',
|
||||
'roundabout',
|
||||
'rotary',
|
||||
'roundabout turn',
|
||||
'exit roundabout',
|
||||
'exit rotary',
|
||||
'use lane',
|
||||
'notification',
|
||||
'something new',
|
||||
];
|
||||
const modifiers = [
|
||||
null,
|
||||
'left',
|
||||
'right',
|
||||
'slight left',
|
||||
'slight right',
|
||||
'sharp left',
|
||||
'sharp right',
|
||||
'straight',
|
||||
'uturn',
|
||||
];
|
||||
const roads = [
|
||||
{ name: '', ref: null },
|
||||
{ name: 'Congress Avenue', ref: null },
|
||||
{ name: 'Mannerheimintie', ref: 'E12' },
|
||||
{ name: '', ref: 'I 35' },
|
||||
];
|
||||
for (const type of types)
|
||||
for (const modifier of modifiers)
|
||||
for (const road of roads)
|
||||
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
|
||||
const step = { type, modifier, exit, ...road };
|
||||
assert.equal(
|
||||
directionsInstruction(step),
|
||||
instructionFor(step),
|
||||
JSON.stringify(step),
|
||||
);
|
||||
}
|
||||
// An older payload without a maneuver type keeps the proxy's sentence.
|
||||
assert.equal(
|
||||
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
|
||||
'Head out on Kaivokatu',
|
||||
);
|
||||
});
|
||||
|
||||
test('route numbers follow the page language and never break from their unit', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||
assert.equal(routeDistanceText(-1), '');
|
||||
assert.equal(routeDurationText(Number.NaN), '');
|
||||
|
||||
setLocale('ru');
|
||||
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
|
||||
assert.equal(routeDurationText(240), '4\u00a0мин');
|
||||
assert.equal(
|
||||
directionsInstruction({
|
||||
type: 'turn',
|
||||
modifier: 'right',
|
||||
name: 'East 7th Street',
|
||||
}),
|
||||
'Поверните направо на East 7th Street',
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
|
||||
'На кольце сверните на 2-й съезд',
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
});
|
||||
@@ -726,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
|
||||
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
|
||||
/** Seed one civil contact through the production refresh seam. */
|
||||
function seedLabelContact({ callsign, registration, tracked = false }) {
|
||||
function seedLabelContact({
|
||||
callsign,
|
||||
registration,
|
||||
tracked = false,
|
||||
// Per-test metadata overrides, spread last so a test can set a field to
|
||||
// `undefined` to model "the feed never sent it" — a default parameter
|
||||
// could not express that, it would just re-supply the default.
|
||||
meta: metaOverrides = {},
|
||||
}) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: LABEL_ICAO,
|
||||
entity: null,
|
||||
@@ -752,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.21,
|
||||
rawLon: -97.71,
|
||||
...metaOverrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -825,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
|
||||
});
|
||||
|
||||
test('the tracked descriptor publishes the reported vertical rate', () => {
|
||||
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
|
||||
// seam did not, so a consumer that needs climb/descent (a flight-phase
|
||||
// classifier, the cockpit) had to reach past getTrackedInfo() into the
|
||||
// raw record. Both seams now answer with the same field name and unit.
|
||||
seedLabelContact({
|
||||
callsign: 'SWA696',
|
||||
registration: 'N123AB',
|
||||
tracked: true,
|
||||
meta: { verticalRate: -6.4 },
|
||||
});
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
|
||||
assert.equal(
|
||||
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
|
||||
-6.4,
|
||||
'the analyst seam and the tracked seam must agree',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unreported vertical rate stays null, never a level-flight zero', () => {
|
||||
// Absent is not level. `stickyNumber` seeds this field with null, so a
|
||||
// contact that has never transmitted a rate must not read as 0 m/s — a
|
||||
// phase classifier would call that cruise while the aircraft climbs.
|
||||
for (const missing of [undefined, null, Number.NaN]) {
|
||||
seedLabelContact({
|
||||
callsign: 'SWA696',
|
||||
registration: 'N123AB',
|
||||
tracked: true,
|
||||
meta: { verticalRate: missing },
|
||||
});
|
||||
assert.equal(
|
||||
flightsLayer.getTrackedInfo()?.verticalRateMps,
|
||||
null,
|
||||
`vertical rate ${String(missing)} must publish as null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('civil label chain: identity stays icao24 while the label moves', () => {
|
||||
for (const fixture of [
|
||||
{ callsign: 'SWA696', registration: 'N123AB' },
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// GBFS PROXY — the frequency the allowlist never bounded.
|
||||
//
|
||||
// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a
|
||||
// station_information/station_status path, a 12 s timeout, a 5 MB body cap and
|
||||
// no redirects. Nothing said HOW OFTEN, so the dev server would relay an
|
||||
// unbounded stream of requests at a third-party bikeshare operator on any
|
||||
// caller's behalf — one outbound fetch and one timeout timer each.
|
||||
//
|
||||
// These cases drive the real mounted middleware. `fetch` is stubbed per test
|
||||
// and restored afterwards, so a case that reaches the network fails loudly,
|
||||
// and the stub's call count is what proves a refused request costs nothing.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js';
|
||||
|
||||
const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
|
||||
const STATION_BODY = '{"data":{"stations":[]}}';
|
||||
|
||||
/** Mount the proxy the way the dev server does; returns its route handler. */
|
||||
function mount(options) {
|
||||
const routes = new Map();
|
||||
gbfsProxy(options).configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return routes.get('/api/gbfs');
|
||||
}
|
||||
|
||||
function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = {
|
||||
method: method || 'GET',
|
||||
// connect strips the mount prefix before the handler sees the URL.
|
||||
url: '/' + encodeURIComponent(target),
|
||||
headers: {},
|
||||
socket: remoteAddress ? { remoteAddress } : undefined,
|
||||
};
|
||||
let status = 200;
|
||||
let headers = {};
|
||||
const res = {
|
||||
writeHead(code, sent) {
|
||||
status = code;
|
||||
headers = sent || {};
|
||||
return res;
|
||||
},
|
||||
end(body = '') {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = body ? JSON.parse(String(body)) : null;
|
||||
} catch {
|
||||
parsed = String(body);
|
||||
}
|
||||
resolve({ status, headers, body: parsed });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Answer every allowlisted upstream, counting the calls that got through. */
|
||||
function stubUpstream(t) {
|
||||
const asked = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
asked.push(String(url));
|
||||
return new Response(STATION_BODY, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
return asked;
|
||||
}
|
||||
|
||||
test('a maximal real session is never throttled', async (t) => {
|
||||
const asked = stubUpstream(t);
|
||||
const handler = mount();
|
||||
|
||||
// The bikeshare layer catalogues 15 systems and refreshes each one's status
|
||||
// once every 60 s, so a minute of the busiest possible session is ~15
|
||||
// requests. Run eight times that and expect every one of them served.
|
||||
let served = 0;
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
const res = await request(handler);
|
||||
if (res.status === 200) served += 1;
|
||||
}
|
||||
assert.equal(served, 120, 'the shipped ceiling leaves a real session alone');
|
||||
assert.equal(asked.length, 120);
|
||||
assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on');
|
||||
});
|
||||
|
||||
test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => {
|
||||
const asked = stubUpstream(t);
|
||||
const handler = mount({ ratePerMinute: 5 });
|
||||
|
||||
const responses = [];
|
||||
for (let i = 0; i < 12; i += 1) responses.push(await request(handler));
|
||||
|
||||
const served = responses.filter((r) => r.status === 200);
|
||||
const refused = responses.filter((r) => r.status === 429);
|
||||
assert.equal(served.length, 5, 'exactly the admitted budget is served');
|
||||
assert.equal(refused.length, 7);
|
||||
assert.equal(refused[0].body.error, 'Rate limit exceeded');
|
||||
assert.equal(refused[0].headers['Retry-After'], '10');
|
||||
assert.equal(refused[0].headers['Cache-Control'], 'no-store');
|
||||
assert.equal(
|
||||
asked.length,
|
||||
5,
|
||||
'a refused request never reaches the GBFS operator',
|
||||
);
|
||||
});
|
||||
|
||||
test('a different client keeps its own budget', async (t) => {
|
||||
stubUpstream(t);
|
||||
const handler = mount({ ratePerMinute: 3 });
|
||||
|
||||
for (let i = 0; i < 6; i += 1)
|
||||
await request(handler, { remoteAddress: '10.0.0.1' });
|
||||
const noisy = await request(handler, { remoteAddress: '10.0.0.1' });
|
||||
assert.equal(noisy.status, 429, 'the noisy client spent its own budget');
|
||||
|
||||
const quiet = await request(handler, { remoteAddress: '10.0.0.2' });
|
||||
assert.equal(quiet.status, 200, 'a quiet client is not collateral');
|
||||
});
|
||||
|
||||
test('the limiter does not displace the existing guards', async (t) => {
|
||||
const asked = stubUpstream(t);
|
||||
const handler = mount({ ratePerMinute: 10 });
|
||||
|
||||
const wrongMethod = await request(handler, { method: 'POST' });
|
||||
assert.equal(wrongMethod.status, 405);
|
||||
|
||||
const offAllowlist = await request(handler, {
|
||||
target: 'https://example.com/gbfs/station_status.json',
|
||||
});
|
||||
assert.equal(offAllowlist.status, 403);
|
||||
assert.equal(offAllowlist.body.error, 'GBFS host not allowed');
|
||||
|
||||
const wrongPath = await request(handler, {
|
||||
target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json',
|
||||
});
|
||||
assert.equal(wrongPath.status, 400);
|
||||
|
||||
assert.equal(asked.length, 0, 'none of those reached upstream');
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([
|
||||
'austin.publicbikesystem.net',
|
||||
'hon.publicbikesystem.net',
|
||||
'chat.publicbikesystem.net',
|
||||
'gbfs.nextbike.net',
|
||||
'gbfs.urbansharing.com',
|
||||
'api.entur.io',
|
||||
'api.cyclocity.fr',
|
||||
|
||||
+94
-36
@@ -2,6 +2,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createImportImageBudget } from './geoImportImages.js';
|
||||
import { createImportedPins } from './geoImportPins.js';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/** Largest file accepted, in bytes. */
|
||||
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
@@ -61,11 +62,17 @@ const KML_REMOVED_ELEMENTS = new Set([
|
||||
'httpQuery',
|
||||
]);
|
||||
|
||||
/** An error whose message is meant for the person who chose the file. */
|
||||
/**
|
||||
* An error whose message is meant for the person who chose the file. The
|
||||
* message stays English; `template` and `values`, when given, let the UI show
|
||||
* it in the active language.
|
||||
*/
|
||||
export class GeoImportError extends Error {
|
||||
constructor(message) {
|
||||
constructor(message, template = message, values = {}) {
|
||||
super(message);
|
||||
this.name = 'GeoImportError';
|
||||
this.template = template;
|
||||
this.values = values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +131,14 @@ export function parseGeoJsonText(text) {
|
||||
try {
|
||||
value = JSON.parse(String(text).replace(/^/, ''));
|
||||
} catch {
|
||||
throw new GeoImportError('That file is not valid JSON.');
|
||||
throw new GeoImportError(msg('That file is not valid JSON.'));
|
||||
}
|
||||
if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type))
|
||||
throw new GeoImportError('That JSON file is not GeoJSON.');
|
||||
throw new GeoImportError(msg('That JSON file is not GeoJSON.'));
|
||||
if (value.type === 'FeatureCollection' && !Array.isArray(value.features))
|
||||
throw new GeoImportError('That FeatureCollection has no features list.');
|
||||
throw new GeoImportError(
|
||||
msg('That FeatureCollection has no features list.'),
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -164,7 +173,7 @@ export function countGeoJsonGeometry(
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i]?.type !== 'Feature')
|
||||
throw new GeoImportError(
|
||||
'Every GeoJSON feature must have type Feature.',
|
||||
msg('Every GeoJSON feature must have type Feature.'),
|
||||
);
|
||||
stack.push(list[i]);
|
||||
}
|
||||
@@ -183,11 +192,11 @@ export function countGeoJsonGeometry(
|
||||
if (node.type === 'GeometryCollection') {
|
||||
const list = node.geometries;
|
||||
if (!Array.isArray(list))
|
||||
throw new GeoImportError('That GeoJSON has invalid geometries.');
|
||||
throw new GeoImportError(msg('That GeoJSON has invalid geometries.'));
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
|
||||
throw new GeoImportError(
|
||||
'That GeoJSON contains an unsupported geometry.',
|
||||
msg('That GeoJSON contains an unsupported geometry.'),
|
||||
);
|
||||
stack.push(list[i]);
|
||||
}
|
||||
@@ -216,7 +225,9 @@ export function countGeoJsonGeometry(
|
||||
while (coords.length) {
|
||||
const [c, remaining] = coords.pop();
|
||||
if (!Array.isArray(c))
|
||||
throw new GeoImportError('That GeoJSON has invalid coordinates.');
|
||||
throw new GeoImportError(
|
||||
msg('That GeoJSON has invalid coordinates.'),
|
||||
);
|
||||
if (remaining === 0) {
|
||||
if (
|
||||
c.length < 2 ||
|
||||
@@ -226,7 +237,9 @@ export function countGeoJsonGeometry(
|
||||
Math.abs(c[1]) > 90 ||
|
||||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
|
||||
)
|
||||
throw new GeoImportError('That GeoJSON has invalid coordinates.');
|
||||
throw new GeoImportError(
|
||||
msg('That GeoJSON has invalid coordinates.'),
|
||||
);
|
||||
positions += 1;
|
||||
if (positions > maxPositions) return result('positions');
|
||||
} else
|
||||
@@ -234,7 +247,7 @@ export function countGeoJsonGeometry(
|
||||
}
|
||||
} else
|
||||
throw new GeoImportError(
|
||||
'That GeoJSON contains an unsupported geometry.',
|
||||
msg('That GeoJSON contains an unsupported geometry.'),
|
||||
);
|
||||
}
|
||||
return result();
|
||||
@@ -245,17 +258,36 @@ function overLimitError({ over, features }) {
|
||||
if (over === 'entities')
|
||||
return new GeoImportError(
|
||||
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
|
||||
msg('That file creates more than {limit} map objects (the limit).'),
|
||||
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
|
||||
);
|
||||
if (over === 'links')
|
||||
return new GeoImportError(
|
||||
`That file has more than ${MAX_KML_LINKS.toLocaleString()} style definitions and image links after cleanup (the limit).`,
|
||||
msg(
|
||||
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
|
||||
),
|
||||
{ limit: MAX_KML_LINKS.toLocaleString() },
|
||||
);
|
||||
if (over === 'features')
|
||||
return new GeoImportError(
|
||||
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
|
||||
msg('That file has more than {limit} features (the limit).'),
|
||||
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
|
||||
);
|
||||
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
|
||||
if (features)
|
||||
return new GeoImportError(
|
||||
`That file has more than ${limit} points (the limit) across ${features.toLocaleString()} features.`,
|
||||
msg(
|
||||
'That file has more than {limit} points (the limit) across {features} features.',
|
||||
),
|
||||
{ limit, features: features.toLocaleString() },
|
||||
);
|
||||
return new GeoImportError(
|
||||
`That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
|
||||
`That file has more than ${limit} points (the limit).`,
|
||||
msg('That file has more than {limit} points (the limit).'),
|
||||
{ limit },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,14 +334,16 @@ async function inflateRaw(bytes, maxBytes) {
|
||||
total += value.length;
|
||||
if (total > maxBytes)
|
||||
throw new GeoImportError(
|
||||
'That KMZ unpacks to more than the import limit.',
|
||||
msg('That KMZ unpacks to more than the import limit.'),
|
||||
);
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => {});
|
||||
if (error instanceof GeoImportError) throw error;
|
||||
throw new GeoImportError('That KMZ is damaged and could not be unpacked.');
|
||||
throw new GeoImportError(
|
||||
msg('That KMZ is damaged and could not be unpacked.'),
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
@@ -332,7 +366,7 @@ export async function readZipEntries(
|
||||
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
|
||||
) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const fail = (message = 'That KMZ is not a readable zip archive.') => {
|
||||
const fail = (message = msg('That KMZ is not a readable zip archive.')) => {
|
||||
throw new GeoImportError(message);
|
||||
};
|
||||
// End of central directory: 22 bytes plus a comment of up to 64 KiB.
|
||||
@@ -352,7 +386,7 @@ export async function readZipEntries(
|
||||
const dirSize = view.getUint32(eocd + 12, true);
|
||||
let at = view.getUint32(eocd + 16, true);
|
||||
if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd)
|
||||
fail('That KMZ uses a zip format this import does not read.');
|
||||
fail(msg('That KMZ uses a zip format this import does not read.'));
|
||||
const entries = new Map();
|
||||
const names = new TextDecoder();
|
||||
let inflated = 0;
|
||||
@@ -370,9 +404,9 @@ export async function readZipEntries(
|
||||
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
|
||||
at += 46 + nameLength + extraLength + commentLength;
|
||||
if (name.endsWith('/')) continue;
|
||||
if (flags & 0x1) fail('That KMZ is encrypted.');
|
||||
if (flags & 0x1) fail(msg('That KMZ is encrypted.'));
|
||||
if (compressed === 0xffffffff || size === 0xffffffff)
|
||||
fail('That KMZ uses a zip format this import does not read.');
|
||||
fail(msg('That KMZ uses a zip format this import does not read.'));
|
||||
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
|
||||
fail();
|
||||
const start =
|
||||
@@ -386,10 +420,13 @@ export async function readZipEntries(
|
||||
if (method === 0) contents = data;
|
||||
else if (method === 8)
|
||||
contents = await inflateRaw(data, maxInflatedBytes - inflated);
|
||||
else fail('That KMZ uses a compression method this import does not read.');
|
||||
else
|
||||
fail(
|
||||
msg('That KMZ uses a compression method this import does not read.'),
|
||||
);
|
||||
inflated += contents.length;
|
||||
if (inflated > maxInflatedBytes)
|
||||
fail('That KMZ unpacks to more than the import limit.');
|
||||
fail(msg('That KMZ unpacks to more than the import limit.'));
|
||||
entries.set(name, contents);
|
||||
}
|
||||
return entries;
|
||||
@@ -765,7 +802,7 @@ export function countKmlGeometry(
|
||||
!Number.isFinite(value) ||
|
||||
Math.abs(value) > limit
|
||||
)
|
||||
throw new GeoImportError('That KML has invalid geometry bounds.');
|
||||
throw new GeoImportError(msg('That KML has invalid geometry bounds.'));
|
||||
}
|
||||
// Cesium creates entities for folders, a parent for every placemark, and
|
||||
// a child for each geometry in a MultiGeometry, including nested containers.
|
||||
@@ -822,7 +859,7 @@ function checkKmlPosition(tuple) {
|
||||
Math.abs(numbers[1]) > 90 ||
|
||||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
|
||||
)
|
||||
throw new GeoImportError('That KML has invalid coordinates.');
|
||||
throw new GeoImportError(msg('That KML has invalid coordinates.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -843,7 +880,7 @@ export function gpxToGeoJson(
|
||||
) {
|
||||
const root = doc.documentElement;
|
||||
if (root?.localName !== 'gpx')
|
||||
throw new GeoImportError('That GPX file has no <gpx> document.');
|
||||
throw new GeoImportError(msg('That GPX file has no <gpx> document.'));
|
||||
const features = [];
|
||||
let positions = 0;
|
||||
const point = (el) => {
|
||||
@@ -1028,7 +1065,11 @@ function parseXml(text, what) {
|
||||
'application/xml',
|
||||
);
|
||||
if (doc.getElementsByTagName('parsererror').length)
|
||||
throw new GeoImportError(`That ${what} file is not valid XML.`);
|
||||
throw new GeoImportError(
|
||||
`That ${what} file is not valid XML.`,
|
||||
msg('That {format} file is not valid XML.'),
|
||||
{ format: what },
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -1066,7 +1107,9 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
|
||||
}
|
||||
} catch {
|
||||
throw new GeoImportError(
|
||||
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
|
||||
msg(
|
||||
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1086,7 +1129,7 @@ async function loadKml(doc, viewer, signal) {
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
const failed = () => {
|
||||
reject(new GeoImportError('That KML could not be read.'));
|
||||
reject(new GeoImportError(msg('That KML could not be read.')));
|
||||
};
|
||||
globalThis.addEventListener?.('error', failed);
|
||||
removeError = () => globalThis.removeEventListener?.('error', failed);
|
||||
@@ -1116,7 +1159,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
|
||||
if (!position) continue;
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
||||
if (!cartographic)
|
||||
throw new GeoImportError('That file has invalid point coordinates.');
|
||||
throw new GeoImportError(
|
||||
msg('That file has invalid point coordinates.'),
|
||||
);
|
||||
pins.push({
|
||||
entity,
|
||||
original: position,
|
||||
@@ -1192,7 +1237,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
|
||||
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
|
||||
if (!c)
|
||||
throw new GeoImportError(
|
||||
'Ground heights are not available for that file. Try again when the map has loaded.',
|
||||
msg(
|
||||
'Ground heights are not available for that file. Try again when the map has loaded.',
|
||||
),
|
||||
);
|
||||
item.groundHeight = c.height;
|
||||
c.height += item.offset;
|
||||
@@ -1216,7 +1263,8 @@ async function boundedImport(promise, signal) {
|
||||
abort = () => reject(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
timer = setTimeout(
|
||||
() => reject(new GeoImportError('That file took too long to read.')),
|
||||
() =>
|
||||
reject(new GeoImportError(msg('That file took too long to read.'))),
|
||||
60_000,
|
||||
);
|
||||
}),
|
||||
@@ -1244,16 +1292,18 @@ export async function loadGeoFile(
|
||||
: AbortSignal.timeout(60_000);
|
||||
signal?.throwIfAborted();
|
||||
if (!file || !Number.isFinite(file.size))
|
||||
throw new GeoImportError('Nothing to import.');
|
||||
throw new GeoImportError(msg('Nothing to import.'));
|
||||
if (file.size > MAX_IMPORT_BYTES)
|
||||
throw new GeoImportError(
|
||||
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MiB.`,
|
||||
msg('That file is larger than {size} MiB.'),
|
||||
{ size: MAX_IMPORT_BYTES / 1024 / 1024 },
|
||||
);
|
||||
let bytes = new Uint8Array(await boundedImport(file.arrayBuffer(), signal));
|
||||
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
|
||||
if (!format)
|
||||
throw new GeoImportError(
|
||||
'Only GeoJSON, KML, KMZ and GPX files can be imported.',
|
||||
msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'),
|
||||
);
|
||||
const stroke = Cesium.Color.fromCssColorString(color);
|
||||
const urls = [];
|
||||
@@ -1276,7 +1326,7 @@ export async function loadGeoFile(
|
||||
const size = countGeoJsonGeometry(geojson);
|
||||
if (size.over) throw overLimitError(size);
|
||||
if (!size.positions)
|
||||
throw new GeoImportError('That file has nothing to draw.');
|
||||
throw new GeoImportError(msg('That file has nothing to draw.'));
|
||||
prepareGeoJson(geojson);
|
||||
bytes = null;
|
||||
dataSource = await boundedImport(
|
||||
@@ -1299,7 +1349,8 @@ export async function loadGeoFile(
|
||||
if (format === 'kmz') {
|
||||
entries = await readZipEntries(bytes);
|
||||
main = kmzMainDocument(entries);
|
||||
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
|
||||
if (!main)
|
||||
throw new GeoImportError(msg('That KMZ holds no KML document.'));
|
||||
text = decodeXml(entries.get(main));
|
||||
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
|
||||
} else {
|
||||
@@ -1325,6 +1376,8 @@ export async function loadGeoFile(
|
||||
if (error instanceof GeoImportError) throw error;
|
||||
throw new GeoImportError(
|
||||
`That ${format.toUpperCase()} file could not be read.`,
|
||||
msg('That {format} file could not be read.'),
|
||||
{ format: format.toUpperCase() },
|
||||
);
|
||||
}
|
||||
const entityCount = dataSource.entities.values.length;
|
||||
@@ -1332,6 +1385,11 @@ export async function loadGeoFile(
|
||||
revoke();
|
||||
throw new GeoImportError(
|
||||
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
|
||||
msg('That file has {count} map objects; the limit is {limit}.'),
|
||||
{
|
||||
count: entityCount.toLocaleString(),
|
||||
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
const now = Cesium.JulianDate.now();
|
||||
@@ -1346,7 +1404,7 @@ export async function loadGeoFile(
|
||||
);
|
||||
if (!drawable) {
|
||||
revoke();
|
||||
throw new GeoImportError('That file has nothing to draw.');
|
||||
throw new GeoImportError(msg('That file has nothing to draw.'));
|
||||
}
|
||||
scrubImportedEntities(dataSource);
|
||||
try {
|
||||
@@ -1356,7 +1414,7 @@ export async function loadGeoFile(
|
||||
throw error instanceof GeoImportError
|
||||
? error
|
||||
: new GeoImportError(
|
||||
'Ground heights could not be read. Try that file again.',
|
||||
msg('Ground heights could not be read. Try that file again.'),
|
||||
);
|
||||
}
|
||||
// Keep the clock invariant explicit even though unused KML dates have
|
||||
@@ -1366,7 +1424,7 @@ export async function loadGeoFile(
|
||||
pins = createImportedPins(dataSource, viewer);
|
||||
} catch {
|
||||
revoke();
|
||||
throw new GeoImportError('That file has invalid point graphics.');
|
||||
throw new GeoImportError(msg('That file has invalid point graphics.'));
|
||||
}
|
||||
// The display name travels beside the data source (the file name, not
|
||||
// anything the document says about itself).
|
||||
|
||||
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
|
||||
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
|
||||
});
|
||||
|
||||
// --- Antimeridian ------------------------------------------------------------
|
||||
// Raw longitude arithmetic walks a segment across 180° the long way round and
|
||||
// reads a contact on the seam as half a world from its previous cell.
|
||||
|
||||
test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
|
||||
for (const [from, to, across, behind] of [
|
||||
[179.999, -179.998, -179.999, 179.998],
|
||||
[-179.999, 179.998, 179.999, -179.998],
|
||||
]) {
|
||||
const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
|
||||
const lons = cells.map((c) => c.lon);
|
||||
assert.equal(lons[0], from);
|
||||
assert.equal(lons.at(-1), to);
|
||||
assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
|
||||
assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
|
||||
assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('stickyFloorCell holds across the antimeridian', () => {
|
||||
for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
|
||||
const prev = { lat: -16.69, lon: prevLon };
|
||||
assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
|
||||
}
|
||||
});
|
||||
|
||||
// --- F2: corridor budget fairness ------------------------------------------
|
||||
// The first cut spent the budget in Map insertion order and decremented it
|
||||
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
/** Explain mapped-site availability without claiming an unobserved overload. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const reasons = {
|
||||
rate_limited: 'Overpass rate-limited',
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
||||
names_unavailable: 'Mapped names temporarily unavailable',
|
||||
};
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
// layers/installations/controls.js translates describeInstallationFeedback().
|
||||
const REASONS = Object.freeze({
|
||||
rate_limited: msg('Overpass rate-limited'),
|
||||
timeout: msg('Overpass timed out'),
|
||||
query_failed: msg('Overpass could not complete the query'),
|
||||
tiles_unavailable: msg('Map tiles temporarily unavailable'),
|
||||
names_unavailable: msg('Mapped names temporarily unavailable'),
|
||||
});
|
||||
const FALLBACK_REASON = msg('Overpass temporarily unavailable');
|
||||
|
||||
/**
|
||||
* Mapped-site availability as a message to render, without claiming an
|
||||
* unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is
|
||||
* English (`{reason}` / `{seconds}` placeholders, `reason` English too), or
|
||||
* `{ count, km }` for a found-sites count (`km` is null for the view).
|
||||
* @param {object} [stats]
|
||||
* @param {number} [now]
|
||||
* @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}}
|
||||
*/
|
||||
export function describeInstallationFeedback(stats = {}, now = Date.now()) {
|
||||
const reason = REASONS[stats.failureReason] || FALLBACK_REASON;
|
||||
if (stats.loading)
|
||||
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
return {
|
||||
text: stats.retrying
|
||||
? msg('Retrying mapped sites…')
|
||||
: msg('Fetching mapped sites…'),
|
||||
};
|
||||
if (stats.retryAt > 0) {
|
||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
||||
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
|
||||
return seconds
|
||||
? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds }
|
||||
: { text: msg('{reason} — retry pending'), reason };
|
||||
}
|
||||
if (stats.status === 'unavailable') return reason;
|
||||
if (stats.status === 'unavailable') return { text: reason };
|
||||
if (stats.status === 'zoom-in')
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
return { text: msg('Zoom in to search mapped installations') };
|
||||
if (stats.stale) return { text: msg('Showing cached mapped sites') };
|
||||
if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') };
|
||||
// With a count, say what was found and where; an empty area is not "loaded".
|
||||
if (Number.isFinite(stats.count)) {
|
||||
const km =
|
||||
@@ -27,9 +45,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
Number.isFinite(stats.coverage.radiusM)
|
||||
? Math.round(stats.coverage.radiusM / 1000)
|
||||
: null;
|
||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
||||
if (stats.count === 0) return `No mapped sites${where}`;
|
||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
||||
return { count: stats.count, km: km || null };
|
||||
}
|
||||
return 'Mapped sites loaded';
|
||||
return { text: msg('Mapped sites loaded') };
|
||||
}
|
||||
|
||||
/** Explain mapped-site availability in English. */
|
||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
const feedback = describeInstallationFeedback(stats, now);
|
||||
if (feedback.text !== undefined)
|
||||
return feedback.text
|
||||
.replace('{reason}', feedback.reason)
|
||||
.replace('{seconds}', feedback.seconds);
|
||||
const where = feedback.km
|
||||
? ` within ${feedback.km} km of the contact`
|
||||
: ' in view';
|
||||
if (feedback.count === 0) return `No mapped sites${where}`;
|
||||
return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* English names for installations without a mapped name. Data keeps them
|
||||
* English (server, AI tools, voice context); classLabel.js and
|
||||
* militaryAwarenessEngine.js translate them.
|
||||
*/
|
||||
|
||||
/** Name of an unnamed feature, by installation class. */
|
||||
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
|
||||
airfield: msg('Military airfield'),
|
||||
naval_base: msg('Naval base'),
|
||||
range: msg('Firing range'),
|
||||
military_land: msg('Military land'),
|
||||
});
|
||||
|
||||
/** Name of an unnamed feature without a usable class. */
|
||||
export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation');
|
||||
|
||||
/** Name of a military polygon the names pack has not named. */
|
||||
export const UNNAMED_MILITARY_AREA = msg('Military area');
|
||||
|
||||
/** Every name above, for the display to translate. */
|
||||
export const UNNAMED_PLACE_NAMES = Object.freeze([
|
||||
...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS),
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_MILITARY_AREA,
|
||||
]);
|
||||
@@ -1,3 +1,6 @@
|
||||
// Restore labels are marked with msg(); ui/shareRestoration.js translates them.
|
||||
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
|
||||
const msg = (text) => text;
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
@@ -473,19 +476,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedFlightsTrackingId',
|
||||
expiryWindowMs: 90_000,
|
||||
label: 'flight',
|
||||
label: msg('flight'),
|
||||
}),
|
||||
military: Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedMilitaryTrackingId',
|
||||
expiryWindowMs: 45_000,
|
||||
label: 'military flight',
|
||||
label: msg('military flight'),
|
||||
}),
|
||||
satellites: Object.freeze({
|
||||
optionOwner: 'satellites',
|
||||
optionKey: 'selectedSatTrackingId',
|
||||
expiryWindowMs: 300_000,
|
||||
label: 'satellite',
|
||||
label: msg('satellite'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
|
||||
setMeshFloorPreferred(true);
|
||||
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
|
||||
});
|
||||
|
||||
test('sampler: a cell just across the antimeridian is still near the viewer', () => {
|
||||
reset();
|
||||
let calls = 0;
|
||||
const scene = fakeScene(() => { calls += 1; return undefined; });
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
|
||||
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
|
||||
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
|
||||
});
|
||||
+1669
-573
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,10 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
|
||||
export const AWARENESS_RADIUS_M = 250000;
|
||||
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
||||
export const AWARENESS_RELATIONSHIP = Object.freeze({
|
||||
@@ -47,7 +52,11 @@ export function formatAwarenessDistance(meters) {
|
||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
||||
}
|
||||
|
||||
/** Return a stable visible label without leaking missing values into UI text. */
|
||||
/**
|
||||
* Return a stable visible label without leaking missing values into UI text.
|
||||
* The English name the data gives an unnamed installation ("Military area")
|
||||
* is shown in the UI language; mapped names and callsigns show as they are.
|
||||
*/
|
||||
export function formatAwarenessLabel(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
for (const candidate of [
|
||||
@@ -63,7 +72,11 @@ export function formatAwarenessLabel(value) {
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'string') return value.trim() || '—';
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return '—';
|
||||
return UNNAMED_NAMES.has(text) ? t(text) : text;
|
||||
}
|
||||
if (Number.isFinite(value)) return String(value);
|
||||
return '—';
|
||||
}
|
||||
@@ -82,14 +95,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed unavailable',
|
||||
reason: t('feed unavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: 'feed stale',
|
||||
reason: t('feed stale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -106,8 +119,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? 'observed or mapped nearby context'
|
||||
: 'no observed or mapped objects in current feeds',
|
||||
? t('observed or mapped nearby context')
|
||||
: t('no observed or mapped objects in current feeds'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -834,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
||||
'and the same query resolves the same way every time',
|
||||
);
|
||||
});
|
||||
|
||||
test('the military tracked descriptor publishes the reported vertical rate', () => {
|
||||
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
|
||||
// the rate under different record keys (`verticalRateMps` here,
|
||||
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
|
||||
// shape if BOTH descriptors map it — a consumer that reads the field off the
|
||||
// civil seam and finds it missing on the military one would silently treat
|
||||
// every military contact as level flight.
|
||||
const seedRate = (verticalRateMps) =>
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'ae7f01',
|
||||
entity: { gevLabelModel: { title: 'TEST', details: [] } },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: true,
|
||||
},
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: { camera: { positionCartographic: null }, scene: {} },
|
||||
meta: {
|
||||
callsign: 'RCH512',
|
||||
rawLat: 31,
|
||||
rawLon: -97,
|
||||
altitudeFt: 26_000,
|
||||
speedMps: 220,
|
||||
track: 95,
|
||||
onGround: false,
|
||||
verticalRateMps,
|
||||
},
|
||||
});
|
||||
|
||||
seedRate(12.7);
|
||||
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
|
||||
|
||||
// Absent is not level: a contact that has never transmitted a rate must read
|
||||
// as null, not as a 0 m/s a phase classifier would call cruise.
|
||||
for (const missing of [undefined, null, Number.NaN]) {
|
||||
seedRate(missing);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
|
||||
null,
|
||||
`vertical rate ${String(missing)} must publish as null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
UNNAMED_INSTALLATION_FALLBACK,
|
||||
UNNAMED_INSTALLATION_LABEL_BY_CLASS,
|
||||
} from './installationNames.js';
|
||||
|
||||
/**
|
||||
* Normalize the deliberately small, allow-listed installation context returned
|
||||
* by `/api/military-installations`. These are mapped features, not assertions
|
||||
@@ -22,12 +27,10 @@ const CLASS_BY_MILITARY_TAG = {
|
||||
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
|
||||
* attribution and the details panel read it.
|
||||
*/
|
||||
const LABEL_BY_CLASS = {
|
||||
airfield: 'Military airfield',
|
||||
naval_base: 'Naval base',
|
||||
range: 'Firing range',
|
||||
military_land: 'Military land',
|
||||
};
|
||||
// The labels stay English in data (the server provider and AI tools read the
|
||||
// records too); the map card and Contacts translate them (installationNames.js).
|
||||
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
|
||||
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
|
||||
|
||||
const MAX_FOOTPRINT_POINTS = 400;
|
||||
|
||||
@@ -44,7 +47,7 @@ export function humanizeInstallationClass(klass) {
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
: UNNAMED_FALLBACK;
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on
|
||||
* screen; callers translate the English name with translateKnown().
|
||||
*/
|
||||
|
||||
const POINTER_OWNER_NAMES = Object.freeze({
|
||||
draw: msg('Draw'),
|
||||
directions: msg('Directions'),
|
||||
'recent-imagery-box': msg('Recent imagery'),
|
||||
});
|
||||
|
||||
/**
|
||||
* English display name of a pointer owner. An owner without a registered name
|
||||
* falls back to its id.
|
||||
* @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pointerOwnerName(owner) {
|
||||
const id = typeof owner === 'string' ? owner.trim() : '';
|
||||
if (!id) return '';
|
||||
return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { createModel } from '../layers/launches/model.js';
|
||||
import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
_setSelectedRocketMissionForTest,
|
||||
@@ -676,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
|
||||
assert.equal(launches[0].lon, -120.611);
|
||||
});
|
||||
|
||||
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
for (const blank of [null, '', ' ', false]) {
|
||||
assert.deepEqual(
|
||||
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('null pad coordinates use the location fallback instead of 0,0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
|
||||
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
|
||||
assert.equal(fallback.lat, 28.6);
|
||||
assert.equal(fallback.lon, -80.6);
|
||||
});
|
||||
|
||||
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
|
||||
rocket: { launcher_stage: [{ launcher_flight_number: null,
|
||||
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
|
||||
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
|
||||
const [stage] = launch.recoveryStages;
|
||||
assert.equal(stage.lat, null);
|
||||
assert.equal(stage.lon, null);
|
||||
assert.equal(stage.downrangeKm, null);
|
||||
assert.equal(stage.flightNumber, null);
|
||||
});
|
||||
|
||||
test('normalizes detailed payload and stage recovery records', () => {
|
||||
const launches = normalizeRocketLaunches({ results: [{
|
||||
id: 'recovery-details',
|
||||
@@ -728,6 +760,126 @@ test('normalizes detailed payload and stage recovery records', () => {
|
||||
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
|
||||
});
|
||||
|
||||
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
|
||||
const model = createModel({});
|
||||
assert.equal(model.finiteCoordinate(42.5), 42.5);
|
||||
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
|
||||
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
|
||||
assert.equal(model.finiteCoordinate(0), 0);
|
||||
assert.equal(model.finiteCoordinate('0'), 0);
|
||||
assert.equal(model.finiteCoordinate(undefined), null);
|
||||
assert.equal(model.finiteCoordinate('invalid'), null);
|
||||
assert.equal(model.finiteCoordinate('TBD'), null);
|
||||
assert.equal(model.finiteCoordinate(Number.NaN), null);
|
||||
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
|
||||
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
|
||||
});
|
||||
|
||||
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
|
||||
const launches = normalizeRocketLaunches(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
id: 'recovery-fallbacks',
|
||||
name: 'Falcon 9 | Edge Case Stage Normalization',
|
||||
net: '2026-07-20T10:00:00Z',
|
||||
pad: { latitude: '28.608', longitude: '-80.604' },
|
||||
rocket: {
|
||||
launcher_stage: [
|
||||
{
|
||||
id: 88,
|
||||
type: { name: 'Booster' },
|
||||
launcher_flight_number: 'invalid-flight',
|
||||
landing: {
|
||||
attempt: true,
|
||||
success: false,
|
||||
downrange_distance: 'TBD',
|
||||
landing_location: {
|
||||
name: 'Ocean Impact',
|
||||
latitude: 'N/A',
|
||||
longitude: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 89,
|
||||
type: { name: 'Expended Stage' },
|
||||
landing: {
|
||||
attempt: false,
|
||||
type: { name: 'Expendable' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(launches[0].recoveryStages.length, 2);
|
||||
|
||||
const booster = launches[0].recoveryStages[0];
|
||||
assert.equal(booster.status, 'LOST');
|
||||
assert.equal(booster.success, false);
|
||||
assert.equal(booster.attempted, true);
|
||||
assert.equal(booster.flightNumber, null);
|
||||
assert.equal(booster.downrangeKm, null);
|
||||
assert.equal(booster.lat, null);
|
||||
assert.equal(booster.lon, null);
|
||||
|
||||
const expendable = launches[0].recoveryStages[1];
|
||||
assert.equal(expendable.status, 'EXPENDABLE');
|
||||
assert.equal(expendable.attempted, false);
|
||||
assert.equal(expendable.success, null);
|
||||
});
|
||||
|
||||
test('normalizes payload amounts and fractional string masses', () => {
|
||||
const launches = normalizeRocketLaunches(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
id: 'payload-edge-cases',
|
||||
name: 'Electron | Mixed Payloads',
|
||||
net: '2026-07-20T10:00:00Z',
|
||||
pad: { latitude: '-39.26', longitude: '177.86' },
|
||||
rocket: {
|
||||
payloads: [
|
||||
{
|
||||
id: 601,
|
||||
amount: 'not-a-number',
|
||||
payload: { id: 11, name: 'Unknown Amount Payload' },
|
||||
},
|
||||
{
|
||||
id: 602,
|
||||
amount: 3,
|
||||
payload: { id: 12, name: 'Triple Payload' },
|
||||
},
|
||||
{
|
||||
id: 603,
|
||||
payload: {
|
||||
id: 13,
|
||||
name: 'Valid Fractional Mass Payload',
|
||||
mass: '12.75',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
// Blank, negative and non-numeric masses are pinned by "missing payload
|
||||
// details stay unknown without inventing mass or classification".
|
||||
const payloads = launches[0].payloads;
|
||||
assert.equal(payloads.length, 3);
|
||||
assert.equal(payloads[0].amount, 1);
|
||||
assert.equal(payloads[1].amount, 3);
|
||||
assert.equal(payloads[2].massKg, 12.75);
|
||||
});
|
||||
|
||||
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
|
||||
const launches = normalizeRocketLaunches({ results: [{
|
||||
id: 'undisclosed',
|
||||
|
||||
+42
-18
@@ -17,54 +17,46 @@
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Class registry. `label` is the card/legend token, `color` the point color,
|
||||
* `blurb` the plain-language gloss for the legend tooltip.
|
||||
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>}
|
||||
* Class registry: the point color of each class. The card and legend text is
|
||||
* in CLASS_DISPLAY_KEYS.
|
||||
* @type {Readonly<Record<string, { color: string }>>}
|
||||
*/
|
||||
export const SATELLITE_CLASSES = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: 'STATION',
|
||||
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
||||
// NOT amber: 40-48deg amber is the app's known-military convention
|
||||
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
||||
// leak into another domain. Slightly warm so it also separates from the
|
||||
// pure white the civilian air fleet draws in.
|
||||
color: '#fff6e5',
|
||||
blurb: 'Crewed stations and their visiting vehicles',
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: 'NAV',
|
||||
// Cyan — the strongest accent goes to the class users actually name
|
||||
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
||||
// VISUAL group used to occupy, so this adds no new hue to the app.
|
||||
color: '#4fd8ff',
|
||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: 'GEO',
|
||||
// Violet, unchanged. Already the app's "space" semantic (the detection
|
||||
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
||||
// as one clean equatorial ring at globe scale.
|
||||
color: '#c89bff',
|
||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: 'VISUAL',
|
||||
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
|
||||
// cyan it used to hold and recedes behind the three classes that mean
|
||||
// something. Still well clear of the dense shell by luminance.
|
||||
color: '#9fb3c4',
|
||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: 'COMMS',
|
||||
// Dim slate — thousands of these appear in DENSE mode. They must read as
|
||||
// texture behind the core catalog, never compete with it. Rec.601 luma
|
||||
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
||||
// NVG/FLIR shaders collapse the scene to a single channel.
|
||||
color: '#54697f',
|
||||
blurb: 'Broadband constellation shell — shown only in DENSE mode',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -77,6 +69,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
'comms',
|
||||
]);
|
||||
|
||||
// What the card and legend show for each class.
|
||||
const CLASS_DISPLAY_KEYS = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: msg('STATION'),
|
||||
blurb: msg('Crewed stations and their visiting vehicles'),
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: msg('NAV'),
|
||||
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: msg('GEO'),
|
||||
blurb: msg(
|
||||
'Geostationary belt — comms and weather, fixed over the equator',
|
||||
),
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: msg('VISUAL'),
|
||||
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: msg('COMMS'),
|
||||
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
|
||||
@@ -134,12 +152,17 @@ export function satelliteClassColor(group) {
|
||||
* The ISS is always a STATION regardless of which group ingested it — see
|
||||
* satelliteClassOf, which owns that rule for every consumer.
|
||||
* @param {string|undefined|null} group CelesTrak group tag.
|
||||
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
|
||||
* @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
|
||||
* localize: false keeps the English label (data for the AI tools).
|
||||
* @returns {string} Display label.
|
||||
*/
|
||||
export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
export function satelliteClassLabel(
|
||||
group,
|
||||
{ isIss = false, localize = true } = {},
|
||||
) {
|
||||
const { klass, subtype } = satelliteClassOf(group, { isIss });
|
||||
const base = SATELLITE_CLASSES[klass].label;
|
||||
const label = CLASS_DISPLAY_KEYS[klass].label;
|
||||
const base = localize ? t(label) : label;
|
||||
return subtype ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
|
||||
@@ -180,11 +203,12 @@ export function satelliteClassLegend(counts) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
const display = CLASS_DISPLAY_KEYS[klass];
|
||||
result.push({
|
||||
klass,
|
||||
label: spec.label,
|
||||
label: t(display.label),
|
||||
color: spec.color,
|
||||
blurb: spec.blurb,
|
||||
blurb: t(display.blurb),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
import satellitesLayer from './satellites.js';
|
||||
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
||||
|
||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
test('partial and dense catalogs do not make an early false missing decision', async () => {
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
@@ -32,31 +34,45 @@ test('partial and dense catalogs do not make an early false missing decision', a
|
||||
assert.match(partial.reason, /partial CelesTrak catalog/);
|
||||
|
||||
let releaseDense;
|
||||
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
|
||||
const densePromise = new Promise((resolve) => {
|
||||
releaseDense = resolve;
|
||||
});
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'dense',
|
||||
densePromise,
|
||||
});
|
||||
let settled = false;
|
||||
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => { settled = true; return result; });
|
||||
const resolution = satellitesLayer
|
||||
.resolveTrackingRestoreTarget(987654)
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
|
||||
releaseDense({ status: 'ready' });
|
||||
assert.equal((await resolution).status, 'missing');
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' });
|
||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
catalog: 'core',
|
||||
});
|
||||
});
|
||||
|
||||
test('satellite first update aborts every parallel catalog request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
const observedSignals = [];
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignals.push(options.signal);
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = satellitesLayer.update({}, { signal: controller.signal });
|
||||
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
|
||||
assert.ok(observedSignals.length > 1);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
|
||||
assert.equal(
|
||||
observedSignals.every((signal) => signal?.aborted),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
point,
|
||||
});
|
||||
try {
|
||||
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
|
||||
assert.doesNotThrow(() =>
|
||||
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
|
||||
);
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
|
||||
|
||||
@@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999);
|
||||
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_applyPendingSatelliteTrackingRestoreForTest();
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
|
||||
@@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
|
||||
satellitesLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingSatelliteTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
@@ -187,8 +216,8 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
|
||||
{ noradId: 99999, name: 'UNRELATED SAT', point: near(50_000) }, // 50 km away
|
||||
],
|
||||
});
|
||||
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
const details = entity.gevLabelModel.details;
|
||||
// Class leads the detail block; the altitude line follows it, and the
|
||||
// consolidated companions stay last.
|
||||
assert.equal(details[0], 'STATION · ISS', 'the class line names what this is');
|
||||
assert.equal(
|
||||
details[0],
|
||||
'STATION · ISS',
|
||||
'the class line names what this is',
|
||||
);
|
||||
assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
|
||||
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card');
|
||||
assert.equal(
|
||||
details[2],
|
||||
'DOCKED · PROGRESS-MS 34 · +1',
|
||||
'companions are consolidated, named, and counted on the tracked card',
|
||||
);
|
||||
|
||||
// Scoping is encoded in that string: THREE neighbours were seeded but only two
|
||||
// are counted (the named one plus "+1"). The satellite 50 km away is not part
|
||||
@@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
camera: null,
|
||||
scene: { frameState: { frameNumber: 1 } },
|
||||
};
|
||||
const epochs = [
|
||||
Date.UTC(2008, 8, 20, 12, 30),
|
||||
Date.UTC(2008, 8, 20, 13, 0),
|
||||
];
|
||||
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
|
||||
let epochIndex = 0;
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
|
||||
assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544');
|
||||
// formatQuantity keeps the number and its unit together with a no-break space.
|
||||
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
|
||||
|
||||
epochIndex = 1;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
|
||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
|
||||
'the class line survives an altitude-only republish');
|
||||
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||
assert.equal(
|
||||
entity.gevLabelModel.details[0],
|
||||
'STATION · ISS',
|
||||
'the class line survives an altitude-only republish',
|
||||
);
|
||||
});
|
||||
|
||||
test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => {
|
||||
@@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
|
||||
const ambient = ambientPublication[2][0];
|
||||
assert.equal(ambient.title, 'ISS');
|
||||
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
|
||||
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
|
||||
assert.equal(
|
||||
ambient.position(),
|
||||
point.position,
|
||||
'ISS getter reads the existing point cache',
|
||||
);
|
||||
assert.equal(
|
||||
point.label,
|
||||
undefined,
|
||||
'ISS point primitive carries no label graphic',
|
||||
);
|
||||
|
||||
const trackedEntity = _trackIssForTest();
|
||||
assert.ok(trackedEntity);
|
||||
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity');
|
||||
assert.equal(
|
||||
viewer.entities.values.length,
|
||||
1,
|
||||
'runtime guard requires a real tracked entity',
|
||||
);
|
||||
assert.equal(trackedEntity.label, undefined);
|
||||
assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing');
|
||||
assert.ok(
|
||||
trackedEntity.point,
|
||||
'tracked dot remains on the entity for camera framing',
|
||||
);
|
||||
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
|
||||
assert.ok(trackedEntry);
|
||||
assert.equal(trackedEntry.id, 'satellites:25544');
|
||||
assert.equal(trackedEntry.protected, true);
|
||||
assert.equal(trackedEntry.paintLane, 'tracked');
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
2,
|
||||
'tracking may refresh before ownership changes but must not publish a second ISS entry',
|
||||
);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
], 'tracked ISS suppresses ambient text so the tracked card is the only text surface');
|
||||
assert.deepEqual(
|
||||
calls.slice(-2),
|
||||
[
|
||||
['clear', 'satellites-iss'],
|
||||
['visible', 'satellites-iss', false],
|
||||
],
|
||||
'tracked ISS suppresses ambient text so the tracked card is the only text surface',
|
||||
);
|
||||
|
||||
_clearSatelliteLabelLifecycleForTest();
|
||||
cleaned = true;
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.equal(
|
||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
type === 'entries' && sourceId === 'satellites-iss',
|
||||
).length,
|
||||
3,
|
||||
'untracking republishes exactly one persistent ISS ambient entry before teardown',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import {
|
||||
_runSatellitePreRenderForTest,
|
||||
_setTrackedSatelliteRefreshStateForTest,
|
||||
_trackedFrameCartesianForTest,
|
||||
} from './satellites.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
|
||||
function run(locale) {
|
||||
setLocale(locale);
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
|
||||
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
|
||||
const near = (metres) => ({
|
||||
get position() {
|
||||
const tracked = _trackedFrameCartesianForTest();
|
||||
return new Cesium.Cartesian3(tracked.x + metres, tracked.y, tracked.z);
|
||||
},
|
||||
});
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) },
|
||||
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) },
|
||||
],
|
||||
});
|
||||
_runSatellitePreRenderForTest();
|
||||
nowMs += 2000;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
_runSatellitePreRenderForTest();
|
||||
const details = entity.gevLabelModel.details;
|
||||
setLocale('en');
|
||||
return details;
|
||||
}
|
||||
|
||||
test('es: tracked ISS card is localized', () => {
|
||||
const details = run('es');
|
||||
assert.equal(details[0], 'ESTACIÓN · ISS');
|
||||
assert.doesNotMatch(
|
||||
details[2],
|
||||
/^DOCKED/,
|
||||
'companions line should be localized',
|
||||
);
|
||||
});
|
||||
|
||||
test('ru: tracked ISS card is localized', () => {
|
||||
const details = run('ru');
|
||||
assert.equal(details[0], 'СТАНЦИЯ · ISS');
|
||||
assert.match(details[1], /км/, 'ru glossary: km → км');
|
||||
assert.doesNotMatch(
|
||||
details[2],
|
||||
/^DOCKED/,
|
||||
'companions line should be localized',
|
||||
);
|
||||
});
|
||||
+498
-7
@@ -115,6 +115,265 @@ function metroTransitRouteMode(routeId) {
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* TTC BusTime is the SURFACE feed: buses and streetcars, never the subway.
|
||||
* Streetcar routes are the 500-series, plus the four Blue Night routes that
|
||||
* run on streetcar trackage (301 Queen, 304 King, 306 Carlton, 310 Spadina);
|
||||
* the rest of the 300-series is night buses.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function ttcRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^5\d\d$/.test(routeId)) return 'tram';
|
||||
if (/^(301|304|306|310)$/.test(routeId)) return 'tram';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* The fleet number settles TTC's mode before the route does: buses replace
|
||||
* streetcars on 5xx routes during track work, and streetcars running out of
|
||||
* service report no route at all. Flexity streetcars are numbered 4400–4699;
|
||||
* every other TTC number is a bus.
|
||||
* @param {string|null} vehicleId GTFS-RT vehicle id (the fleet number).
|
||||
* @returns {string}
|
||||
*/
|
||||
function ttcVehicleMode(vehicleId) {
|
||||
if (!/^\d{4}$/.test(String(vehicleId ?? ''))) return 'unknown';
|
||||
const fleet = Number(vehicleId);
|
||||
return fleet >= 4400 && fleet < 4700 ? 'tram' : 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guelph Transit's realtime feed publishes the internal database key of a
|
||||
* route (`2991`), not the number on the front of the bus (`1`). Without this
|
||||
* table the detection label reads "Route 2991", which is not what anyone at
|
||||
* the stop calls it. Route ids and short names are from the City of Guelph's
|
||||
* own published GTFS `routes.txt` (see DATA_SOURCES.md); an id missing here
|
||||
* falls through unchanged, so a route added later degrades to its raw id
|
||||
* rather than disappearing.
|
||||
*/
|
||||
const GUELPH_ROUTE_NAMES = Object.freeze({
|
||||
2991: '1',
|
||||
2992: '10',
|
||||
2993: '11',
|
||||
2994: '12',
|
||||
2995: '13',
|
||||
2996: '14',
|
||||
2997: '15',
|
||||
2998: '16',
|
||||
2999: '17',
|
||||
3000: '18',
|
||||
3001: '19',
|
||||
3002: '2',
|
||||
3003: '20',
|
||||
3004: '3',
|
||||
3005: '4',
|
||||
3006: '5',
|
||||
3007: '50 U',
|
||||
3008: '52 U',
|
||||
3009: '56 U',
|
||||
3010: '58 U',
|
||||
3011: '6',
|
||||
3012: '7',
|
||||
3013: '8',
|
||||
3014: '9',
|
||||
3015: '98',
|
||||
3016: '99',
|
||||
3017: '99Lite',
|
||||
3018: 'BREW',
|
||||
3019: 'EdiCol',
|
||||
3023: 'IroHarv',
|
||||
3030: 'Sun LN',
|
||||
3032: 'WHanSco',
|
||||
3039: 'ZehCha',
|
||||
});
|
||||
|
||||
/**
|
||||
* Guelph route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function guelphRouteLabel(routeId) {
|
||||
return GUELPH_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamilton publishes an internal key for every route: not one of HSR's 80 route
|
||||
* ids equals the number on the bus, so an untranslated feed reads as
|
||||
* "Route 5783" city-wide. Two generations of id are live at once — `5688` and
|
||||
* `5780` are both the 2 BARTON — and both are listed, because both arrive.
|
||||
*
|
||||
* Numbers are unpadded to match the rest of the registry (`routes.txt` says
|
||||
* `02`; a rider says 2). The seasonal and event shuttles publish a name rather
|
||||
* than a number — `TC230`, `PEACH`, `ROCKTON` — and those are already what a
|
||||
* rider would say, so they survive as published. Ids and names are from the
|
||||
* City's own published GTFS `routes.txt`, under the same terms as the feed.
|
||||
*/
|
||||
const HAMILTON_ROUTE_NAMES = Object.freeze({
|
||||
5687: '1',
|
||||
5688: '2',
|
||||
5689: '3',
|
||||
5690: '4',
|
||||
5691: '5',
|
||||
5692: '6',
|
||||
5693: '7',
|
||||
5694: '8',
|
||||
5695: '9',
|
||||
5696: '10',
|
||||
5697: '11',
|
||||
5698: '12',
|
||||
5699: '16',
|
||||
5700: '18',
|
||||
5701: '20',
|
||||
5702: '21',
|
||||
5703: '22',
|
||||
5704: '23',
|
||||
5705: '24',
|
||||
5706: '25',
|
||||
5707: '26',
|
||||
5708: '27',
|
||||
5709: '33',
|
||||
5710: '34',
|
||||
5711: '35',
|
||||
5712: '41',
|
||||
5713: '42',
|
||||
5714: '43',
|
||||
5715: '44',
|
||||
5716: '51',
|
||||
5717: '52',
|
||||
5718: '55',
|
||||
5719: '56',
|
||||
5720: '99',
|
||||
5731: 'X-IND',
|
||||
5780: '2',
|
||||
5781: '3',
|
||||
5782: '4',
|
||||
5783: '5',
|
||||
5784: '6',
|
||||
5785: '7',
|
||||
5786: '8',
|
||||
5787: '9',
|
||||
5789: '11',
|
||||
5790: '12',
|
||||
5791: '16',
|
||||
5792: '18',
|
||||
5793: '20',
|
||||
5794: '21',
|
||||
5795: '22',
|
||||
5796: '23',
|
||||
5797: '24',
|
||||
5798: '25',
|
||||
5799: '26',
|
||||
5800: '27',
|
||||
5801: '33',
|
||||
5802: '34',
|
||||
5803: '35',
|
||||
5805: '42',
|
||||
5806: '43',
|
||||
5807: '44',
|
||||
5808: '51',
|
||||
5809: '52',
|
||||
5811: '56',
|
||||
5812: '99',
|
||||
5820: 'TC230',
|
||||
5823: 'X IND',
|
||||
5824: 'TC-700',
|
||||
5825: 'TC-300',
|
||||
5826: 'CAN',
|
||||
5827: 'PEACH',
|
||||
5828: '1',
|
||||
5829: '10',
|
||||
5830: '41',
|
||||
5831: '55',
|
||||
5832: 'TC300',
|
||||
5833: 'TC700',
|
||||
5834: 'TC730',
|
||||
5835: 'ANCFAIR',
|
||||
5836: 'ROCKTON',
|
||||
});
|
||||
|
||||
/**
|
||||
* Hamilton route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hamiltonRouteLabel(routeId) {
|
||||
return HAMILTON_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* YRT publishes Viva bus rapid transit by route id (`601`), while every rider,
|
||||
* station sign and map calls it "Viva Blue". Plain local routes are already the
|
||||
* number on the bus and are left alone, unpadded: `105` reads better than the
|
||||
* `005` of `routes.txt`.
|
||||
*
|
||||
* Branches are the exception. YRT suffixes a two-digit branch onto the base
|
||||
* route — the scheme Viva's own `60102` follows — so a branch id reaches a
|
||||
* rider as "8301" unless it is translated. Names and colours are from York
|
||||
* Region's own published GTFS `routes.txt`; Viva Green (604) and Pink (606)
|
||||
* are not in current service and so are absent here too.
|
||||
*
|
||||
* Only branches observed live AND published in `routes.txt` are listed. `9101`
|
||||
* runs but is unpublished, so it is deliberately missing: the pattern suggests
|
||||
* "91A", and suggesting is not knowing.
|
||||
*/
|
||||
const YRT_ROUTE_NAMES = Object.freeze({
|
||||
601: 'Viva Blue',
|
||||
60102: 'Viva Blue B',
|
||||
603: 'Viva Purple',
|
||||
60301: 'Viva Purple A',
|
||||
605: 'Viva Orange',
|
||||
607: 'Viva Yellow',
|
||||
8301: '83A',
|
||||
9002: '90B',
|
||||
10702: '107B',
|
||||
});
|
||||
|
||||
/**
|
||||
* YRT route id to the name a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function yrtRouteLabel(routeId) {
|
||||
return YRT_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Burlington publishes the internal key of a route, and the keys are a trap:
|
||||
* `351` is route 1 while `3510` is route 10, so trimming a `35` prefix gets
|
||||
* the wrong answer on half the network. The join is the only correct route.
|
||||
* Ids and numbers are from the City's own published GTFS `routes.txt`, which
|
||||
* sits in the same directory as the realtime feed under the same terms.
|
||||
*/
|
||||
const BURLINGTON_ROUTE_NAMES = Object.freeze({
|
||||
351: '1',
|
||||
352: '2',
|
||||
353: '3',
|
||||
354: '4',
|
||||
356: '6',
|
||||
3510: '10',
|
||||
3511: '11',
|
||||
3512: '12',
|
||||
3525: '25',
|
||||
3548: '48',
|
||||
3550: '50',
|
||||
3551: '51',
|
||||
3552: '52',
|
||||
3580: '80',
|
||||
3581: '81',
|
||||
3587: '87',
|
||||
});
|
||||
|
||||
/**
|
||||
* Burlington route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function burlingtonRouteLabel(routeId) {
|
||||
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
@@ -270,6 +529,204 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ttc-toronto',
|
||||
name: 'TTC',
|
||||
operator: 'Toronto Transit Commission',
|
||||
region: 'Toronto, ON',
|
||||
center: Object.freeze({ lat: 43.6532, lon: -79.3832 }),
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://bustime.ttc.ca/gtfsrt/vehicles',
|
||||
license: 'Open Government Licence \u2013 Toronto',
|
||||
licenseUrl: 'https://open.toronto.ca/open-data-licence/',
|
||||
attribution:
|
||||
'Toronto Transit Commission \u2014 City of Toronto Open Data (Open Government Licence \u2013 Toronto)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider.',
|
||||
note: 'The City publishes this endpoint from the TTC BusTime NVAS catalogue entry, whose own licence field reads \"License not specified\"; the quote above is the portal-wide Open Government Licence \u2013 Toronto the City applies to its open data, so the credit carries the licence name the way the Metro Transit entry carries its public-domain note. BusTime is the surface system: buses and streetcars report real positions, the subway reports none, so Lines 1/2/4 are simply absent rather than inferred. The endpoint sends no ETag or Last-Modified, so conditional requests fall through to the proxy snapshot TTL.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
// Toronto rides streetcars. The vehicles are the shared `tram` mode and
|
||||
// share its silhouette and speed limits; only the word a reader sees
|
||||
// changes, because "tram" is not what anyone at Queen and Spadina says.
|
||||
modeWords: Object.freeze({
|
||||
tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }),
|
||||
}),
|
||||
routeMode: ttcRouteMode,
|
||||
vehicleMode: ttcVehicleMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'miway-mississauga',
|
||||
name: 'MiWay',
|
||||
operator: 'MiWay (City of Mississauga)',
|
||||
region: 'Mississauga, ON',
|
||||
center: Object.freeze({ lat: 43.589, lon: -79.6441 }),
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
|
||||
license: 'City of Mississauga Open Data Terms of Use',
|
||||
licenseUrl:
|
||||
'http://www5.mississauga.ca/research_catalogue/CityofMississauga_TermsofUse.pdf',
|
||||
attribution: 'MiWay \u2014 City of Mississauga Open Data',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The City of Mississauga (the City) now grants you a world-wide, royalty-free, non-exclusive, revocable licence to use, modify, and distribute the Datasets in all current and future media and formats for any lawful purpose.',
|
||||
note: 'Two conditions shape how this ships. The licence is REVOCABLE, and it asks that anyone who provides access to the data pass on the Terms of Use URL \u2014 so `licenseUrl` points at the terms themselves rather than at a catalogue page, and the attribution popover carries the link. Credit is optional here ("you are not required to credit the City"), and is given anyway as a courtesy, the way the other municipal feeds are.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'hsr-hamilton',
|
||||
name: 'Hamilton HSR',
|
||||
operator: 'Hamilton Street Railway (City of Hamilton)',
|
||||
region: 'Hamilton, ON',
|
||||
center: Object.freeze({ lat: 43.2557, lon: -79.8711 }),
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
|
||||
license: 'City of Hamilton Open Data Licence',
|
||||
licenseUrl:
|
||||
'https://www.hamilton.ca/city-initiatives/strategies-actions/open-data-licence-terms-and-conditions',
|
||||
attribution:
|
||||
'Contains public sector Data made available under the City of Hamilton\u2019s Open Data Licence',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The City of Hamilton grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Data, including for commercial purposes ... you must acknowledge the source of the Data by including the following attribution statement: \u201cContains public sector Data made available under the City of Hamilton\u2019s Open Data Licence\u201d.',
|
||||
note: 'The attribution wording is prescribed by the licence, so it is used verbatim as the credit rather than paraphrased to an operator name. Names, crests, logos and marks are excluded from the grant, so the credit stays text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: hamiltonRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'durham-region',
|
||||
name: 'Durham Region Transit',
|
||||
operator: 'Durham Region Transit (Regional Municipality of Durham)',
|
||||
region: 'Durham Region, ON',
|
||||
center: Object.freeze({ lat: 43.9496, lon: -78.9365 }),
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
|
||||
license: 'Region of Durham Open Data Licence v.1.0',
|
||||
licenseUrl: 'https://www.durham.ca/en/regional-government/open-data.aspx',
|
||||
attribution:
|
||||
"Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence",
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Durham Region grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information ... You are free to: Copy, publish, distribute and transmit the information ... Use the information commercially.',
|
||||
note: 'The licence link the open-data catalogue publishes for this feed (OpenDataLicenceAgreement.pdf) is dead after a site relaunch; `licenseUrl` points at the live Open Data page carrying the same licence v.1.0 text, which is where the quote above was read. Credit is optional and the suggested statement is used verbatim.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'guelph-transit',
|
||||
name: 'Guelph Transit',
|
||||
operator: 'Guelph Transit (City of Guelph)',
|
||||
region: 'Guelph, ON',
|
||||
center: Object.freeze({ lat: 43.5325, lon: -80.2482 }),
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://glphprdtmgtfs.glphtrpcloud.com/tmgtfsrealtimewebservice/vehicle/vehiclepositions.pb',
|
||||
license: 'Open Government Licence \u2013 City of Guelph (v2.0)',
|
||||
licenseUrl: 'https://explore.guelph.ca/pages/open-data-license',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Government Licence \u2013 City of Guelph',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information including for commercial purposes subject to the terms below. ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence.',
|
||||
note: 'The endpoint sits on a vendor host, so provenance matters: the City publishes this exact URL on its own open-data developer page, https://explore.guelph.ca/pages/transit-gtfs-data, under an open-data initiative heading (that page is JavaScript-rendered; its readable source is the ArcGIS item JSON behind it). The licence is a verbatim OGL-Canada 2.0 derivative and the attribution wording above is its prescribed fallback, so it is used as written. The transit datasets carry a BLANK licence field in the portal catalogue; the Terms of Use supply it by default ("Except where otherwise noted this is the Open Government Licence"), the same shape of gap the TTC entry records. The Terms also publish a traffic limit \u2014 no concurrent requests, at most 5 per second \u2014 which one poll every 15 s satisfies many times over. Crests and logos are excluded from the grant, so the credit stays text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: guelphRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'kingston-transit',
|
||||
name: 'Kingston Transit',
|
||||
operator: 'Kingston Transit (The Corporation of the City of Kingston)',
|
||||
region: 'Kingston, ON',
|
||||
center: Object.freeze({ lat: 44.24, lon: -76.57 }),
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://api.cityofkingston.ca/gtfs-realtime/vehicleupdates.pb',
|
||||
license: 'City of Kingston Open Data Licence 1.0',
|
||||
licenseUrl:
|
||||
'https://www.cityofkingston.ca/media/wtpgpkb0/gis_license_opendata.pdf',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Data Licence \u2013 City of Kingston',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider, the City of Kingston, grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information provided for any purpose, including for commercial purposes, subject to the terms below. ... Acknowledge the source of the information by including any attribution statement specified by the City of Kingston and, where possible, provide a link to this licence.',
|
||||
note: 'The City catalogues this exact URL as an open-data dataset, so provenance is direct rather than inferred. `licenseUrl` is the canonical location of the licence PDF; the address the catalogue prints is a legacy path that currently redirects there, and pinning the redirect target is what survives the next site relaunch \u2014 the Durham entry records what happens when it does not. Names, crests and logos are excluded from the grant, so the credit stays text. Roughly one entity in ten carries no usable position (the position field absent, occasionally a momentary 0,0 GPS dropout); both are discarded before rendering.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'barrie-transit',
|
||||
name: 'Barrie Transit',
|
||||
operator: 'Barrie Transit (City of Barrie)',
|
||||
region: 'Barrie, ON',
|
||||
center: Object.freeze({ lat: 44.371, lon: -79.6733 }),
|
||||
loadRadiusKm: 10,
|
||||
url: 'https://www.myridebarrie.ca/gtfs/GTFS_VehiclePositions.pb',
|
||||
license: 'Barrie Transit Data licence',
|
||||
licenseUrl:
|
||||
'https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs',
|
||||
attribution:
|
||||
'Realtime vehicle data \u00a9 City of Barrie, used under the Barrie Transit Data licence',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Barrie Transit hereby grants you a non-exclusive, limited and revocable rights to use, reproduce, and redistribute Barrie Transit Data (scheduled and real-time data) subject to the following terms: Barrie Transit trademarks and copyrighted materials, including any confusingly similar variants, may not be used in association with Data. ... Barrie Transit maintains title, ownership, rights and interest in and to Data.',
|
||||
note: 'The City offers this licence for acceptance behind a click-through checkbox, and the licence body also makes use itself acceptance: \u201cBy using Barrie Transit Data, you agree to be bound by these terms.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on; nothing gates the endpoint, which needs no key, token or cookie. Two oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'yrt-york',
|
||||
name: 'YRT/Viva',
|
||||
operator:
|
||||
'York Region Transit (YRT/Viva), The Regional Municipality of York',
|
||||
region: 'York Region, ON',
|
||||
center: Object.freeze({ lat: 44.025, lon: -79.436 }),
|
||||
loadRadiusKm: 38,
|
||||
url: 'https://rtu.york.ca/gtfsrealtime/VehiclePositions',
|
||||
license: 'YRT Open Data Licence v2.0 (UK OGL-derived)',
|
||||
licenseUrl:
|
||||
'https://www.yrt.ca/en/about-us/open-data-licence-agreement.aspx',
|
||||
attribution:
|
||||
"Contains public transit Information made available under YRT's Open Data Licence",
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'YRT grants You a worldwide, royalty-free, perpetual, non-exclusive licence to Use the Information subject to the conditions below ... Use the Information commercially ... Provide credit to YRT when using Information under this licence but are not required to. ... Please note: You must agree with and accept the above stated License Agreement in order to download YRT GTFS or real-time GTFS data.',
|
||||
note: 'The operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential), while the licence body says acceptance is by use: \u201cYour Use of Information indicates Your acceptance of the terms and conditions below.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on. Nothing gates the endpoint. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: yrtRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'burlington-transit',
|
||||
name: 'Burlington Transit',
|
||||
operator: 'Burlington Transit (City of Burlington)',
|
||||
region: 'Burlington, ON',
|
||||
center: Object.freeze({ lat: 43.3342, lon: -79.8089 }),
|
||||
loadRadiusKm: 12,
|
||||
url: 'https://opendata.burlington.ca/gtfs-rt/GTFS_VehiclePositions.pb',
|
||||
license: 'City of Burlington Open Data Terms of Use',
|
||||
licenseUrl:
|
||||
'https://opendata.burlington.ca/opendata-terms-of-use/City%20of%20Burlington%20-%20Open%20Data%20Terms%20of%20Use.pdf',
|
||||
attribution:
|
||||
'Transit data \u00a9 City of Burlington (Burlington Transit), used under the City of Burlington Open Data Terms of Use',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Corporation of the City of Burlington (the City) now grants you a worldwide, royalty-free, non-exclusive, revocable licence to use, reproduce, modify, and distribute the datasets in all current and future media and formats for any lawful purpose. ... If you distribute or provide access to these datasets to any other person ... you agree to include a copy of, or this Uniform Resource Locator (URL) for, these Terms of Use.',
|
||||
note: 'Same 2011-era municipal template as Mississauga, and the same two consequences: the grant is REVOCABLE, and the terms have to travel with the data, so `licenseUrl` is the Terms document itself rather than a catalogue page and the attribution popover carries the link. Credit is optional here and given anyway. Crests, logos and marks are excluded and the terms forbid implying any association, so the credit stays plain text. The catalogue record for this data leads with a licence link that 404s; the URL above is the live one. Plain http:// has no listener at all \u2014 not a redirect, a hang \u2014 so the https:// URL is mandatory rather than preferred. The feed updates once a minute and answers conditional requests with an ETag.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: burlingtonRouteLabel,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -353,14 +810,32 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's route hint when it has one, else its default.
|
||||
* The feed's own hint for a vehicle: its fleet number when the feed maps
|
||||
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} vehicleId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hintedMode(feed, routeId, vehicleId) {
|
||||
const byVehicle =
|
||||
typeof feed?.vehicleMode === 'function'
|
||||
? feed.vehicleMode(vehicleId)
|
||||
: null;
|
||||
if (byVehicle && byVehicle !== 'unknown') return byVehicle;
|
||||
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's fleet or route hint when it has one, else
|
||||
* its default.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
|
||||
* @param {string|null} [vehicleId] GTFS-RT vehicle id.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
export function transitModeFor(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
const mode =
|
||||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
@@ -373,16 +848,32 @@ export function transitModeFor(feed, routeId) {
|
||||
* not hold a defaulted vehicle to a bus's limits.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} [vehicleId]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function transitModeResolved(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
export function transitModeResolved(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
return (
|
||||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route as a rider would say it. Most feeds publish that already, so this
|
||||
* is identity for them; a feed that publishes internal keys supplies its own
|
||||
* `routeLabel`. Never invents a label: an id the feed does not know survives
|
||||
* unchanged, because a raw id a person can compare against the bus is more
|
||||
* use than a blank.
|
||||
* @param {object|null} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function transitRouteLabel(feed, routeId) {
|
||||
if (!routeId || typeof feed?.routeLabel !== 'function') return routeId;
|
||||
const named = feed.routeLabel(routeId);
|
||||
return typeof named === 'string' && named ? named : routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
transitRouteLabel,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
|
||||
@@ -239,3 +240,245 @@ test('published transit history scope agrees with the catalog capability', () =>
|
||||
/four fixes|0\.95x|guaranteed lower|one poll interval behind/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the TTC feed is registered, routable, and covers Toronto', () => {
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(ttc?.url, 'https://bustime.ttc.ca/gtfsrt/vehicles');
|
||||
assert.equal(ttc.defaultEnabled, true);
|
||||
assert.match(ttc.attribution, /Toronto Transit Commission/);
|
||||
assert.equal(ttc.historyRetention, undefined, 'no proxy retention');
|
||||
assert.ok(
|
||||
transitFeedsInRange(43.6532, -79.3832).some(
|
||||
(feed) => feed.id === 'ttc-toronto',
|
||||
),
|
||||
'downtown Toronto is inside the load radius',
|
||||
);
|
||||
});
|
||||
|
||||
test('TTC route numbers tell a streetcar from a bus', () => {
|
||||
// BusTime is the surface feed: 500-series are streetcar routes and the
|
||||
// streetcar-operated Blue Night routes keep their own numbers. Everything
|
||||
// else on this feed is a bus, so the mode is established, not defaulted.
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(transitModeFor(ttc, '501'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '512'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '304'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '36'), 'bus');
|
||||
// 52 Lawrence West is a bus. Only the three-digit 500-series is streetcar,
|
||||
// so the rule must not match on a leading 5 alone.
|
||||
assert.equal(transitModeFor(ttc, '52'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '59'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '900'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '352'), 'bus');
|
||||
assert.equal(transitModeResolved(ttc, '501'), true);
|
||||
assert.equal(transitModeResolved(ttc, null), false);
|
||||
assert.equal(
|
||||
transitModeFor(ttc, null),
|
||||
'bus',
|
||||
'an unrouted vehicle falls back to the feed default, unresolved',
|
||||
);
|
||||
});
|
||||
|
||||
test('a TTC fleet number outranks the route', () => {
|
||||
// Buses replace streetcars on 5xx routes during track work, and streetcars
|
||||
// out of service report no route; the Flexity fleet is 4400–4699.
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(transitModeFor(ttc, '506', '8515'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '512', '3101'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, null, '4507'), 'tram');
|
||||
assert.equal(transitModeResolved(ttc, null, '4507'), true);
|
||||
assert.equal(transitModeFor(ttc, '504', '4400'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '29', '4699'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '501', '4700'), 'bus');
|
||||
// A vehicle id that is not a fleet number leaves the route to decide.
|
||||
assert.equal(transitModeFor(ttc, '501', 'abc'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '501'), 'tram');
|
||||
});
|
||||
|
||||
test('the TTC row records that BusTime carries no subway', () => {
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const row = sources.split('\n').find((line) => line.startsWith('| **TTC**'));
|
||||
assert.ok(row, 'TTC has a DATA_SOURCES row');
|
||||
assert.match(row, /Toronto/);
|
||||
assert.match(sources, /surface vehicles \(buses and streetcars\) only/);
|
||||
assert.match(sources, /Open Government Licence – Toronto/);
|
||||
});
|
||||
|
||||
test('the Golden Horseshoe neighbours are registered, keyless and routable', () => {
|
||||
const expected = {
|
||||
'miway-mississauga':
|
||||
'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
|
||||
'hsr-hamilton':
|
||||
'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
|
||||
'durham-region':
|
||||
'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
|
||||
};
|
||||
for (const [id, url] of Object.entries(expected)) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.ok(feed, `${id} is routable`);
|
||||
assert.equal(feed.url, url);
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.defaultMode, 'bus');
|
||||
assert.equal(feed.historyRetention, undefined, `${id} retains nothing`);
|
||||
assert.ok(feed.terms?.quote, `${id} records the terms it ships on`);
|
||||
}
|
||||
});
|
||||
|
||||
test('each Golden Horseshoe feed covers its own city and not its neighbours', () => {
|
||||
const near = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
// Mississauga City Centre, downtown Hamilton, Oshawa.
|
||||
assert.ok(near(43.589, -79.6441).includes('miway-mississauga'));
|
||||
assert.ok(near(43.2557, -79.8711).includes('hsr-hamilton'));
|
||||
assert.ok(near(43.8971, -78.8658).includes('durham-region'));
|
||||
// Hamilton is 60 km from Toronto: far enough that the TTC feed is not polled
|
||||
// for it, which is the whole point of a per-feed radius.
|
||||
assert.equal(near(43.2557, -79.8711).includes('ttc-toronto'), false);
|
||||
});
|
||||
|
||||
test('a bus-only feed never claims a mode its route ids cannot establish', () => {
|
||||
for (const id of ['miway-mississauga', 'hsr-hamilton', 'durham-region']) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.equal(transitModeFor(feed, '1'), 'bus');
|
||||
assert.equal(
|
||||
transitModeResolved(feed, '1'),
|
||||
false,
|
||||
`${id} defaults rather than resolves`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every Golden Horseshoe feed is documented with its licence', () => {
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
for (const name of ['MiWay', 'Hamilton HSR', 'Durham Region Transit']) {
|
||||
assert.ok(
|
||||
sources.split('\n').some((line) => line.startsWith(`| **${name}`)),
|
||||
`${name} has a DATA_SOURCES row`,
|
||||
);
|
||||
}
|
||||
assert.match(sources, /Durham Region Transit publishes no bearing/);
|
||||
});
|
||||
|
||||
test('a feed whose route ids are internal database keys can name its routes', () => {
|
||||
// Guelph's realtime feed emits `2991` where the rider sees route `1`. A feed
|
||||
// that knows the difference translates it; every other feed is unaffected.
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
assert.equal(transitRouteLabel(guelph, '2991'), '1');
|
||||
assert.equal(transitRouteLabel(guelph, '3016'), '99');
|
||||
assert.equal(transitRouteLabel(guelph, '3007'), '50 U');
|
||||
assert.equal(transitRouteLabel(guelph, '3017'), '99Lite');
|
||||
});
|
||||
|
||||
test('an unknown or absent route id survives translation unchanged', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
// A route added after this table was built must still render its raw id
|
||||
// rather than vanish or read as "undefined".
|
||||
assert.equal(transitRouteLabel(guelph, '4242'), '4242');
|
||||
assert.equal(transitRouteLabel(guelph, null), null);
|
||||
assert.equal(transitRouteLabel(guelph, ''), '');
|
||||
});
|
||||
|
||||
test('a feed with rider-facing route ids is left alone', () => {
|
||||
// TTC and Kingston publish the route a rider would say, so translation is
|
||||
// identity for them and no table is carried.
|
||||
assert.equal(transitRouteLabel(getTransitFeed('ttc-toronto'), '501'), '501');
|
||||
assert.equal(transitRouteLabel(getTransitFeed('mbta'), 'Red'), 'Red');
|
||||
assert.equal(transitRouteLabel(null, '7'), '7');
|
||||
});
|
||||
|
||||
test('the feeds whose licences are accepted by use are switched on', () => {
|
||||
// Barrie and YRT each ask for acceptance through a form, and each licence
|
||||
// also says that using the data is itself acceptance. That reading was
|
||||
// taken deliberately, so both are routable and both record the reasoning
|
||||
// in their terms note rather than leaving it to be re-derived later.
|
||||
for (const id of ['barrie-transit', 'yrt-york']) {
|
||||
assert.ok(getTransitFeed(id), `${id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((feed) => feed.id === id),
|
||||
`${id} is offered to the browser`,
|
||||
);
|
||||
assert.match(
|
||||
getRegisteredTransitFeed(id).terms.note,
|
||||
/acceptance is by use|use itself acceptance|took that clause as sufficient/,
|
||||
`${id} records why it ships on`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every registered feed now ships on, and the gate still decides that', () => {
|
||||
// The enabled set must stay a filter over the registry, not a copy of it:
|
||||
// flipping one feed off has to make it unreachable.
|
||||
assert.equal(getRegisteredTransitFeed('yrt-york')?.name, 'YRT/Viva');
|
||||
assert.deepEqual(
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled !== true),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Viva bus rapid transit is named by its colour, not its route number', () => {
|
||||
// YRT publishes `601` where every rider, map and station sign says
|
||||
// "Viva Blue". The numeric local routes already read correctly and are
|
||||
// left alone.
|
||||
const yrt = getTransitFeed('yrt-york');
|
||||
assert.equal(transitRouteLabel(yrt, '601'), 'Viva Blue');
|
||||
assert.equal(transitRouteLabel(yrt, '60301'), 'Viva Purple A');
|
||||
assert.equal(transitRouteLabel(yrt, '607'), 'Viva Yellow');
|
||||
assert.equal(transitRouteLabel(yrt, '105'), '105', 'a local bus is a number');
|
||||
assert.equal(transitRouteLabel(yrt, '9899'), '9899');
|
||||
});
|
||||
|
||||
test('YRT local branches read as the branch letter, not the padded id', () => {
|
||||
// YRT suffixes a two-digit branch onto the base route, the same scheme the
|
||||
// Viva table already relies on for `60102`. Unsuffixed, that leaks to a rider
|
||||
// as "8301". The letters are York Region's own published GTFS
|
||||
// `routes.txt` short names, unpadded to match the local routes beside them.
|
||||
const yrt = getTransitFeed('yrt-york');
|
||||
assert.equal(transitRouteLabel(yrt, '10702'), '107B');
|
||||
assert.equal(transitRouteLabel(yrt, '8301'), '83A');
|
||||
assert.equal(transitRouteLabel(yrt, '9002'), '90B');
|
||||
// `9101` runs in the realtime feed but is absent from routes.txt, so there is
|
||||
// no published name to give it. Inventing "91A" from the pattern is exactly
|
||||
// the guess the registry refuses to make; it stays opaque until it is
|
||||
// published.
|
||||
assert.equal(transitRouteLabel(yrt, '9101'), '9101');
|
||||
});
|
||||
|
||||
test('Hamilton route ids are internal keys, read as the number on the bus', () => {
|
||||
// Not one of HSR's 80 route ids equals its published short name, so every
|
||||
// vehicle reads as "Route 5783" until the table translates it. Hamilton also
|
||||
// carries two generations of id for the same route — `5688` and `5780` are
|
||||
// both the 2 BARTON — so both must land on the same answer.
|
||||
const hsr = getTransitFeed('hsr-hamilton');
|
||||
assert.equal(transitRouteLabel(hsr, '5780'), '2');
|
||||
assert.equal(transitRouteLabel(hsr, '5688'), '2', 'the older id agrees');
|
||||
assert.equal(transitRouteLabel(hsr, '5687'), '1');
|
||||
assert.equal(transitRouteLabel(hsr, '5829'), '10', 'the B-Line');
|
||||
assert.equal(transitRouteLabel(hsr, '5793'), '20', 'the A-Line');
|
||||
// Published names that are not numbers are already what a rider would say,
|
||||
// so they survive as published rather than being forced into a number.
|
||||
assert.equal(transitRouteLabel(hsr, '5820'), 'TC230');
|
||||
assert.equal(transitRouteLabel(hsr, '9999'), '9999', 'unknown survives');
|
||||
});
|
||||
|
||||
test('Burlington route ids are joined, never prefix-stripped', () => {
|
||||
// `351` is route 1 and `3510` is route 10, so no amount of trimming a `35`
|
||||
// prefix works — the table is the only correct answer.
|
||||
const bur = getTransitFeed('burlington-transit');
|
||||
assert.equal(transitRouteLabel(bur, '351'), '1');
|
||||
assert.equal(transitRouteLabel(bur, '3510'), '10');
|
||||
assert.equal(transitRouteLabel(bur, '3512'), '12');
|
||||
assert.equal(transitRouteLabel(bur, '3587'), '87');
|
||||
assert.equal(transitRouteLabel(bur, '9999'), '9999', 'unknown survives');
|
||||
});
|
||||
|
||||
test('Burlington is live and carries its terms link, which its licence requires', () => {
|
||||
const bur = getTransitFeed('burlington-transit');
|
||||
assert.equal(bur.defaultEnabled, true);
|
||||
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
|
||||
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
* after the vessel cards moved out of their dedicated canvas renderer.
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||
export const VESSEL_LABEL_GRID_PX = 118;
|
||||
@@ -13,6 +15,8 @@ export const VESSEL_DEFAULT_LABEL_LIMIT = 900;
|
||||
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
|
||||
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
|
||||
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
||||
/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */
|
||||
export const VESSEL_CARD_FADE_START_RATIO = 0.7;
|
||||
|
||||
/**
|
||||
* AIS type family → chevron hue + card accent. Single source of truth for
|
||||
@@ -64,6 +68,42 @@ const NUMERIC_TYPE_FAMILIES = {
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||
// and tests match on them.
|
||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||
FISHING: msg('FISHING'),
|
||||
TOWING: msg('TOWING'),
|
||||
DREDGER: msg('DREDGER'),
|
||||
'DIVE OPS': msg('DIVE OPS'),
|
||||
MILITARY: msg('MILITARY'),
|
||||
SAILING: msg('SAILING'),
|
||||
PLEASURE: msg('PLEASURE'),
|
||||
PILOT: msg('PILOT'),
|
||||
SAR: msg('SAR'),
|
||||
TUG: msg('TUG'),
|
||||
'PORT TENDER': msg('PORT TENDER'),
|
||||
'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
|
||||
'LAW ENFORCE': msg('LAW ENFORCE'),
|
||||
MEDICAL: msg('MEDICAL'),
|
||||
'HIGH-SPEED': msg('HIGH-SPEED'),
|
||||
PASSENGER: msg('PASSENGER'),
|
||||
CARGO: msg('CARGO'),
|
||||
TANKER: msg('TANKER'),
|
||||
OTHER: msg('OTHER'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Card-display form of a normalized AIS type: known tokens translate through
|
||||
* the catalog; free-text types and unknown codes render unchanged.
|
||||
* @param {string} type Raw AIS type.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function displayVesselType(type) {
|
||||
const normalized = normalizeVesselType(type);
|
||||
const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()];
|
||||
return key ? t(key) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||
* family names ("71" → "CARGO"); text types pass through unchanged.
|
||||
@@ -148,7 +188,7 @@ export function applyVesselOverlayPolicy(
|
||||
verticalOnly: !selected,
|
||||
viewportMargin: 4,
|
||||
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// A translated vessel type is never cut mid-word on the card.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { buildVesselCard, buildSelectedVesselCard } from './aisLiveVessels.js';
|
||||
import { displayVesselType } from './vesselLabels.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const POS = new Cesium.Cartesian3(1, 2, 3);
|
||||
const rec = (type) => ({
|
||||
name: 'X',
|
||||
mmsi: '1',
|
||||
type,
|
||||
destination: '',
|
||||
speed: 5,
|
||||
course: 10,
|
||||
heading: 10,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
// Every AIS ShipType code with its own display token.
|
||||
const CODES = [
|
||||
'30',
|
||||
'31',
|
||||
'33',
|
||||
'34',
|
||||
'35',
|
||||
'36',
|
||||
'37',
|
||||
'50',
|
||||
'51',
|
||||
'52',
|
||||
'53',
|
||||
'54',
|
||||
'55',
|
||||
'58',
|
||||
'40',
|
||||
'60',
|
||||
'70',
|
||||
'80',
|
||||
'90',
|
||||
];
|
||||
|
||||
// The card label is acceptable when it is the whole translation, or when it
|
||||
// was shortened at a word boundary and marked with an ellipsis.
|
||||
function assertNotCutMidWord(label, full, where) {
|
||||
if (label === full) return;
|
||||
const head = label.replace(/(…|\.\.\.)$/, '');
|
||||
assert.notEqual(
|
||||
head,
|
||||
label,
|
||||
`${where}: "${label}" cut from "${full}" without an ellipsis`,
|
||||
);
|
||||
assert.ok(
|
||||
full.startsWith(head),
|
||||
`${where}: "${label}" is not a prefix of "${full}"`,
|
||||
);
|
||||
assert.match(
|
||||
full.slice(head.length),
|
||||
/^[\s-]/,
|
||||
`${where}: "${label}" cuts "${full}" mid-word`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: vessel type on cards is never cut mid-word`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const code of CODES) {
|
||||
const full = displayVesselType(code).toUpperCase();
|
||||
const ambient = buildVesselCard(rec(code)).details[0].split(' · ')[0];
|
||||
const selected = buildSelectedVesselCard(rec(code)).details[0].split(
|
||||
' · ',
|
||||
)[0];
|
||||
assertNotCutMidWord(ambient, full, `${locale} ${code} ambient`);
|
||||
assertNotCutMidWord(selected, full, `${locale} ${code} selected`);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: free feed text is still bounded to the card width', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
buildVesselCard(rec('Crude Oil Tanker')).details[0].split(' · ')[0],
|
||||
'CRUDE OIL TANK',
|
||||
);
|
||||
assert.equal(
|
||||
buildSelectedVesselCard(rec('54')).details[0].split(' · ')[0],
|
||||
'ANTI-POLLUTION',
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export class SceneDocumentError extends Error {
|
||||
super(`${path}: ${reason}`);
|
||||
this.name = 'SceneDocumentError';
|
||||
this.path = path;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
export const fail = (path, reason) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { BUNDLE_SOURCE } from './bundle.js';
|
||||
|
||||
// Statuses stay English; scenes/sharing.js translates them for the dialog.
|
||||
|
||||
/** Describe an import without fetching assets, applying state or exposing source configuration. */
|
||||
export function describeSceneShare(
|
||||
{ project, assets },
|
||||
@@ -16,11 +19,11 @@ export function describeSceneShare(
|
||||
status:
|
||||
pack.source.adapter === BUNDLE_SOURCE
|
||||
? assets.has(pack.source.path)
|
||||
? 'Included in bundle'
|
||||
: 'Missing bundle file — reimport its bundle'
|
||||
? msg('Included in bundle')
|
||||
: msg('Missing bundle file — reimport its bundle')
|
||||
: sources.has(pack.source.adapter)
|
||||
? 'Source configured; file checked when loaded'
|
||||
: 'Source unavailable',
|
||||
? msg('Source configured; file checked when loaded')
|
||||
: msg('Source unavailable'),
|
||||
})),
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
|
||||
let shotIndex = scene.shots.length - 1;
|
||||
for (let index = 0; index < scene.shots.length; index += 1) {
|
||||
const endElapsedSec = startElapsedSec + durations[index];
|
||||
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
|
||||
// Compare in progress units: a shot's startProgress (start / total)
|
||||
// times total can round below its start and select the previous shot.
|
||||
if (
|
||||
normalized < endElapsedSec / totalSec ||
|
||||
index === scene.shots.length - 1
|
||||
) {
|
||||
shotIndex = index;
|
||||
break;
|
||||
}
|
||||
startElapsedSec = endElapsedSec;
|
||||
}
|
||||
// Keep the elapsed time inside the selected shot, so it agrees with
|
||||
// shotIndex when targetSec rounded below the shot's start.
|
||||
const elapsedSec = Math.max(targetSec, startElapsedSec);
|
||||
const shot = scene.shots[shotIndex];
|
||||
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
|
||||
const holdDurationSec = holdForShot(scene, shot);
|
||||
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
|
||||
const shotElapsedSec = Math.max(
|
||||
0,
|
||||
Math.min(shotDurationSec, targetSec - startElapsedSec),
|
||||
Math.min(shotDurationSec, elapsedSec - startElapsedSec),
|
||||
);
|
||||
const cameraProgress = clamp01(
|
||||
shotElapsedSec / Math.max(0.001, flightDurationSec),
|
||||
@@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
|
||||
);
|
||||
const move = resolveCameraMove(scene, shot);
|
||||
return {
|
||||
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
|
||||
sceneElapsedSec: targetSec,
|
||||
sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0,
|
||||
sceneElapsedSec: elapsedSec,
|
||||
sceneDurationSec: totalSec,
|
||||
shotIndex,
|
||||
shot,
|
||||
|
||||
@@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', ()
|
||||
);
|
||||
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
|
||||
});
|
||||
|
||||
test('seeking to a shot start lands in that shot for every shot count and duration', () => {
|
||||
const misses = [];
|
||||
for (let count = 2; count <= 12; count += 1) {
|
||||
for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) {
|
||||
for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) {
|
||||
const shots = Array.from({ length: count }, (_, index) => ({
|
||||
id: `s${index}`,
|
||||
// Every third shot is longer, so boundaries are not all equal steps.
|
||||
durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0),
|
||||
holdSec: holdTenths / 10,
|
||||
camera: camera(20, 0),
|
||||
}));
|
||||
const mixed = { shots };
|
||||
for (const [index, shot] of shots.entries()) {
|
||||
const { startProgress, startElapsedSec } = sceneTimingForShot(
|
||||
mixed,
|
||||
shot,
|
||||
duration,
|
||||
);
|
||||
const seek = sceneSeekState(mixed, startProgress, duration, hold);
|
||||
// One or two ulps earlier still belongs to the previous shot.
|
||||
const before = startProgress * (1 - Number.EPSILON);
|
||||
const previous = sceneSeekState(mixed, before, duration, hold);
|
||||
if (
|
||||
seek.shotIndex !== index ||
|
||||
seek.sceneElapsedSec < startElapsedSec ||
|
||||
previous.shotIndex !== Math.max(0, index - 1)
|
||||
)
|
||||
misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(misses.slice(0, 5), []);
|
||||
});
|
||||
+36
-13
@@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
// Choosing a mission is deliberately NOT durable suppression: picking a mission
|
||||
// is enthusiasm, not "never show me this again".
|
||||
|
||||
import { t, msg } from './i18n/index.js';
|
||||
|
||||
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
|
||||
@@ -34,10 +36,10 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
|
||||
*/
|
||||
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
|
||||
|
||||
const ENVIRONMENTAL_LABELS = Object.freeze({
|
||||
ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }),
|
||||
EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }),
|
||||
ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }),
|
||||
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
|
||||
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
|
||||
EARTH_WATCH: msg('EARTH WATCH'),
|
||||
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -45,7 +47,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({
|
||||
* @returns {{title: string}} The label set the constant above selects.
|
||||
*/
|
||||
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
|
||||
return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;
|
||||
const key =
|
||||
ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;
|
||||
return { title: t(key) };
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -92,12 +96,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
contacts: Object.freeze({
|
||||
kind: 'context',
|
||||
contextMode: 'contacts',
|
||||
busyText: 'Starting live contacts…',
|
||||
busyKey: msg('Starting live contacts…'),
|
||||
}),
|
||||
'space-missions': Object.freeze({
|
||||
kind: 'context',
|
||||
contextMode: 'space-missions',
|
||||
busyText: 'Opening space missions…',
|
||||
busyKey: msg('Opening space missions…'),
|
||||
}),
|
||||
environmental: Object.freeze({
|
||||
kind: 'globe',
|
||||
@@ -115,7 +119,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
||||
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
|
||||
// are judged on the layer row, which tells them the truth.
|
||||
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
||||
busyText: 'Scanning active events…',
|
||||
busyKey: msg('Scanning active events…'),
|
||||
}),
|
||||
explore: Object.freeze({ kind: 'none' }),
|
||||
});
|
||||
@@ -355,12 +359,25 @@ export function initFirstRunExperience({
|
||||
}
|
||||
|
||||
// The tile name is owner-switchable from one constant, so paint it from the
|
||||
// module rather than trusting the markup to have been edited to match.
|
||||
// module rather than trusting the markup to match. The description and the
|
||||
// tile subcopy are data-i18n-runtime in welcome.html and translated here.
|
||||
const environmentalTitle = root.querySelector(
|
||||
'[data-first-run-environmental-title]',
|
||||
);
|
||||
if (environmentalTitle)
|
||||
environmentalTitle.textContent = environmentalLabel().title;
|
||||
const environmentalSub = root.querySelector(
|
||||
'[data-first-run-environmental-title] + small',
|
||||
);
|
||||
if (environmentalSub)
|
||||
environmentalSub.textContent = t(
|
||||
'Live earthquakes and active fires, from USGS and NASA',
|
||||
);
|
||||
const description = root.querySelector('#first-run-description');
|
||||
if (description)
|
||||
description.textContent = t(
|
||||
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
|
||||
);
|
||||
|
||||
const status = root.querySelector('[data-first-run-status]');
|
||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||
@@ -443,7 +460,9 @@ export function initFirstRunExperience({
|
||||
button.setAttribute('aria-disabled', String(next));
|
||||
if (!status) return;
|
||||
if (next)
|
||||
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
|
||||
status.textContent = t(
|
||||
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
|
||||
);
|
||||
else if (status.dataset.sticky !== 'true')
|
||||
status.textContent = defaultStatus;
|
||||
};
|
||||
@@ -493,7 +512,10 @@ export function initFirstRunExperience({
|
||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||
if (status) {
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
|
||||
status.textContent = t(
|
||||
'Could not open that mission{detail}. Retry or explore manually.',
|
||||
{ detail },
|
||||
);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
@@ -509,8 +531,9 @@ export function initFirstRunExperience({
|
||||
if (box) box.checked = !wanted;
|
||||
if (!status) return;
|
||||
status.dataset.sticky = 'true';
|
||||
status.textContent =
|
||||
'This browser is blocking storage, so that could not be saved.';
|
||||
status.textContent = t(
|
||||
'This browser is blocking storage, so that could not be saved.',
|
||||
);
|
||||
};
|
||||
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
|
||||
+483
-144
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
|
||||
rememberFirstRunSessionDismissed(session);
|
||||
assert.equal(session.read(), 'dismissed');
|
||||
// Gone for THIS session...
|
||||
assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: session,
|
||||
location: { search: '' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// ...and back in the next one, because sessionStorage did not survive it.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Session dismissal must never have written the durable key.
|
||||
assert.equal(storage.read(), null);
|
||||
});
|
||||
@@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un
|
||||
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
|
||||
setFirstRunSuppressed(true, storage);
|
||||
assert.equal(storage.read(), 'suppressed');
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
// Unticking before dismissing takes the suppression back.
|
||||
setFirstRunSuppressed(false, storage);
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
||||
setFirstRunSuppressed(true, storage);
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('welcome params work in both directions and outrank both suppressions', () => {
|
||||
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
||||
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
||||
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage: suppressed,
|
||||
sessionStorageRef: dismissed,
|
||||
location: { search: '?welcome=1' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// ?welcome=0 suppresses a session that would otherwise see it.
|
||||
assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }),
|
||||
false,
|
||||
);
|
||||
// A share link outranks everything, including the replay hatch.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
|
||||
}), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
hasShareState: true,
|
||||
...fresh(),
|
||||
location: { search: '?welcome=1' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a share link never sees the launcher — its author already chose the view', () => {
|
||||
@@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view
|
||||
|
||||
test('privacy-restricted storage fails open and every write stays best-effort', () => {
|
||||
const blocked = {
|
||||
getItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => { throw new Error('blocked'); },
|
||||
removeItem: () => { throw new Error('blocked'); },
|
||||
getItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }),
|
||||
true,
|
||||
);
|
||||
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
|
||||
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
|
||||
});
|
||||
@@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
||||
// before the function body starts, so the SecurityError escapes every
|
||||
// try/catch in the module and the launcher silently never appears.
|
||||
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
const savedSession = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'sessionStorage',
|
||||
);
|
||||
const hostile = {
|
||||
configurable: true,
|
||||
get() { throw new Error('SecurityError: The operation is insecure.'); },
|
||||
get() {
|
||||
throw new Error('SecurityError: The operation is insecure.');
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', hostile);
|
||||
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
||||
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
||||
} finally {
|
||||
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
||||
else delete globalThis.localStorage;
|
||||
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||
if (savedSession)
|
||||
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||
else delete globalThis.sessionStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test('no storage is touched from a default parameter position', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// Comments stripped first: the block explaining this very defect quotes the
|
||||
// bad pattern, and matching prose instead of code would make the pin a liar.
|
||||
const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const code = module
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
assert.doesNotMatch(
|
||||
code,
|
||||
/=\s*globalThis\.(local|session)Storage/,
|
||||
@@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => {
|
||||
);
|
||||
// The only reads of the global areas live inside the guarded resolver.
|
||||
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
||||
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
|
||||
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
|
||||
assert.equal(
|
||||
globalReads.length,
|
||||
2,
|
||||
'exactly two global storage reads, both in resolveStore',
|
||||
);
|
||||
const resolver = code.slice(
|
||||
code.indexOf('function resolveStore'),
|
||||
code.indexOf('function readStored'),
|
||||
);
|
||||
assert.equal(
|
||||
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
||||
2,
|
||||
'both global storage reads must be inside resolveStore, inside its try',
|
||||
);
|
||||
assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/);
|
||||
assert.match(
|
||||
resolver,
|
||||
/try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/,
|
||||
);
|
||||
assert.match(code, /function readStored\(kind, injected, key\)/);
|
||||
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
|
||||
});
|
||||
@@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => {
|
||||
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.deepEqual(
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
|
||||
);
|
||||
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
|
||||
'cockpit-mode',
|
||||
'recording-mode',
|
||||
'scene-playback-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
// A surface that hides the card in CSS but is missing from the JS list would
|
||||
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
|
||||
const hideRule = css.slice(
|
||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||
css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')),
|
||||
css.indexOf(
|
||||
'display: none',
|
||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||
),
|
||||
);
|
||||
const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]);
|
||||
const inCss = [
|
||||
...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g),
|
||||
].map((m) => m[1]);
|
||||
assert.deepEqual(
|
||||
inCss.sort(),
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
'every screen-claiming surface must appear in BOTH lists',
|
||||
);
|
||||
assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/);
|
||||
assert.match(
|
||||
module,
|
||||
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||
);
|
||||
});
|
||||
|
||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } });
|
||||
const make = (classes) => ({
|
||||
body: { classList: { contains: (name) => classes.includes(name) } },
|
||||
});
|
||||
assert.equal(exclusiveSurfaceActive(make([])), false);
|
||||
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
|
||||
assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`);
|
||||
assert.equal(
|
||||
exclusiveSurfaceActive(make([name])),
|
||||
true,
|
||||
`${name} must count as exclusive`,
|
||||
);
|
||||
}
|
||||
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
|
||||
assert.equal(exclusiveSurfaceActive(undefined), false);
|
||||
@@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
});
|
||||
|
||||
test('the key handler refuses to act for a card that is not really on screen', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
|
||||
// Real visibility, not just the class: the class survives while CSS hides the
|
||||
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
||||
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
||||
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
assert.match(
|
||||
module,
|
||||
/&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/,
|
||||
);
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const handler = keyboard.slice(
|
||||
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||
);
|
||||
assert.match(
|
||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||
/!isActive\(\)/,
|
||||
@@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', (
|
||||
});
|
||||
|
||||
test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
||||
@@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
|
||||
// buried launcher and wrote the session flag while the lightbox stayed open.
|
||||
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
|
||||
assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/);
|
||||
assert.match(
|
||||
overlay.slice(0, overlay.indexOf('}')),
|
||||
/z-index: 200 !important/,
|
||||
);
|
||||
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
|
||||
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
|
||||
|
||||
@@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
|
||||
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
|
||||
// launcher acting under an overlay can never become why ESC stopped working.
|
||||
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
|
||||
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
|
||||
assert.match(
|
||||
covered,
|
||||
/if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/,
|
||||
);
|
||||
assert.match(
|
||||
covered,
|
||||
/if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/,
|
||||
);
|
||||
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
@@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
|
||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||
assert.match(claim, /event\.preventDefault\(\);/);
|
||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself');
|
||||
assert.doesNotMatch(
|
||||
claim,
|
||||
/event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself',
|
||||
);
|
||||
|
||||
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||
// whether or not that surface remembered to silence us.
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const handler = keyboard.slice(
|
||||
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||
);
|
||||
assert.match(
|
||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||
/event\.defaultPrevented\) return;/,
|
||||
'a marked key must be somebody else\'s key',
|
||||
"a marked key must be somebody else's key",
|
||||
);
|
||||
});
|
||||
|
||||
test('a refused write takes the tick back instead of promising "never again"', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The write stays best-effort; the OUTCOME is now reported, because a box left
|
||||
// ticked after a refused write tells the visitor the launcher is gone for good
|
||||
// while it is already guaranteed to return next session.
|
||||
const blocked = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('blocked'); },
|
||||
removeItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
||||
assert.equal(setFirstRunSuppressed(false, blocked), false);
|
||||
@@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', (
|
||||
module.indexOf('const onSuppressChange = (event) => {'),
|
||||
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
||||
);
|
||||
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
|
||||
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
|
||||
assert.match(
|
||||
handler,
|
||||
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
handler,
|
||||
/box\.checked = !wanted;/,
|
||||
'a refused write must revert the tick',
|
||||
);
|
||||
});
|
||||
|
||||
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const state = fs.readFileSync(
|
||||
new URL('../docs/CURRENT-STATE.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// A "reveal anyway after N seconds" would trade a benign no-show for the card
|
||||
// punching through a recording in progress — recordings run long, and none of
|
||||
@@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
|
||||
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
|
||||
module.indexOf(' const onViewportResize'),
|
||||
);
|
||||
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
|
||||
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
|
||||
'the pin must cover the function the note governs, not just the note');
|
||||
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
|
||||
'the acceptance is the decision NOT to time this out');
|
||||
assert.ok(
|
||||
accepted.length > 0,
|
||||
'the acceptance must be written where the next editor will read it',
|
||||
);
|
||||
assert.match(
|
||||
accepted,
|
||||
/const syncToExclusiveSurfaces = \(\) => \{/,
|
||||
'the pin must cover the function the note governs, not just the note',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
accepted,
|
||||
/setTimeout|setInterval/,
|
||||
'the acceptance is the decision NOT to time this out',
|
||||
);
|
||||
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
|
||||
assert.match(state, /a surface class that never clears means no launcher for that page/i);
|
||||
assert.match(
|
||||
state,
|
||||
/a surface class that never clears means no launcher for that page/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('the scroll fade only appears when the list really overflows', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
// A fade on a card where all five tiles fit promises a sixth mission that does
|
||||
// not exist, which is worse than no affordance at all.
|
||||
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
|
||||
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
|
||||
assert.match(
|
||||
module,
|
||||
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
|
||||
);
|
||||
assert.match(
|
||||
module,
|
||||
/choiceList\.dataset\.scrollable = String\(overflows\);/,
|
||||
);
|
||||
// ...and the CSS must be gated on that flag, never on the media query alone.
|
||||
assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/);
|
||||
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
|
||||
assert.match(
|
||||
css,
|
||||
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
|
||||
);
|
||||
const bareFade =
|
||||
css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||
assert.doesNotMatch(
|
||||
bareFade,
|
||||
/mask-image/,
|
||||
'the fade must never apply unconditionally',
|
||||
);
|
||||
// Rotating a phone changes which tiles fit, so it re-measures.
|
||||
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
||||
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
||||
});
|
||||
|
||||
test('the launcher yields on engage and waits when a surface is already up', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// Both directions from one observer: yield if it is up and something takes
|
||||
// the screen; wait to reveal if something already has it.
|
||||
assert.match(
|
||||
@@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', ()
|
||||
// not see a new hold because of onboarding chrome.
|
||||
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
||||
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
||||
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
|
||||
assert.doesNotMatch(
|
||||
module,
|
||||
/setInterval|requestAnimationFrame\(function poll/,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Per-mission behavior ─────────────────────────────────────────────────────
|
||||
|
||||
function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) {
|
||||
function missionSpy({
|
||||
contextOk = true,
|
||||
layerResult = () => true,
|
||||
globe = async () => ({ ok: true }),
|
||||
} = {}) {
|
||||
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
setContextMode: async (mode) => {
|
||||
calls.contextModes.push(mode);
|
||||
return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||
return contextOk
|
||||
? { ok: true, mode }
|
||||
: { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||
},
|
||||
setLayerEnabled: async (layerId) => {
|
||||
calls.layerIds.push(layerId);
|
||||
@@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => {
|
||||
// went is the one-click globe-scale dump. Restoring the tile needs the
|
||||
// globe-LOD declutter first.
|
||||
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
||||
'contacts', 'space-missions', 'environmental', 'explore',
|
||||
'contacts',
|
||||
'space-missions',
|
||||
'environmental',
|
||||
'explore',
|
||||
]);
|
||||
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
|
||||
'the infrastructure mission must be gone, not dormant');
|
||||
assert.equal(
|
||||
FIRST_RUN_MISSIONS.infrastructure,
|
||||
undefined,
|
||||
'the infrastructure mission must be gone, not dormant',
|
||||
);
|
||||
});
|
||||
|
||||
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
|
||||
for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) {
|
||||
for (const [choice, mode] of [
|
||||
['contacts', 'contacts'],
|
||||
['space-missions', 'space-missions'],
|
||||
]) {
|
||||
const spy = missionSpy();
|
||||
const outcome = await runFirstRunChoice(choice, spy.deps);
|
||||
assert.equal(outcome.ok, true);
|
||||
@@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
|
||||
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
|
||||
// post-launch, and the note must stay where the next editor will read it
|
||||
// rather than being re-discovered as a launcher bug.
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const table = module.slice(
|
||||
module.indexOf(' environmental: Object.freeze({'),
|
||||
module.indexOf(' explore:'),
|
||||
);
|
||||
assert.match(table, /KEY REQUIRED/);
|
||||
assert.match(table, /src\/loadingFeedback\.js/);
|
||||
assert.match(table, /LEDGERED post-launch/);
|
||||
@@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant'
|
||||
assert.equal(outcome.ok, true);
|
||||
assert.deepEqual(outcome.failedLayerIds, []);
|
||||
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
||||
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
|
||||
@@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does
|
||||
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
|
||||
|
||||
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
||||
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
|
||||
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
|
||||
const flightDown = missionSpy({
|
||||
globe: () => {
|
||||
throw new Error('cancelled');
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runFirstRunChoice('environmental', flightDown.deps)).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
|
||||
const spy = missionSpy();
|
||||
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
|
||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
||||
assert.deepEqual(spy.calls, {
|
||||
contextModes: [],
|
||||
layerIds: [],
|
||||
globeFlights: 0,
|
||||
});
|
||||
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
|
||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
||||
assert.deepEqual(spy.calls, {
|
||||
contextModes: [],
|
||||
layerIds: [],
|
||||
globeFlights: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed Context mission reports the layers the facade named', async () => {
|
||||
@@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => {
|
||||
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
|
||||
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
||||
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
|
||||
assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title);
|
||||
assert.equal(
|
||||
environmentalLabel().title,
|
||||
environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
|
||||
|
||||
test('no mission writes a preference the visitor did not choose by picking it', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const code = module.slice(
|
||||
module.indexOf('export function shouldShowFirstRun'),
|
||||
);
|
||||
|
||||
// Layer enables ARE durable in this app and a mission tile IS that choice, so
|
||||
// they run at the same origin a click on those rows uses.
|
||||
@@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it',
|
||||
'_setModels3dParams',
|
||||
'setFeather',
|
||||
]) {
|
||||
assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`);
|
||||
assert.doesNotMatch(
|
||||
code,
|
||||
new RegExp(forbidden),
|
||||
`a mission must never touch ${forbidden}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The only durable panel write is the Context reveal, and only on the Context
|
||||
// missions — the globe missions open no panel at all.
|
||||
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
||||
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
|
||||
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
|
||||
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
|
||||
assert.equal(
|
||||
panelWrites.length,
|
||||
1,
|
||||
'exactly one panel reveal, on the Context path',
|
||||
);
|
||||
const contextPath = code.slice(
|
||||
code.indexOf('setContextMode: async (mode)'),
|
||||
code.indexOf('setLayerEnabled:'),
|
||||
);
|
||||
assert.match(
|
||||
contextPath,
|
||||
/result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the decision table is written down where the next editor will read it', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
module,
|
||||
/MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/,
|
||||
);
|
||||
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
|
||||
assert.ok(module.includes(row), `decision table is missing its "${row}" rows`);
|
||||
assert.ok(
|
||||
module.includes(row),
|
||||
`decision table is missing its "${row}" rows`,
|
||||
);
|
||||
}
|
||||
assert.match(module, /SHOW POLICY/);
|
||||
});
|
||||
@@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', ()
|
||||
// ── Markup, startup ordering, accessibility ─────────────────────────────────
|
||||
|
||||
test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
|
||||
const html = expandApplicationHtml(
|
||||
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
const startup = fs.readFileSync(
|
||||
new URL('./app/startupChrome.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
|
||||
assert.match(
|
||||
html,
|
||||
/id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/,
|
||||
);
|
||||
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
|
||||
assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/);
|
||||
assert.match(
|
||||
html,
|
||||
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
|
||||
);
|
||||
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
|
||||
assert.match(html, /<strong data-first-run-environmental-title>/);
|
||||
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
|
||||
// half of what it does is the defect this replaced. Only the VISIBLE <small>
|
||||
// text counts; the comment beside it naturally says the words too.
|
||||
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
|
||||
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
|
||||
const envTile = html.slice(
|
||||
html.indexOf('data-first-run-choice="environmental"'),
|
||||
);
|
||||
const visible = envTile.slice(
|
||||
envTile.indexOf('<small>'),
|
||||
envTile.indexOf('</small>'),
|
||||
);
|
||||
assert.match(visible, /earthquakes/i);
|
||||
assert.match(visible, /fires?/i, 'the tile must promise the fires it enables');
|
||||
assert.match(
|
||||
visible,
|
||||
/fires?/i,
|
||||
'the tile must promise the fires it enables',
|
||||
);
|
||||
|
||||
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
|
||||
// unspaced em dash included. This is copy, not prose to be improved in a
|
||||
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
|
||||
assert.ok(
|
||||
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
|
||||
+ '—then you realize the sources are public and the data is real.</p>'),
|
||||
html.includes(
|
||||
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
|
||||
'—then you realize the sources are public and the data is real.</p>',
|
||||
),
|
||||
'the owner-authored first-run line must ship exactly as written',
|
||||
);
|
||||
|
||||
// Menu order is the owner's, read straight off the markup.
|
||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]);
|
||||
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
|
||||
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
|
||||
'the removed tile must leave no markup behind');
|
||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
assert.deepEqual(order, [
|
||||
'contacts',
|
||||
'space-missions',
|
||||
'environmental',
|
||||
'explore',
|
||||
]);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/data-first-run-choice="infrastructure"/,
|
||||
'the removed tile must leave no markup behind',
|
||||
);
|
||||
|
||||
assert.match(startup, /styleManager\.initialRestorePromise/);
|
||||
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
|
||||
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
|
||||
assert.ok(
|
||||
startup.indexOf("loadingScreen.classList.add('hidden')") <
|
||||
startup.indexOf(
|
||||
"loadingScreen.addEventListener('transitionend', revealFirstRun",
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
startup,
|
||||
/initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/,
|
||||
);
|
||||
|
||||
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
|
||||
assert.match(css, /body\.recording-mode #first-run-launcher/);
|
||||
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
|
||||
// matching one of THOSE would let the launcher's own opt-out be deleted.
|
||||
const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]
|
||||
.map((match) => match[1]);
|
||||
const reducedMotion = [
|
||||
...css.matchAll(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
assert.ok(
|
||||
reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')),
|
||||
reducedMotion.some(
|
||||
(block) =>
|
||||
block.includes('#first-run-launcher') &&
|
||||
block.includes('.first-run-choices'),
|
||||
),
|
||||
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
|
||||
);
|
||||
// The card must be click-through until revealed and again while it fades out.
|
||||
const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible'));
|
||||
const base = css.slice(
|
||||
css.indexOf('#first-run-launcher {'),
|
||||
css.indexOf('#first-run-launcher.visible'),
|
||||
);
|
||||
assert.match(base, /pointer-events: none/);
|
||||
assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/);
|
||||
assert.match(
|
||||
css,
|
||||
/#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/,
|
||||
);
|
||||
// The card is a flex column so its mission list can scroll on a short
|
||||
// viewport — and an AUTHOR `display` on this id outranks the UA's
|
||||
// `[hidden] { display: none }`, which would strand the card in the
|
||||
@@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
});
|
||||
|
||||
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// aria-disabled, never the `disabled` property: disabling a focused button
|
||||
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
||||
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
|
||||
assert.match(
|
||||
module,
|
||||
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
|
||||
);
|
||||
assert.doesNotMatch(module, /button\.disabled = /);
|
||||
// Tab is confined to the launcher, and ESC always releases it.
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(module, /keyboard\.activate\(\)/);
|
||||
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
||||
assert.match(keyboard, /event\.key !== 'Tab'/);
|
||||
@@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
||||
});
|
||||
|
||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/panelPositionControls.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
||||
// now, so it was opening to offer controls for things already happening —
|
||||
// while competing with the mission card for the one first impression there is.
|
||||
@@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
|
||||
);
|
||||
// The first-run default may only apply when NOTHING is stored: a visitor who
|
||||
// opened the rail keeps it open, and one who closed it keeps it closed.
|
||||
const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))'));
|
||||
const block = ui.slice(
|
||||
ui.indexOf(
|
||||
'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))',
|
||||
),
|
||||
);
|
||||
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
|
||||
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
||||
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
|
||||
@@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) => tool.name !== 'set_cyber_sonar',
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, [
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const block = JSON.stringify(legacyTools);
|
||||
@@ -681,7 +1001,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
const instructions = fs.readFileSync(
|
||||
new URL('../server/providers/openai/instructions.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// ...and the mapping that makes them reachable by voice is one instruction
|
||||
// string, whose rollback is deleting that string. Anchored to a LIVE array
|
||||
@@ -692,23 +1015,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
/\n\s+'NAMED VIEWS are shorthand/,
|
||||
'the mission mapping must be an active instruction entry, not commented out',
|
||||
);
|
||||
const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand'));
|
||||
const mapping = instructions.slice(
|
||||
instructions.indexOf('NAMED VIEWS are shorthand'),
|
||||
);
|
||||
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
|
||||
for (const layerId of [
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
]) {
|
||||
assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`);
|
||||
assert.ok(
|
||||
paragraph.includes(layerId),
|
||||
`mapping must name the existing ${layerId} enum value`,
|
||||
);
|
||||
}
|
||||
assert.ok(paragraph.includes('zoom_to_globe'));
|
||||
assert.ok(paragraph.includes('set_layer_visibility'));
|
||||
});
|
||||
|
||||
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
|
||||
const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility');
|
||||
const tool = GEV_REALTIME_TOOLS.find(
|
||||
(tool) => tool.name === 'set_layer_visibility',
|
||||
);
|
||||
const allowedLayers = tool.parameters.properties.layerId.enum;
|
||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []);
|
||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap(
|
||||
(mission) => mission.layerIds || [],
|
||||
);
|
||||
assert.ok(missionLayerIds.length > 0);
|
||||
for (const layerId of missionLayerIds) {
|
||||
assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`);
|
||||
assert.ok(
|
||||
allowedLayers.includes(layerId),
|
||||
`${layerId} must already be an allowed enum value`,
|
||||
);
|
||||
}
|
||||
});
|
||||
+125
-19
@@ -16,8 +16,9 @@ import { applicationServices } from './services/application.js';
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './locations.js';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||
import {
|
||||
ellipsoidalToMslDisplayM,
|
||||
ensureGeoidReady,
|
||||
@@ -27,7 +28,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import {
|
||||
hudSummaryMatchesProvenance,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
hudTelemetryProvenance,
|
||||
isHudSummaryUnconfigured,
|
||||
} from './hudSummaryResponse.js';
|
||||
import {
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -60,6 +63,79 @@ const HUD_COLORS = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mode names per style: the same English as STYLE_STATUS_LABELS
|
||||
* (ui/visualPresets.js), so both translate alike.
|
||||
*/
|
||||
const MODE_LABELS = Object.freeze({
|
||||
normal: msg('NORMAL'),
|
||||
retro: msg('CRT'),
|
||||
surveillance: msg('NVG'),
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
/** Region ids from _regionLabel() and the name shown for each. */
|
||||
const REGION_LABELS = Object.freeze({
|
||||
ARCTIC: msg('ARCTIC'),
|
||||
ANTARCTIC: msg('ANTARCTIC'),
|
||||
'NORTH AMERICA': msg('NORTH AMERICA'),
|
||||
'SOUTH AMERICA': msg('SOUTH AMERICA'),
|
||||
EUROPE: msg('EUROPE'),
|
||||
AFRICA: msg('AFRICA'),
|
||||
ASIA: msg('ASIA'),
|
||||
OCEANIA: msg('OCEANIA'),
|
||||
'NORTHERN OCEANIC GRID': msg('NORTHERN OCEANIC GRID'),
|
||||
'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'),
|
||||
});
|
||||
|
||||
/** Feed-state ids from hudTelemetryProvenance() and the word shown for each. */
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
loading: msg('LOADING'),
|
||||
degraded: msg('DEGRADED'),
|
||||
stale: msg('STALE'),
|
||||
partial: msg('PARTIAL'),
|
||||
fallback: msg('FALLBACK'),
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The summary's feed-state suffix in the active language, e.g.
|
||||
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
|
||||
* layer data; only this line translates them.
|
||||
*/
|
||||
function localizedProvenanceTag(provenance) {
|
||||
if (!provenance) return null;
|
||||
const state = FEED_STATE_LABELS[provenance.state]
|
||||
? t(FEED_STATE_LABELS[provenance.state])
|
||||
: provenance.state.toUpperCase();
|
||||
if (!provenance.names.length) return state;
|
||||
return t('{state} {layers}', {
|
||||
state,
|
||||
layers: provenance.names
|
||||
.map((name) => localizeLayerName(name).toUpperCase())
|
||||
.join('/'),
|
||||
});
|
||||
}
|
||||
|
||||
/** The name shown for a _viewBand() id. */
|
||||
function viewBandLabel(band) {
|
||||
switch (band) {
|
||||
case 'STREET':
|
||||
return t('STREET', { context: 'view band' });
|
||||
case 'CITY':
|
||||
return t('CITY', { context: 'view band' });
|
||||
case 'METRO':
|
||||
return t('METRO', { context: 'view band' });
|
||||
case 'REGIONAL':
|
||||
return t('REGIONAL', { context: 'view band' });
|
||||
default:
|
||||
return t('GLOBAL', { context: 'view band' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||
|
||||
@@ -185,6 +261,7 @@ export class IntelHUD {
|
||||
this._el = document.getElementById('intel-hud');
|
||||
if (!this._el) return;
|
||||
|
||||
// i18n-ignore: sensor codes and a classification banner; the rest uses t()
|
||||
this._el.innerHTML = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
@@ -199,17 +276,17 @@ export class IntelHUD {
|
||||
<div class="hud-content">
|
||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||
<div class="hud-mode" id="hud-mode">NORMAL</div>
|
||||
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
|
||||
<div class="hud-summary-wrap">
|
||||
<div class="hud-summary-label">SUMMARY</div>
|
||||
<div class="hud-summary" id="hud-summary">Awaiting telemetry...</div>
|
||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud-corner hud-top-right">
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> REC <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
|
||||
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
||||
</div>
|
||||
<div class="hud-bracket">┐</div>
|
||||
@@ -227,7 +304,7 @@ export class IntelHUD {
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
||||
</div>
|
||||
<div class="hud-bracket">┘</div>
|
||||
</div>
|
||||
@@ -355,9 +432,11 @@ export class IntelHUD {
|
||||
const formatted = this._formatMGRS(mgrsStr);
|
||||
mgrsLabel = formatted;
|
||||
const el = document.getElementById('hud-mgrs');
|
||||
// i18n-ignore: sensor codes
|
||||
if (el) el.textContent = `MGRS: ${formatted}`;
|
||||
} catch {
|
||||
const el = document.getElementById('hud-mgrs');
|
||||
// i18n-ignore: sensor codes
|
||||
if (el) el.textContent = 'MGRS: ---';
|
||||
}
|
||||
|
||||
@@ -366,6 +445,7 @@ export class IntelHUD {
|
||||
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
||||
const bottomEl = document.getElementById('hud-bottom-line');
|
||||
if (bottomEl) {
|
||||
// i18n-ignore: sensor codes
|
||||
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
||||
}
|
||||
|
||||
@@ -381,6 +461,7 @@ export class IntelHUD {
|
||||
);
|
||||
const gsdEl = document.getElementById('hud-gsd');
|
||||
if (gsdEl)
|
||||
// i18n-ignore: sensor codes
|
||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
|
||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||
@@ -393,6 +474,7 @@ export class IntelHUD {
|
||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||
if (altEl)
|
||||
// i18n-ignore: sensor codes
|
||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
@@ -402,6 +484,7 @@ export class IntelHUD {
|
||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||
// i18n-ignore: sensor codes
|
||||
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
|
||||
}
|
||||
|
||||
@@ -410,6 +493,7 @@ export class IntelHUD {
|
||||
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
||||
const ona = Math.max(0, 90 + pitchDeg);
|
||||
const onaEl = document.getElementById('hud-ona');
|
||||
// i18n-ignore: sensor codes
|
||||
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
||||
|
||||
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
||||
@@ -634,10 +718,10 @@ export class IntelHUD {
|
||||
*/
|
||||
_composeSummary() {
|
||||
const m = this._latestMetrics;
|
||||
if (!m) return 'Awaiting telemetry...';
|
||||
if (!m) return t('Awaiting telemetry...');
|
||||
|
||||
const modeEl = document.getElementById('hud-mode');
|
||||
const modeLabel = modeEl?.textContent || 'NORMAL';
|
||||
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
|
||||
const region = this._regionLabel(m.latDeg, m.lonDeg);
|
||||
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
|
||||
const band = this._viewBand(m.altM);
|
||||
@@ -649,20 +733,41 @@ export class IntelHUD {
|
||||
// together, so they must never disagree. The view band above deliberately
|
||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
||||
// Display only: the AI summary request carries its own context, so this
|
||||
// line is built in the active language.
|
||||
const altTag =
|
||||
altDisplayM >= 1000
|
||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||
: `${Math.round(altDisplayM)}M`;
|
||||
? hudQuantity(altDisplayM / 1000, 'km', 1)
|
||||
: hudQuantity(Math.round(altDisplayM), 'm');
|
||||
const winTag = window
|
||||
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
|
||||
: 'N/A';
|
||||
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
|
||||
: t('N/A');
|
||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
||||
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
||||
const localityTag = composeLocalityTag(
|
||||
nearest,
|
||||
m.latDeg,
|
||||
m.lonDeg,
|
||||
LOCALIZED_LOCALITY,
|
||||
);
|
||||
|
||||
const provenance = hudTelemetryProvenanceTag(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
const provenance = localizedProvenanceTag(
|
||||
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
||||
);
|
||||
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||
const line = t(
|
||||
'{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
|
||||
{
|
||||
mode: modeLabel,
|
||||
band: viewBandLabel(band),
|
||||
locality: localityTag,
|
||||
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
|
||||
alt: altTag,
|
||||
window: winTag,
|
||||
sun: formatNumber(Math.round(m.sunEl)),
|
||||
ona: formatNumber(Math.round(m.ona)),
|
||||
zone: localTag,
|
||||
},
|
||||
);
|
||||
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
||||
return provenance ? `${line} | ${provenance}` : line;
|
||||
}
|
||||
|
||||
@@ -818,8 +923,9 @@ export class IntelHUD {
|
||||
// Update mode label
|
||||
const modeEl = document.getElementById('hud-mode');
|
||||
if (modeEl) {
|
||||
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
|
||||
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
|
||||
modeEl.textContent = MODE_LABELS[styleName]
|
||||
? t(MODE_LABELS[styleName])
|
||||
: styleName.toUpperCase();
|
||||
}
|
||||
// Update color scheme
|
||||
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
|
||||
|
||||
@@ -22,10 +22,12 @@ test('HUD sliders and location search have descriptive explicit names', () => {
|
||||
});
|
||||
|
||||
test('the first-run checkbox keeps its native visible label', () => {
|
||||
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span>Don't show this again<\/span>\s*<\/label>/);
|
||||
// The span may carry a data-i18n extraction attribute; the native wrapping
|
||||
// label contract is what this guards.
|
||||
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
|
||||
});
|
||||
|
||||
test('generated style sliders use the visible parameter label as their name', () => {
|
||||
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
|
||||
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
|
||||
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
|
||||
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
|
||||
});
|
||||
+23
-3
@@ -23,6 +23,14 @@
|
||||
*/
|
||||
export const NEAR_POI_MAX_KM = 150;
|
||||
|
||||
/** The English locality line; the HUD passes hudLocalityFormat.js instead. */
|
||||
const ENGLISH_LOCALITY = Object.freeze({
|
||||
near: ({ poi, city, distKm }) =>
|
||||
`NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`,
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
|
||||
* @param {number} value Signed decimal degrees.
|
||||
@@ -42,13 +50,25 @@ function coordinateTag(value, positive, negative) {
|
||||
* catalogue is empty.
|
||||
* @param {number} latDeg Camera latitude in decimal degrees.
|
||||
* @param {number} lonDeg Camera longitude in decimal degrees.
|
||||
* @param {{near: Function, sector: Function}} [format] Builds the line from
|
||||
* `{ poi, city, distKm }` or `{ latDeg, lonDeg }`; names arrive as
|
||||
* catalogued (`Tokyo`), not upper-cased.
|
||||
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
||||
* otherwise `SECTOR <lat> <lon>`.
|
||||
*/
|
||||
export function composeLocalityTag(nearest, latDeg, lonDeg) {
|
||||
export function composeLocalityTag(
|
||||
nearest,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
format = ENGLISH_LOCALITY,
|
||||
) {
|
||||
const distKm = Number(nearest?.distKm);
|
||||
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
||||
return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
|
||||
return format.near({
|
||||
poi: String(nearest.poi),
|
||||
city: String(nearest.city),
|
||||
distKm,
|
||||
});
|
||||
}
|
||||
return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
|
||||
return format.sector({ latDeg, lonDeg });
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { composeLocalityTag, NEAR_POI_MAX_KM } from './hudLocality.js';
|
||||
import { LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
const ALCATRAZ = { poi: 'Alcatraz Island', city: 'San Francisco' };
|
||||
const LINCOLN = { poi: 'Lincoln Memorial', city: 'Washington DC' };
|
||||
@@ -17,7 +19,7 @@ test('the NEAR bound is metro scale, not continental', () => {
|
||||
|
||||
test('a landmark under the camera reads NEAR', () => {
|
||||
assert.equal(
|
||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
|
||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
|
||||
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -40,20 +42,42 @@ test('the field failures now fall through to the SECTOR readout', () => {
|
||||
});
|
||||
|
||||
test('the boundary is pinned on both sides, inclusive at the bound', () => {
|
||||
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
|
||||
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
|
||||
const at = composeLocalityTag(
|
||||
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM },
|
||||
40,
|
||||
-78,
|
||||
);
|
||||
assert.match(
|
||||
at,
|
||||
/^NEAR LINCOLN MEMORIAL/,
|
||||
'exactly at the bound still reads NEAR',
|
||||
);
|
||||
|
||||
const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
|
||||
const just_under = composeLocalityTag(
|
||||
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
|
||||
40,
|
||||
-78,
|
||||
);
|
||||
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
|
||||
|
||||
const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
|
||||
const just_over = composeLocalityTag(
|
||||
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
|
||||
40,
|
||||
-78,
|
||||
);
|
||||
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
|
||||
});
|
||||
|
||||
test('southern and western hemispheres carry the right suffixes', () => {
|
||||
// Rio and Honolulu — the two the fallback already handled correctly in the field.
|
||||
assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
|
||||
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
|
||||
assert.equal(
|
||||
composeLocalityTag(null, -22.9068, -43.1729),
|
||||
'SECTOR 22.91S 43.17W',
|
||||
);
|
||||
assert.equal(
|
||||
composeLocalityTag(null, 21.3069, -157.8583),
|
||||
'SECTOR 21.31N 157.86W',
|
||||
);
|
||||
});
|
||||
|
||||
// The tests above all pass against a hud.js that still computes the tag inline —
|
||||
@@ -72,7 +96,9 @@ test('hud.js actually composes its summary through this helper', () => {
|
||||
'hud.js must import composeLocalityTag from ./hudLocality.js',
|
||||
);
|
||||
assert.equal(
|
||||
has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
|
||||
has(
|
||||
/const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
|
||||
),
|
||||
true,
|
||||
'_composeSummary must build its locality tag through composeLocalityTag()',
|
||||
);
|
||||
@@ -91,6 +117,41 @@ test('hud.js actually composes its summary through this helper', () => {
|
||||
test('a missing or malformed nearest POI never crashes the summary', () => {
|
||||
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
|
||||
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
|
||||
assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
|
||||
assert.match(
|
||||
composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
|
||||
/^SECTOR /,
|
||||
);
|
||||
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
|
||||
});
|
||||
|
||||
test('a formatter builds the line from the same parts', () => {
|
||||
const format = {
|
||||
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
|
||||
sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
|
||||
};
|
||||
assert.equal(
|
||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
|
||||
'near:Alcatraz Island|San Francisco|2.4',
|
||||
);
|
||||
assert.equal(
|
||||
composeLocalityTag(null, 21.3, -157.8, format),
|
||||
'sector:21.3|-157.8',
|
||||
);
|
||||
});
|
||||
|
||||
test('the HUD formatter translates the catalogued city, then upper-cases it', () => {
|
||||
const tokyo = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
|
||||
assert.equal(
|
||||
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
|
||||
'NEAR TOKYO TOWER (TOKYO) 2KM',
|
||||
);
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.match(
|
||||
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
|
||||
/\(ТОКИО\)/,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @module hudLocalityFormat
|
||||
* @description The HUD summary's locality line in the active language — the
|
||||
* formatter `hud.js` hands to `composeLocalityTag`. Kept apart from `hud.js`
|
||||
* (which imports `mgrs`, not loadable under plain Node) so it is unit-testable.
|
||||
*/
|
||||
import { formatNumber, getLocale, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
export function hudQuantity(value, unit, digits = 0) {
|
||||
const number = formatNumber(value, {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${number}${unitLabel(unit).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
|
||||
function hudCoordinate(value, positive, negative) {
|
||||
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
/** The summary's locality line in the active language (see hudLocality.js). */
|
||||
export const LOCALIZED_LOCALITY = Object.freeze({
|
||||
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
|
||||
// language: the place-name table knows `Tokyo`, not `TOKYO`.
|
||||
near: ({ poi, city, distKm }) =>
|
||||
t('NEAR {poi} ({city}) {distance}', {
|
||||
poi: poi.toLocaleUpperCase(getLocale()),
|
||||
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
|
||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
t('SECTOR {lat} {lon}', {
|
||||
lat: hudCoordinate(latDeg, t('N'), t('S')),
|
||||
lon: hudCoordinate(lonDeg, t('E'), t('W')),
|
||||
}),
|
||||
});
|
||||
@@ -83,12 +83,14 @@ export function hudSummaryLayerContext(layers = [], options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic telemetry suffix when an enabled feed is not nominal.
|
||||
* The feed state and up to two layer names for the HUD telemetry suffix when
|
||||
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
|
||||
* id, `names` are `layer.name`); the HUD translates them for display.
|
||||
* @param {Array<object>} [layers]
|
||||
* @param {{ now?: number }} [options]
|
||||
* @returns {string|null}
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function hudTelemetryProvenanceTag(layers = [], options) {
|
||||
export function hudTelemetryProvenance(layers = [], options) {
|
||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||
@@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
|
||||
return { state: envelope.overall, names };
|
||||
}
|
||||
|
||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
||||
|
||||
+111
-39
@@ -5,7 +5,7 @@ import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
hudTelemetryProvenance,
|
||||
isHudSummaryUnconfigured,
|
||||
keylessHudSummaryResponse,
|
||||
} from './hudSummaryResponse.js';
|
||||
@@ -30,7 +30,10 @@ function installOpenAiRoutes() {
|
||||
return routes;
|
||||
}
|
||||
|
||||
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
|
||||
function invokeRoute(
|
||||
handler,
|
||||
{ method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const req = {
|
||||
@@ -71,33 +74,54 @@ test('builds an HTTP-success capability response only for a blank key', () => {
|
||||
test('recognizes only the exact deliberate no-key fallback response', () => {
|
||||
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
error: 'provider failed',
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
summary: 'Unexpected provider output',
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
configured: true,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
unexpected: true,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}), false);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
error: 'provider failed',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
summary: 'Unexpected provider output',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
configured: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
unexpected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not hide real provider and HTTP failures', () => {
|
||||
assert.equal(isHudSummaryUnconfigured(502, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}), false);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(502, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, { error: 'provider failed' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
|
||||
@@ -121,14 +145,18 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await invokeRoute(hud, { method: 'POST' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(
|
||||
response.headers['content-type'],
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
||||
}
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = previousKey;
|
||||
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
if (previousLimit === undefined)
|
||||
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
@@ -140,7 +168,12 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
|
||||
stats: {
|
||||
stale: true,
|
||||
count: 12,
|
||||
lastUpdate: now - 240_000,
|
||||
source: 'OpenSky Network',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
@@ -153,27 +186,66 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
||||
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
||||
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
||||
assert.equal(context.feedProvenance.overall, 'stale');
|
||||
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
|
||||
assert.equal(hudTelemetryProvenanceTag([{
|
||||
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
|
||||
}], { now }), null);
|
||||
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
|
||||
state: 'stale',
|
||||
names: ['Live Flights'],
|
||||
});
|
||||
assert.equal(
|
||||
hudTelemetryProvenance(
|
||||
[
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { count: 4, lastUpdate: now },
|
||||
},
|
||||
],
|
||||
{ now },
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/five words MUST include that feedState token/,
|
||||
);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
|
||||
);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||
});
|
||||
|
||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
||||
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
|
||||
const local = readFileSync(
|
||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||
});
|
||||
|
||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
||||
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
|
||||
const { hudSummaryMatchesProvenance } =
|
||||
await import('./hudSummaryResponse.js');
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||
overall: 'stale',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
|
||||
overall: 'stale',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||
overall: 'nominal',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
findUnmarked,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
unmarkedMarkup,
|
||||
unmarkedScript,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { LOCALES } from './locale.js';
|
||||
|
||||
const messages = extractMessages();
|
||||
|
||||
test('every catalog file is a shipped locale and every non-English locale has a catalog', () => {
|
||||
assert.deepEqual(
|
||||
shippedLocales().sort(),
|
||||
LOCALES.filter((locale) => locale !== 'en').sort(),
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of shippedLocales()) {
|
||||
test(`${locale} catalog matches the code (npm run i18n)`, () => {
|
||||
const { missing, stale, errors } = checkCatalog(
|
||||
locale,
|
||||
readCatalog(locale),
|
||||
messages,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ missing, stale, errors },
|
||||
{ missing: [], stale: [], errors: [] },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
|
||||
assert.deepEqual(
|
||||
findUnmarked().map(({ where, text }) => `${where} ${text}`),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
|
||||
const fixture = new Map([
|
||||
['Saved {name}', { plural: false }],
|
||||
['{count} keys', { plural: true, one: '{count} key' }],
|
||||
['Untranslated', { plural: false }],
|
||||
]);
|
||||
const result = checkCatalog(
|
||||
'ru',
|
||||
{
|
||||
'Saved {name}': 'Сохранено {nmae}',
|
||||
'{count} keys': {
|
||||
one: '{count} ключ',
|
||||
few: '{count} ключа',
|
||||
many: '{count} ключей',
|
||||
},
|
||||
Removed: 'Удалено',
|
||||
},
|
||||
fixture,
|
||||
);
|
||||
assert.deepEqual(result.missing, ['Untranslated']);
|
||||
assert.deepEqual(result.stale, ['Removed']);
|
||||
assert.equal(result.errors.length, 2);
|
||||
});
|
||||
|
||||
test('checkCatalog reports a translation left in English unless it is allowed', () => {
|
||||
const fixture = new Map([
|
||||
['Bloom', { plural: false }],
|
||||
['HDG', { plural: false }],
|
||||
['voice|READY', { plural: false }],
|
||||
['{state} · {source}', { plural: false }],
|
||||
]);
|
||||
const catalog = {
|
||||
Bloom: 'Bloom',
|
||||
HDG: 'HDG',
|
||||
'voice|READY': 'READY',
|
||||
'{state} · {source}': '{state} · {source}',
|
||||
};
|
||||
const errors = (allowed) =>
|
||||
checkCatalog('es', catalog, fixture, new Set(allowed)).errors;
|
||||
assert.deepEqual(
|
||||
errors([]).map((error) => error.split(' is still English')[0]),
|
||||
['"Bloom"', '"HDG"', '"voice|READY"'],
|
||||
);
|
||||
assert.deepEqual(errors(['Bloom', 'HDG', 'voice|READY']), []);
|
||||
});
|
||||
|
||||
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
|
||||
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
|
||||
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
|
||||
assert.deepEqual(texts('<span data-i18n>Close</span>'), []);
|
||||
assert.deepEqual(texts('<button title="Pin it">x</button>'), [
|
||||
'title="Pin it"',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts('<button title="Pin it" data-i18n-title>x</button>'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
texts('<select translate="no"><option>0.9 dB</option></select>'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts('<b data-i18n-runtime><i>OFF</i></b>'), []);
|
||||
assert.deepEqual(
|
||||
texts('<span class="material-symbols-outlined">public</span>'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
|
||||
assert.deepEqual(texts("el.textContent = t('Saved');"), []);
|
||||
assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [
|
||||
'On air',
|
||||
'Off air',
|
||||
]);
|
||||
assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [
|
||||
'Open',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts("const row = { label: msg('Wind'), id: 'wind' };"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']);
|
||||
assert.deepEqual(texts("el.textContent = labelFor('flights');"), []);
|
||||
assert.deepEqual(texts("el.className = 'is-active';"), []);
|
||||
assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []);
|
||||
assert.deepEqual(texts("// el.textContent = 'Old copy';"), []);
|
||||
assert.deepEqual(
|
||||
texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('unmarkedScript sees toasts behind optional calls and private helpers', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(texts("showToast?.('North up');"), ['North up']);
|
||||
assert.deepEqual(texts("this._showToast('Search failed');"), [
|
||||
'Search failed',
|
||||
]);
|
||||
assert.deepEqual(texts("this.showToast('Link copied');"), ['Link copied']);
|
||||
assert.deepEqual(texts("this.actions.showToast?.('Tilted view');"), [
|
||||
'Tilted view',
|
||||
]);
|
||||
assert.deepEqual(texts("showToast?.(t('North up'));"), []);
|
||||
assert.deepEqual(texts("this._showToast(t('Search failed'));"), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript sees status text assigned to layer errors and labels', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(texts("layerState._error = 'live feed unavailable';"), [
|
||||
'live feed unavailable',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts("this._error = err?.message || 'TeleGeography load failed';"),
|
||||
['TeleGeography load failed'],
|
||||
);
|
||||
assert.deepEqual(texts("_error = 'Route request failed';"), [
|
||||
'Route request failed',
|
||||
]);
|
||||
assert.deepEqual(texts("error ??= 'Feed closed';"), ['Feed closed']);
|
||||
assert.deepEqual(texts('stats.error = `${name} fetch error`;'), [
|
||||
'fetch error',
|
||||
]);
|
||||
assert.deepEqual(texts("loadingLabel = 'loading coverage...';"), [
|
||||
'loading coverage...',
|
||||
]);
|
||||
assert.deepEqual(texts("state._loadingLabel = 'Loading cables';"), [
|
||||
'Loading cables',
|
||||
]);
|
||||
assert.deepEqual(texts("stats.statusMessage = 'Add a key in Settings';"), [
|
||||
'Add a key in Settings',
|
||||
]);
|
||||
assert.deepEqual(texts("this.status = 'Scene not saved';"), [
|
||||
'Scene not saved',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts("layerState._error = msg('live feed unavailable');"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts("stats.error = t('GBFS fetch error');"), []);
|
||||
// Machine values, comparisons and local scratch variables are not UI text.
|
||||
assert.deepEqual(texts("status = 'missing-key';"), []);
|
||||
assert.deepEqual(texts("if (error === 'Feed closed') stop();"), []);
|
||||
assert.deepEqual(texts("const error = log() || 'Unknown shader error';"), []);
|
||||
assert.deepEqual(texts('stats.error = new Error(`HTTP ${code}`);'), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript sees human-readable status, loadingLabel and statusMessage fields', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(
|
||||
texts("return { status: 'Scene not saved — storage unavailable' };"),
|
||||
['Scene not saved — storage unavailable'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
texts("return { status: ok ? 'Ready' : 'Source unavailable' };"),
|
||||
['Ready', 'Source unavailable'],
|
||||
);
|
||||
assert.deepEqual(texts("return { loadingLabel: 'Loading feeds' };"), [
|
||||
'Loading feeds',
|
||||
]);
|
||||
assert.deepEqual(texts("return { statusMessage: 'connect a receiver' };"), [
|
||||
'connect a receiver',
|
||||
]);
|
||||
assert.deepEqual(texts("return { status: 'streaming', code: 'ok' };"), []);
|
||||
assert.deepEqual(texts("return { status: 'auth-failed' };"), []);
|
||||
assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []);
|
||||
});
|
||||
|
||||
// The Layers panel shows `stats.error || stats.lastError`, so a
|
||||
// lastError is status text like error.
|
||||
test('unmarkedScript sees status text assigned to lastError', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(
|
||||
texts(
|
||||
"state.coverage.lastError = error?.message || 'Coverage tile failed';",
|
||||
),
|
||||
['Coverage tile failed'],
|
||||
);
|
||||
assert.deepEqual(texts("layerState._lastError = 'CelesTrak unreachable';"), [
|
||||
'CelesTrak unreachable',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts(
|
||||
"state.coverage.lastError = error?.message || msg('Coverage tile failed');",
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts('this._lastError = message;'), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript skips default values and ternary branches, not later assignments', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
// A default value in a parameter list or destructuring is a local's.
|
||||
assert.deepEqual(texts("function f(error = 'Feed went away') {}"), []);
|
||||
assert.deepEqual(texts("const f = (status = 'Not ready', x) => x;"), []);
|
||||
assert.deepEqual(
|
||||
texts("function f(\n status = 'Not ready',\n x,\n) {}"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts("const { status = 'Ready now' } = options;"), []);
|
||||
assert.deepEqual(
|
||||
texts("const {\n error = 'No data yet',\n} = options;"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
texts("for (const [id, status = 'Waiting'] of rows) {}"),
|
||||
[],
|
||||
);
|
||||
// A name right after `?` is a ternary branch, not a `status:` property.
|
||||
assert.deepEqual(texts("const s = ok ? status : 'Source unavailable';"), []);
|
||||
assert.deepEqual(texts("const s = ok\n ? title\n : 'Untitled view';"), []);
|
||||
// …while the property itself still is.
|
||||
assert.deepEqual(texts("return { title: x ? title : 'Untitled view' };"), [
|
||||
'Untitled view',
|
||||
]);
|
||||
// A later assignment is checked even when the name is a declared local:
|
||||
// a layer keeps its status in `let error` (wind, AIS watchdog) and builds
|
||||
// `loadingLabel` in a `let` before returning it (FIRMS).
|
||||
assert.deepEqual(
|
||||
texts(
|
||||
"function f() {\n let error = null;\n if (x) error = 'Feed closed';\n}",
|
||||
),
|
||||
['Feed closed'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
texts(
|
||||
"let loadingLabel = '';\nif (x) {\n loadingLabel = 'KEY REQUIRED';\n}",
|
||||
),
|
||||
['KEY REQUIRED'],
|
||||
);
|
||||
assert.deepEqual(texts("let a = 1,\n error = 'Feed closed';"), [
|
||||
'Feed closed',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// An empty or blank translation must not count as done.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { getCatalog, setLocale, t, tn, translateKnown } from './index.js';
|
||||
|
||||
const messages = extractMessages();
|
||||
const EMPTY = 'DISCONNECT';
|
||||
const BLANK = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: checkCatalog rejects an empty or blank translation`, () => {
|
||||
const catalog = { ...readCatalog(locale), [EMPTY]: '', [BLANK]: ' ' };
|
||||
const { errors } = checkCatalog(locale, catalog, messages);
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes(`"${EMPTY}"`)),
|
||||
'empty string passes the check',
|
||||
);
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes(`"${BLANK}"`)),
|
||||
'blank string passes the check',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('t() and translateKnown() fall back to English for an empty entry', () => {
|
||||
const es = getCatalog('es');
|
||||
const saved = es[EMPTY];
|
||||
es[EMPTY] = '';
|
||||
setLocale('es');
|
||||
try {
|
||||
assert.equal(t(EMPTY), EMPTY);
|
||||
assert.equal(translateKnown(EMPTY), EMPTY);
|
||||
} finally {
|
||||
es[EMPTY] = saved;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('tn() skips an empty plural form', () => {
|
||||
const es = getCatalog('es');
|
||||
const KEY = '{count} INPUTS UNKNOWN';
|
||||
const saved = es[KEY];
|
||||
setLocale('es');
|
||||
try {
|
||||
es[KEY] = { ...saved, one: ' ' };
|
||||
assert.equal(
|
||||
tn(1, '{count} INPUT UNKNOWN', KEY),
|
||||
saved.other.replace('{count}', '1'),
|
||||
);
|
||||
es[KEY] = { one: '', many: '', other: '' };
|
||||
assert.equal(tn(1, '{count} INPUT UNKNOWN', KEY), '1 INPUT UNKNOWN');
|
||||
assert.equal(tn(3, '{count} INPUT UNKNOWN', KEY), '3 INPUTS UNKNOWN');
|
||||
} finally {
|
||||
es[KEY] = saved;
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
applyDocumentTranslations,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
msg,
|
||||
persistLocaleAndReload,
|
||||
setLocale,
|
||||
t,
|
||||
tn,
|
||||
} from './index.js';
|
||||
import {
|
||||
LOCALE_STORAGE_KEY,
|
||||
applyDocumentLanguage,
|
||||
normalizeLocale,
|
||||
resolveLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
|
||||
afterEach(() => setLocale('en'));
|
||||
|
||||
function memoryStorage(initial = {}) {
|
||||
const data = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => (data.has(key) ? data.get(key) : null),
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
|
||||
assert.equal(normalizeLocale('ru-RU'), 'ru');
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
});
|
||||
|
||||
test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => {
|
||||
const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' });
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '?lang=en' },
|
||||
storage,
|
||||
languages: ['ru'],
|
||||
}),
|
||||
'en',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({ location: { search: '' }, storage, languages: ['en'] }),
|
||||
'ru',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '?lang=xx' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de-DE', 'ru-RU'],
|
||||
}),
|
||||
'ru',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de'],
|
||||
}),
|
||||
'en',
|
||||
);
|
||||
});
|
||||
|
||||
test('a throwing storage never escapes', () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
resolveLocale({ location: { search: '' }, storage, languages: [] }),
|
||||
'en',
|
||||
);
|
||||
assert.equal(writeStoredLocale('ru', storage), false);
|
||||
});
|
||||
|
||||
test('applyDocumentLanguage sets lang and dir', () => {
|
||||
const doc = { documentElement: {} };
|
||||
applyDocumentLanguage(doc, 'ru-RU');
|
||||
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
|
||||
});
|
||||
|
||||
test('t() returns English text in English and translates in Russian', () => {
|
||||
assert.equal(t('POWERED UP'), 'POWERED UP');
|
||||
setLocale('ru');
|
||||
assert.notEqual(t('POWERED UP'), 'POWERED UP');
|
||||
});
|
||||
|
||||
test('t() interpolates named values and keeps unknown names literal', () => {
|
||||
assert.equal(
|
||||
t('Save failed: {detail}', { detail: 'disk full' }),
|
||||
'Save failed: disk full',
|
||||
);
|
||||
assert.equal(
|
||||
t('Hello {who} {missing}', { who: 'pilot' }),
|
||||
'Hello pilot {missing}',
|
||||
);
|
||||
});
|
||||
|
||||
test('t() falls back to English for text without a translation', () => {
|
||||
setLocale('ru');
|
||||
assert.equal(t('No such message {n}', { n: 1 }), 'No such message 1');
|
||||
});
|
||||
|
||||
test('tn() picks English and Russian plural forms', () => {
|
||||
const label = (count) =>
|
||||
tn(
|
||||
count,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
);
|
||||
assert.equal(label(1), 'POWER UP · 1 KEY WAITING');
|
||||
assert.equal(label(3), 'POWER UP · 3 KEYS WAITING');
|
||||
setLocale('ru');
|
||||
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯЁ])/);
|
||||
assert.match(label(3), / 3 КЛЮЧА(?![А-ЯЁ])/);
|
||||
assert.match(label(5), / 5 КЛЮЧЕЙ(?![А-ЯЁ])/);
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
|
||||
});
|
||||
|
||||
test('t() context separates identical English with different meanings', () => {
|
||||
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
|
||||
setLocale('ru');
|
||||
assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
|
||||
});
|
||||
|
||||
test('msg() returns its text unchanged', () => {
|
||||
assert.equal(msg('Live Flights'), 'Live Flights');
|
||||
});
|
||||
|
||||
test('Intl formatters follow the active locale', () => {
|
||||
const date = new Date(Date.UTC(2026, 0, 15));
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
};
|
||||
assert.equal(formatDate(date, options), 'January 15, 2026');
|
||||
assert.equal(formatNumber(1234.5), '1,234.5');
|
||||
setLocale('ru');
|
||||
assert.equal(formatDate(date, options), '15 января 2026 г.');
|
||||
assert.equal(formatNumber(1234.5), '1 234,5');
|
||||
});
|
||||
|
||||
function fakeElement({ text = '', attributes = {} } = {}) {
|
||||
const attrs = new Map(Object.entries(attributes));
|
||||
return {
|
||||
textContent: text,
|
||||
getAttribute: (name) => (attrs.has(name) ? attrs.get(name) : null),
|
||||
setAttribute: (name, value) => attrs.set(name, value),
|
||||
attrs,
|
||||
};
|
||||
}
|
||||
|
||||
test('applyDocumentTranslations translates opted-in text and attributes in place', () => {
|
||||
const label = fakeElement({ text: '\n POWERED\n UP ' });
|
||||
const pin = fakeElement({ attributes: { title: 'POWERED UP' } });
|
||||
const unknown = fakeElement({ text: 'No such message' });
|
||||
const doc = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '[data-i18n]') return [label, unknown];
|
||||
if (selector === '[data-i18n-title]') return [pin];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
applyDocumentTranslations(doc),
|
||||
0,
|
||||
'English leaves markup alone',
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.equal(applyDocumentTranslations(doc), 2);
|
||||
assert.equal(label.textContent, t('POWERED UP'));
|
||||
assert.equal(pin.attrs.get('title'), t('POWERED UP'));
|
||||
assert.equal(unknown.textContent, 'No such message');
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
|
||||
const calls = [];
|
||||
const location = {
|
||||
href: 'https://gev.local/?x=1#scene=a',
|
||||
assign: (url) => calls.push(['assign', url]),
|
||||
reload: () => calls.push(['reload']),
|
||||
};
|
||||
const history = {
|
||||
replaceState: (_s, _t, url) => calls.push(['replace', url]),
|
||||
};
|
||||
const storage = {
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
persistLocaleAndReload('ru', { location, history, storage }),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
|
||||
['reload'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
|
||||
const storage = memoryStorage();
|
||||
const calls = [];
|
||||
const location = {
|
||||
href: 'https://gev.local/?lang=en&x=1#scene=a',
|
||||
assign: (url) => calls.push(['assign', url]),
|
||||
reload: () => calls.push(['reload']),
|
||||
};
|
||||
const history = {
|
||||
replaceState: (_s, _t, url) => calls.push(['replace', url]),
|
||||
};
|
||||
assert.equal(
|
||||
persistLocaleAndReload('ru', { location, history, storage }),
|
||||
true,
|
||||
);
|
||||
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru');
|
||||
assert.deepEqual(calls, [
|
||||
['replace', 'https://gev.local/?x=1#scene=a'],
|
||||
['reload'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('tn() shows a caller-formatted count and still picks the form by number', () => {
|
||||
const label = (n) =>
|
||||
tn(n, '{count} map object', '{count} map objects', {
|
||||
count: formatNumber(n),
|
||||
});
|
||||
assert.equal(label(12345), '12,345 map objects');
|
||||
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
|
||||
setLocale('ru');
|
||||
try {
|
||||
assert.equal(label(12345), '12 345 объектов на карте');
|
||||
assert.equal(label(1), '1 объект на карте');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
// God's Eye View i18n: the English source text is the message key.
|
||||
//
|
||||
// t('Save keys') → 'Сохранить ключи'
|
||||
// t('Cleared {name}', { name }) → named interpolation
|
||||
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
|
||||
// t('CLEAR', { context: 'action' }) → same English, own entry
|
||||
// msg('Live Flights') → marks a string kept in data
|
||||
// for a later t(); returns it as is
|
||||
//
|
||||
// English needs no catalog. Every other locale is one flat JSON file,
|
||||
// locales/<code>.json, keyed by the English text (for tn, by the English
|
||||
// plural form). A missing or empty entry falls back to English.
|
||||
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
normalizeLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({ es, ru });
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
/** The catalog for a locale; null for English and unknown codes. */
|
||||
export function getCatalog(locale) {
|
||||
return CATALOGS[normalizeLocale(locale)] ?? null;
|
||||
}
|
||||
|
||||
/** Active locale. */
|
||||
export function getLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale. Pure state change: call applyDocumentLanguage /
|
||||
* applyDocumentTranslations to reflect it in the DOM.
|
||||
* @returns {string} The locale actually applied (normalized).
|
||||
*/
|
||||
export function setLocale(locale) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
if (normalized !== currentLocale) {
|
||||
currentLocale = normalized;
|
||||
formatterCache.clear();
|
||||
}
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
/** Development-only warning; silent under node:test and in production. */
|
||||
function warnMissing(text) {
|
||||
if (import.meta.env?.DEV !== true) return;
|
||||
console.warn(
|
||||
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
|
||||
|
||||
/** Named {placeholder} interpolation. Unknown names stay literal so a gap
|
||||
* stays visible instead of silently vanishing. */
|
||||
function interpolate(pattern, values) {
|
||||
if (!values) return pattern;
|
||||
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
|
||||
Object.hasOwn(values, name) ? String(values[name]) : literal,
|
||||
);
|
||||
}
|
||||
|
||||
/** A catalog string with something to show; an empty one counts as missing. */
|
||||
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
||||
|
||||
function lookup(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog) return undefined;
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
|
||||
warnMissing(text);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate English source text into the active locale.
|
||||
* @param {string} text English text, e.g. 'Hold Space to talk'
|
||||
* @param {object} [values] Named interpolation values. `context` tells
|
||||
* apart identical English with different meanings: t('CLEAR', { context:
|
||||
* 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(text, values) {
|
||||
const entry = lookup(values?.context ? `${values.context}|${text}` : text);
|
||||
return interpolate(typeof entry === 'string' ? entry : text, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a count-dependent message. The catalog entry for `other` holds
|
||||
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
|
||||
* for Russian). `{count}` is always available to the pattern: the raw number,
|
||||
* unless `values.count` gives its display text, e.g. `formatNumber(n)`.
|
||||
* @param {number} count
|
||||
* @param {string} one English singular, e.g. '{count} camera'
|
||||
* @param {string} other English plural, e.g. '{count} cameras'
|
||||
* @param {object} [values] Extra interpolation values; a `count` here is
|
||||
* shown in place of the number (the plural form still follows `count`)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tn(count, one, other, values) {
|
||||
const entry = lookup(other);
|
||||
const category = pluralRules(currentLocale).select(count);
|
||||
let pattern;
|
||||
const forms = entry && typeof entry === 'object' ? entry : null;
|
||||
if (forms && (filled(forms[category]) || filled(forms.other))) {
|
||||
pattern = filled(forms[category]) ? forms[category] : forms.other;
|
||||
} else {
|
||||
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
|
||||
}
|
||||
return interpolate(pattern, { count, ...values });
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate text that may be ours or may come from a provider: a catalog
|
||||
* entry is used when there is one, anything else shows as given, without a
|
||||
* missing-translation warning.
|
||||
*/
|
||||
export function translateKnown(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog || typeof text !== 'string') return text;
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
return filled(entry) ? entry : text;
|
||||
}
|
||||
|
||||
/** Mark English text that is stored in data and translated later by t(). */
|
||||
export function msg(text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const pluralRulesCache = new Map();
|
||||
|
||||
function pluralRules(locale) {
|
||||
let rules = pluralRulesCache.get(locale);
|
||||
if (!rules) {
|
||||
rules = new Intl.PluralRules(locale);
|
||||
pluralRulesCache.set(locale, rules);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/*
|
||||
* Intl formatters are memoized per (kind, locale, options): the constructors
|
||||
* are expensive and panels format on refresh paths. The cache is cleared
|
||||
* whenever the active locale changes.
|
||||
*/
|
||||
const formatterCache = new Map();
|
||||
|
||||
function cachedFormatter(kind, Constructor, options) {
|
||||
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
|
||||
let formatter = formatterCache.get(cacheKey);
|
||||
if (!formatter) {
|
||||
formatter = new Constructor(currentLocale, options);
|
||||
formatterCache.set(cacheKey, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/** Locale-aware number formatting (Intl.NumberFormat). */
|
||||
export function formatNumber(value, options) {
|
||||
return cachedFormatter('number', Intl.NumberFormat, options).format(value);
|
||||
}
|
||||
|
||||
/** Locale-aware date/time formatting (Intl.DateTimeFormat). */
|
||||
export function formatDate(value, options) {
|
||||
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
|
||||
}
|
||||
|
||||
const UTC_DAY_MONTH = Object.freeze({
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
|
||||
* English, "13.10" in Russian, "13/10" in Spanish, never read as a
|
||||
* range. Takes an ISO string (its date part is used as written) or a time.
|
||||
* @param {string|number|Date} value
|
||||
* @returns {string} '' when the value is not a time.
|
||||
*/
|
||||
export function formatUtcDayMonth(value) {
|
||||
const written =
|
||||
typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
||||
const time = written
|
||||
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
|
||||
: new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return '';
|
||||
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
|
||||
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
|
||||
time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
|
||||
*/
|
||||
export function formatRelativeTime(value, unit = 'second', options) {
|
||||
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
numeric: 'auto',
|
||||
...options,
|
||||
}).format(value, unit);
|
||||
}
|
||||
|
||||
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
|
||||
export function normalizeMarkupText(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup opt-in: the element's own English text or attribute is the source.
|
||||
* <span data-i18n>Normal</span>
|
||||
* <button title="Pin" data-i18n-title>…</button>
|
||||
* Also data-i18n-aria-label and data-i18n-placeholder.
|
||||
*/
|
||||
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
|
||||
['data-i18n', null],
|
||||
['data-i18n-title', 'title'],
|
||||
['data-i18n-aria-label', 'aria-label'],
|
||||
['data-i18n-placeholder', 'placeholder'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Translate every opted-in element in `doc`. Runs once at boot; switching
|
||||
* locale reloads the page.
|
||||
* @param {Document|Element} doc
|
||||
* @returns {number} Count of element/attribute writes performed.
|
||||
*/
|
||||
export function applyDocumentTranslations(doc) {
|
||||
if (typeof doc?.querySelectorAll !== 'function') return 0;
|
||||
if (!CATALOGS[currentLocale]) return 0;
|
||||
let applied = 0;
|
||||
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
|
||||
for (const element of doc.querySelectorAll(`[${marker}]`)) {
|
||||
const source = attribute
|
||||
? element.getAttribute(attribute)
|
||||
: normalizeMarkupText(element.textContent);
|
||||
if (!source) continue;
|
||||
const text = t(source);
|
||||
if (text === source) continue;
|
||||
if (attribute) element.setAttribute(attribute, text);
|
||||
else element.textContent = text;
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
const unsavedWorkChecks = new Set();
|
||||
|
||||
/**
|
||||
* Name work that switching language would lose: the switch reloads the page,
|
||||
* and the language switch asks first while any check reports something.
|
||||
* @param {() => string|null|undefined} check Returns a msg() label (e.g.
|
||||
* msg('unsaved CCTV calibration')) while there is such work, else nothing.
|
||||
* @returns {() => void} Removes the check.
|
||||
*/
|
||||
export function registerUnsavedWork(check) {
|
||||
if (typeof check !== 'function') return () => {};
|
||||
unsavedWorkChecks.add(check);
|
||||
return () => unsavedWorkChecks.delete(check);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reload would lose right now, translated, without repeats.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function describeUnsavedWork() {
|
||||
const labels = new Set();
|
||||
for (const check of unsavedWorkChecks) {
|
||||
try {
|
||||
const label = check();
|
||||
if (typeof label === 'string' && label) labels.add(t(label));
|
||||
} catch {
|
||||
// A failing check must not block the switch.
|
||||
}
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||
* and stripping ?lang from the search string, so the stored choice applies.
|
||||
* Where storage is blocked, ?lang carries the choice instead (share links
|
||||
* drop it: ShareLinkManager.copyLink).
|
||||
* @returns {boolean} true when a reload was triggered.
|
||||
*/
|
||||
export function persistLocaleAndReload(
|
||||
locale,
|
||||
{
|
||||
location = globalThis.location,
|
||||
history = globalThis.history,
|
||||
storage,
|
||||
} = {},
|
||||
) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
const stored = writeStoredLocale(normalized, storage);
|
||||
if (!location || typeof location.assign !== 'function') return false;
|
||||
let target = location.href;
|
||||
try {
|
||||
// Without storage the choice only survives in the URL, so keep it there.
|
||||
const url = new URL(location.href);
|
||||
if (stored) url.searchParams.delete('lang');
|
||||
else url.searchParams.set('lang', normalized);
|
||||
target = url.href;
|
||||
} catch {
|
||||
// Keep the current href.
|
||||
}
|
||||
// location.assign() to the identical URL does not navigate, so strip ?lang
|
||||
// in place and force a real reload instead.
|
||||
try {
|
||||
history?.replaceState?.(null, '', target);
|
||||
} catch {
|
||||
// replaceState is best-effort; the reload below still applies.
|
||||
}
|
||||
if (typeof location.reload === 'function') {
|
||||
location.reload();
|
||||
} else {
|
||||
location.assign(target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// KartaView texts are English data (msg() in providers/kartaview/) shown
|
||||
// through translateKnown, so each needs an entry in every catalog.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from './index.js';
|
||||
|
||||
const MESSAGES = [
|
||||
// kartaview/policy.js
|
||||
'KartaView is rate-limiting requests — they resume on their own',
|
||||
'KartaView’s photos here are archived and cannot be shown',
|
||||
'KartaView did not filter this area in time — move the map to ask again',
|
||||
'None of KartaView’s photos here pass the imagery filter',
|
||||
// kartaview/source.js
|
||||
'KartaView did not answer in time — try again',
|
||||
'KartaView could not be reached — check the network',
|
||||
'KartaView sent an answer that could not be read',
|
||||
'KartaView is rate-limiting requests',
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
'KartaView sent too much data',
|
||||
'KartaView has no such photo',
|
||||
// kartaview/viewer.js, index.js
|
||||
'The KartaView photo could not be loaded',
|
||||
'KartaView has no position for this photo',
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`KartaView provider messages are translated in ${locale}`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const untranslated = MESSAGES.filter((m) => translateKnown(m) === m);
|
||||
assert.deepEqual(untranslated, []);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// kartaview/source.js refusals with a number in them: English data, shown
|
||||
// through placeholder entries by the Street Level panel and the layer panel.
|
||||
test('KartaView HTTP and refusal codes keep their number in every language', async () => {
|
||||
const { translateStreetLevelError } =
|
||||
await import('../ui/streetLevelPresentation.js');
|
||||
const http = 'KartaView answered HTTP 503';
|
||||
const refused = 'KartaView refused the request (code 610)';
|
||||
assert.equal(translateStreetLevelError(http), http);
|
||||
assert.equal(translateStreetLevelError(refused), refused);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const shownHttp = translateStreetLevelError(http);
|
||||
const shownRefused = translateStreetLevelError(refused);
|
||||
assert.notEqual(shownHttp, http);
|
||||
assert.match(shownHttp, /503/);
|
||||
assert.notEqual(shownRefused, refused);
|
||||
assert.match(shownRefused, /610/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { setLocale, translateKnown } from './index.js';
|
||||
|
||||
// Layers keep these status texts as English data, and the panels show them
|
||||
// through translateKnown, so each needs an entry in every catalog.
|
||||
const SITES = [
|
||||
['../layers/streetLevel/index.js', 'loading coverage...'],
|
||||
['../layers/firms/ingestion.js', 'live feed unavailable'],
|
||||
['../layers/bikeshare/controls.js', 'GBFS fetch error'],
|
||||
[
|
||||
'../layers/streetLevel/viewerHost.js',
|
||||
'Open the Street Level panel to view imagery',
|
||||
],
|
||||
['../layers/streetLevel/viewerHost.js', 'Image could not be opened'],
|
||||
['../layers/satellites/ingestion.js', 'CelesTrak unreachable'],
|
||||
[
|
||||
'../layers/streetLevel/providers/mapillary/policy.js',
|
||||
'Coverage requests are being rate-limited — they resume on their own',
|
||||
],
|
||||
['../layers/alpr/index.js', 'Camera source temporarily unavailable'],
|
||||
['../layers/perimeters/index.js', 'Perimeter source unavailable'],
|
||||
['../layers/recentImagery/index.js', 'Imagery catalog unavailable'],
|
||||
['../layers/installations/ingestion.js', 'Installation context unavailable'],
|
||||
['../layers/earthquakes/index.js', 'Earthquake source unavailable'],
|
||||
['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'],
|
||||
['../layers/vessels/ingestion.js', 'AIS live load failed'],
|
||||
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
|
||||
['../layers/wind/index.js', 'Wind unavailable'],
|
||||
['../layers/wind/source.js', 'Wind request timed out'],
|
||||
['../layers/wind/source.js', 'Malformed wind manifest'],
|
||||
['../layers/wind/source.js', 'Malformed wind grid'],
|
||||
['../layers/wind/fields.js', 'Temperature field unavailable'],
|
||||
['../layers/wind/fields.js', 'Mean sea level pressure field unavailable'],
|
||||
];
|
||||
|
||||
// A fallback that reaches the panel must be marked, or `npm run i18n` cannot
|
||||
// see it and the catalog entry would be pruned as unused.
|
||||
for (const [file, text] of SITES) {
|
||||
test(`"${text}" in ${file} is marked`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
new RegExp(
|
||||
String.raw`\b(?:msg|t)\(\s*'${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`,
|
||||
).test(source),
|
||||
`${text} is not marked in ${file}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
for (const [file, text] of SITES) {
|
||||
test(`${locale}: "${text}" from ${file} is translated`, () => {
|
||||
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
|
||||
assert.ok(source.includes(`'${text}'`), `literal not found in ${file}`);
|
||||
setLocale(locale);
|
||||
try {
|
||||
assert.notEqual(translateKnown(text), text);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Which locale the page runs in: ?lang= → stored choice → browser languages →
|
||||
// English. Only shipped locales are ever selected.
|
||||
|
||||
/** Durable preference key. Matches the repo convention gev:<name>:v1. */
|
||||
export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
||||
|
||||
/** Source language and fallback. */
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
|
||||
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
ru: 'Русский',
|
||||
});
|
||||
|
||||
/** Right-to-left locales. */
|
||||
const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLocale(candidate) {
|
||||
if (typeof candidate !== 'string') return null;
|
||||
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
function queryLocale(location) {
|
||||
try {
|
||||
return normalizeLocale(new URLSearchParams(location?.search).get('lang'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredLocale(storage) {
|
||||
try {
|
||||
const store = storage !== undefined ? storage : globalThis.localStorage;
|
||||
return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store the choice; false when storage is unavailable. */
|
||||
export function writeStoredLocale(locale, storage) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
try {
|
||||
const store = storage !== undefined ? storage : globalThis.localStorage;
|
||||
if (typeof store?.setItem !== 'function') return false;
|
||||
store.setItem(LOCALE_STORAGE_KEY, normalized);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the boot locale. Every input is injectable for tests. */
|
||||
export function resolveLocale({
|
||||
location = globalThis.location,
|
||||
storage,
|
||||
languages = globalThis.navigator?.languages,
|
||||
} = {}) {
|
||||
const fromQuery = queryLocale(location);
|
||||
if (fromQuery) return fromQuery;
|
||||
const stored = readStoredLocale(storage);
|
||||
if (stored) return stored;
|
||||
for (const candidate of Array.isArray(languages) ? languages : []) {
|
||||
const normalized = normalizeLocale(candidate);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/** Set <html lang> and <html dir>. */
|
||||
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
|
||||
const root = doc?.documentElement;
|
||||
if (!root) return false;
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
root.lang = normalized;
|
||||
root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
// msg() for modules the server, tools or other portable code also import:
|
||||
// marks English that stays in data for `npm run i18n`, without loading the
|
||||
// catalogs (src/i18n/index.js). The UI shows it later through t().
|
||||
// Browser-only modules import msg from src/i18n/index.js instead.
|
||||
|
||||
/** Mark English text that is stored in data and translated later by t(). */
|
||||
export const msg = (text) => text;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
missingTemplate,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
|
||||
// A plural entry with forms left in English is reported like a string entry.
|
||||
const fixture = new Map([
|
||||
['{count} cameras', { plural: true, one: '{count} camera' }],
|
||||
['Save keys', { plural: false }],
|
||||
]);
|
||||
|
||||
test('string entry left in English is reported (control)', () => {
|
||||
const { errors } = checkCatalog(
|
||||
'de',
|
||||
{
|
||||
'Save keys': 'Save keys',
|
||||
'{count} cameras': { one: 'x {count}', other: 'y {count}' },
|
||||
},
|
||||
fixture,
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /is still English/);
|
||||
});
|
||||
|
||||
for (const [locale, entry] of [
|
||||
['de', { one: '{count} camera', other: '{count} cameras' }],
|
||||
['ja', { other: '{count} cameras' }],
|
||||
['zh-CN', { other: '{count} cameras' }],
|
||||
[
|
||||
'es',
|
||||
{
|
||||
one: '{count} camera',
|
||||
many: '{count} cámaras',
|
||||
other: '{count} cameras',
|
||||
},
|
||||
],
|
||||
[
|
||||
'ru',
|
||||
{
|
||||
one: '{count} camera',
|
||||
few: '{count} камеры',
|
||||
many: '{count} камер',
|
||||
other: '{count} cameras',
|
||||
},
|
||||
],
|
||||
]) {
|
||||
test(`${locale}: plural entry with English forms is reported`, () => {
|
||||
const { errors } = checkCatalog(
|
||||
locale,
|
||||
{ 'Save keys': 'Schlüssel', '{count} cameras': entry },
|
||||
fixture,
|
||||
new Set(),
|
||||
);
|
||||
assert.ok(
|
||||
errors.some((e) => /is still English/.test(e)),
|
||||
`expected an "is still English" error, got ${JSON.stringify(errors)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The `--missing` template, pasted without edits, must fail entry by entry,
|
||||
// and its plural entries carry every form the language needs.
|
||||
const messages = extractMessages();
|
||||
for (const locale of ['de', 'ja', 'es', 'ru']) {
|
||||
test(`${locale}: an untouched --missing template fails the check entry by entry`, () => {
|
||||
const template = missingTemplate(locale, {}, messages);
|
||||
assert.equal(Object.keys(template).length, messages.size);
|
||||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||
.pluralCategories;
|
||||
for (const [text, info] of messages)
|
||||
if (info.plural)
|
||||
assert.deepEqual(Object.keys(template[text]), categories, text);
|
||||
const { missing, errors } = checkCatalog(
|
||||
locale,
|
||||
template,
|
||||
messages,
|
||||
new Set(),
|
||||
);
|
||||
assert.deepEqual(missing, []);
|
||||
const accepted = [...messages.keys()].filter(
|
||||
(text) => !errors.some((error) => error.startsWith(`"${text}" `)),
|
||||
);
|
||||
assert.deepEqual(accepted, []);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"ASIA",
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Blues",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"ENE",
|
||||
"ERROR",
|
||||
"ESE",
|
||||
"Error: {message}",
|
||||
"FLIR",
|
||||
"FLT",
|
||||
"FM MHZ",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Global",
|
||||
"Global · 60°S–60°N",
|
||||
"Google Street View",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"POS: {time}Z",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"R&B",
|
||||
"RADIO",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SECTOR {lat} {lon}",
|
||||
"SIG",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"VISUAL",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{rate} msg/s",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"GEO",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NAV",
|
||||
"NVG",
|
||||
"R&B",
|
||||
"REC",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// scripts/i18n.mjs must skip index entries gone from the working tree
|
||||
// (plain `mv`/`rm` without `git mv`/`git rm`).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function sandbox() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'k23-'));
|
||||
mkdirSync(path.join(root, 'scripts'));
|
||||
mkdirSync(path.join(root, 'src/i18n/locales'), { recursive: true });
|
||||
copyFileSync(
|
||||
path.join(HERE, '../../scripts/i18n.mjs'),
|
||||
path.join(root, 'scripts/i18n.mjs'),
|
||||
);
|
||||
writeFileSync(path.join(root, 'src/a.js'), "export const x = t('Hello');\n");
|
||||
writeFileSync(path.join(root, 'index.html'), '<p data-i18n>Hello</p>\n');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
git('init', '-q');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=k@23', '-c', 'user.name=k23', 'commit', '-qm', 'init');
|
||||
return root;
|
||||
}
|
||||
|
||||
test('extractMessages survives a module renamed with plain mv', async () => {
|
||||
const root = sandbox();
|
||||
try {
|
||||
renameSync(path.join(root, 'src/a.js'), path.join(root, 'src/b.js'));
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(root, 'scripts/i18n.mjs')).href
|
||||
);
|
||||
const messages = mod.extractMessages();
|
||||
assert.ok(messages.has('Hello'));
|
||||
assert.doesNotThrow(() => mod.findUnmarked());
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+207
-57
@@ -20,12 +20,22 @@ import {
|
||||
* the chip and the dialog are removed outright.
|
||||
*/
|
||||
|
||||
/** Chip label — pure, exported for tests. */
|
||||
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
|
||||
|
||||
/**
|
||||
* Chip label — pure, exported for tests (keySetup.test.mjs pins the English).
|
||||
* @param {{total?: number, setCount?: number}|null} status
|
||||
* @returns {string}
|
||||
*/
|
||||
export function keySetupChipLabel(status) {
|
||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||
return missing > 0
|
||||
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
||||
: 'POWERED UP';
|
||||
? tn(
|
||||
missing,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
)
|
||||
: t('POWERED UP');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +52,73 @@ export function collectKeyUpdates(fields) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why /api/setup/keys refused a save, by the `code` the server sends beside
|
||||
* its English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js).
|
||||
* The English matches the server's text.
|
||||
*/
|
||||
const SAVE_REFUSALS = Object.freeze({
|
||||
proxied: msg('Provider Settings does not answer proxied requests'),
|
||||
sharing: msg('Provider Settings is disabled while sharing is enabled'),
|
||||
'not-loopback': msg(
|
||||
'Provider Settings answers only the machine running the server',
|
||||
),
|
||||
'not-local-host': msg('Provider Settings answers only local hostnames'),
|
||||
'origin-required': msg('Provider Settings requires an exact local Origin'),
|
||||
'origin-unrecognized': msg('Unrecognized Origin refused'),
|
||||
'cross-origin': msg('Cross-origin requests are refused'),
|
||||
'content-type': msg('Content-Type must be application/json'),
|
||||
method: msg('Method not allowed'),
|
||||
'too-large': msg('Request too large'),
|
||||
'invalid-json': msg('Invalid JSON'),
|
||||
'not-object': msg('Body must be a JSON object of {ENV_VAR: value}'),
|
||||
'no-keys': msg('No keys provided'),
|
||||
'too-many': msg('At most {limit} keys per save'),
|
||||
'unknown-key': msg('Unknown key: {name}'),
|
||||
'not-string': msg('{name} must be a string'),
|
||||
empty: msg('{name} is empty'),
|
||||
'too-long': msg('{name} is longer than any real key ({limit} max)'),
|
||||
'not-printable': msg(
|
||||
'{name} may only contain printable characters with no spaces',
|
||||
),
|
||||
'invalid-char': msg(
|
||||
'{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)',
|
||||
),
|
||||
external: msg(
|
||||
'{name} is configured outside Provider Settings and can only be changed where it was set',
|
||||
),
|
||||
'harden-failed': msg(
|
||||
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
|
||||
),
|
||||
'store-unreadable': msg(
|
||||
'The key was not saved: the existing configuration could not be read, so nothing was changed',
|
||||
),
|
||||
'write-failed': msg('Could not write the {store} store'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The status line for a refused save — pure, exported for tests. A known
|
||||
* `code` reads in the active language; anything else shows the server's text.
|
||||
* @param {{code?: string, params?: object, error?: string}} payload
|
||||
* @param {number} status HTTP status of the response.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function saveRefusalText(payload, status) {
|
||||
const pattern = Object.hasOwn(SAVE_REFUSALS, payload?.code)
|
||||
? SAVE_REFUSALS[payload.code]
|
||||
: null;
|
||||
if (pattern) {
|
||||
const values = {};
|
||||
for (const name of ['name', 'limit', 'store']) {
|
||||
const value = payload.params?.[name];
|
||||
if (typeof value === 'string' || typeof value === 'number')
|
||||
values[name] = value;
|
||||
}
|
||||
return t(pattern, values);
|
||||
}
|
||||
return payload?.error || t('Save failed ({status}).', { status });
|
||||
}
|
||||
|
||||
function sleepWithSignal(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -60,6 +137,34 @@ function sleepWithSignal(ms, signal) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An OAuth check error. A server reason is later wrapped in «OAuth check
|
||||
* failed: …»; our own translated fallback is flagged to show unwrapped.
|
||||
* @param {unknown} serverError
|
||||
* @param {string} fallback Already-translated fallback sentence.
|
||||
* @returns {Error}
|
||||
*/
|
||||
function oauthCheckError(serverError, fallback) {
|
||||
if (serverError) return new Error(String(serverError));
|
||||
const error = new Error(fallback);
|
||||
error.oauthFallback = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status text for a failed OAuth check — pure, exported for tests. A fallback
|
||||
* sentence of our own already says what failed and is shown alone; any other
|
||||
* reason (server text, network error) gets the «OAuth check failed» wrapper.
|
||||
* @param {unknown} error
|
||||
* @returns {string}
|
||||
*/
|
||||
export function oauthCheckFailureText(error) {
|
||||
if (error?.oauthFallback && error.message) return error.message;
|
||||
return t('OAuth check failed: {detail}', {
|
||||
detail: error?.message || error,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForChatGptOAuth({
|
||||
fetchImpl,
|
||||
signal,
|
||||
@@ -78,8 +183,9 @@ export async function waitForChatGptOAuth({
|
||||
if (signal?.aborted) return false;
|
||||
if (response.ok && payload.available) return true;
|
||||
if (!response.ok || payload.loginFailed) {
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
||||
throw oauthCheckError(
|
||||
payload.error,
|
||||
t('Could not check ChatGPT sign-in. Try again.'),
|
||||
);
|
||||
}
|
||||
if (now() >= deadline) break;
|
||||
@@ -116,12 +222,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||
state.textContent =
|
||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
||||
mode === 'oauth'
|
||||
? t('VOICE AUTH · CHATGPT OAUTH')
|
||||
: t('VOICE AUTH · API KEY');
|
||||
toggle.textContent =
|
||||
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
|
||||
toggle.title =
|
||||
mode === 'oauth'
|
||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
||||
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
||||
? t('Use OPENAI_API_KEY for the next cloud voice session')
|
||||
: t(
|
||||
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
||||
);
|
||||
// Keep the control focusable while the delegated handler blocks repeats.
|
||||
toggle.setAttribute('aria-disabled', String(busy));
|
||||
}
|
||||
@@ -141,21 +252,22 @@ function buildRow(documentRef, key) {
|
||||
led.className = 'key-setup-led';
|
||||
led.setAttribute('aria-hidden', 'true');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = key.title;
|
||||
title.textContent = key.title; // i18n-ignore: provider product name
|
||||
const tier = documentRef.createElement('span');
|
||||
tier.className = 'key-setup-tier';
|
||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? 'Metered — a billing-enabled account'
|
||||
: 'Free key — register, paste, done';
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
exposed.className = 'key-setup-exposed';
|
||||
exposed.textContent = 'browser-side';
|
||||
exposed.title =
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
exposed.textContent = t('browser-side');
|
||||
exposed.title = t(
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
|
||||
);
|
||||
head.append(exposed);
|
||||
}
|
||||
if (external) {
|
||||
@@ -163,9 +275,10 @@ function buildRow(documentRef, key) {
|
||||
// facts this panel reports, never values it rewrites or deletes.
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = 'key-setup-external';
|
||||
badge.textContent = 'configured externally';
|
||||
badge.title =
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
badge.textContent = t('configured externally');
|
||||
badge.title = t(
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set',
|
||||
);
|
||||
head.append(badge);
|
||||
}
|
||||
const get = documentRef.createElement('a');
|
||||
@@ -173,12 +286,12 @@ function buildRow(documentRef, key) {
|
||||
get.href = key.getUrl;
|
||||
get.target = '_blank';
|
||||
get.rel = 'noopener noreferrer';
|
||||
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
||||
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
|
||||
head.append(get);
|
||||
|
||||
const unlocks = documentRef.createElement('p');
|
||||
unlocks.className = 'key-setup-unlocks';
|
||||
unlocks.textContent = key.unlocks;
|
||||
unlocks.textContent = t(key.unlocks);
|
||||
|
||||
row.append(head, unlocks);
|
||||
if (!external) {
|
||||
@@ -194,8 +307,8 @@ function buildRow(documentRef, key) {
|
||||
input.dataset.envVar = envVar;
|
||||
input.setAttribute('aria-label', envVar);
|
||||
input.placeholder = key.set
|
||||
? `${envVar} saved — paste to replace`
|
||||
: `paste ${envVar}`;
|
||||
? t('{envVar} saved — paste to replace', { envVar })
|
||||
: t('paste {envVar}', { envVar });
|
||||
fields.append(input);
|
||||
}
|
||||
if (key.managed === 'file') {
|
||||
@@ -203,8 +316,10 @@ function buildRow(documentRef, key) {
|
||||
remove.type = 'button';
|
||||
remove.className = 'key-setup-remove';
|
||||
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
||||
remove.textContent = 'REMOVE';
|
||||
remove.title = `Remove ${key.title} from this app's saved keys`;
|
||||
remove.textContent = t('REMOVE');
|
||||
remove.title = t("Remove {title} from this app's saved keys", {
|
||||
title: key.title,
|
||||
});
|
||||
fields.append(remove);
|
||||
}
|
||||
row.append(fields);
|
||||
@@ -225,17 +340,22 @@ function buildRow(documentRef, key) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Move the existing setup action with the theme, retaining its listeners. */
|
||||
export function bindKeySetupPlacement(documentRef, chip, root) {
|
||||
/**
|
||||
* Move the existing setup action with the theme, retaining its listeners:
|
||||
* Cyber puts it in the top toolbar, every other theme keeps it first in its
|
||||
* markup home (the bottom-right corner row, beside the language picker).
|
||||
*/
|
||||
export function bindKeySetupPlacement(documentRef, chip) {
|
||||
const theme = documentRef?.documentElement;
|
||||
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
||||
const Observer = documentRef?.defaultView?.MutationObserver;
|
||||
if (!theme || !toolbar || !Observer) return () => {};
|
||||
const home = chip?.parentNode;
|
||||
if (!theme || !toolbar || !Observer || !home) return () => {};
|
||||
const sync = () => {
|
||||
if (theme.dataset.uiTheme === 'cyber') {
|
||||
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
||||
} else if (chip.parentNode !== root.parentNode) {
|
||||
root.before(chip);
|
||||
} else if (chip.parentNode !== home) {
|
||||
home.prepend(chip);
|
||||
}
|
||||
};
|
||||
const observer = new Observer(sync);
|
||||
@@ -261,9 +381,19 @@ export async function initKeySetup({
|
||||
let disposed = false;
|
||||
let disposeControls = () => {};
|
||||
let disposePlacement = () => {};
|
||||
// A pasted key lives only in its field until Save: switching language (a
|
||||
// reload) asks first while any field holds one.
|
||||
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
||||
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
|
||||
String(input.value ?? '').trim(),
|
||||
)
|
||||
? msg('unsaved provider keys')
|
||||
: null,
|
||||
);
|
||||
const destroy = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unregisterUnsavedWork();
|
||||
lifetime.abort();
|
||||
signal?.removeEventListener('abort', destroy);
|
||||
disposeControls();
|
||||
@@ -295,7 +425,7 @@ export async function initKeySetup({
|
||||
}
|
||||
|
||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
||||
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
|
||||
disposePlacement = bindKeySetupPlacement(documentRef, chip);
|
||||
const applyButton = root.querySelector('[data-key-setup-apply]');
|
||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
||||
@@ -314,7 +444,9 @@ export async function initKeySetup({
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
chipLabel.textContent = keySetupChipLabel(status);
|
||||
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
||||
chip.title = t('Project keys: {status}', {
|
||||
status: keySetupChipLabel(status),
|
||||
});
|
||||
chip.setAttribute('aria-label', chip.title);
|
||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||
@@ -369,17 +501,19 @@ export async function initKeySetup({
|
||||
|
||||
const storeLabel = () =>
|
||||
status?.store === 'pinokio-environment'
|
||||
? 'your app configuration'
|
||||
: 'your local .env';
|
||||
? t('your app configuration')
|
||||
: t('your local .env');
|
||||
|
||||
const submitUpdates = async (updates, doneVerb) => {
|
||||
// doneMessageKey names the whole saved/removed sentence; the storage
|
||||
// destination is interpolated so the {store} phrase stays one spelling.
|
||||
const submitUpdates = async (updates, doneMessageKey) => {
|
||||
if (disposed || busy) return;
|
||||
const googleWasUnset = !status?.keys?.find(
|
||||
(key) => key.id === 'google-maps',
|
||||
)?.set;
|
||||
busy = true;
|
||||
applyButton?.setAttribute('aria-disabled', 'true');
|
||||
say('Saving…');
|
||||
say(t('Saving…'));
|
||||
try {
|
||||
const response = await doFetch('/api/setup/keys', {
|
||||
method: 'POST',
|
||||
@@ -390,7 +524,7 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (disposed) return;
|
||||
if (!response.ok || !payload.ok) {
|
||||
say(payload.error || `Save failed (${response.status}).`);
|
||||
say(saveRefusalText(payload, response.status));
|
||||
return;
|
||||
}
|
||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||
@@ -420,11 +554,13 @@ export async function initKeySetup({
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
}
|
||||
say(
|
||||
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
||||
);
|
||||
say(t(doneMessageKey, { store: storeLabel() }));
|
||||
} catch (error) {
|
||||
say(`Save failed: ${error?.message || error}`);
|
||||
say(
|
||||
t('Save failed: {detail}', {
|
||||
detail: error?.message || error,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
applyButton?.setAttribute('aria-disabled', 'false');
|
||||
@@ -441,10 +577,13 @@ export async function initKeySetup({
|
||||
})),
|
||||
);
|
||||
if (!Object.keys(updates).length) {
|
||||
say('Paste at least one key first.');
|
||||
say(t('Paste at least one key first.'));
|
||||
return;
|
||||
}
|
||||
await submitUpdates(updates, 'Saved to');
|
||||
await submitUpdates(
|
||||
updates,
|
||||
msg('Saved to {store}. Restarting — this page reloads itself.'),
|
||||
);
|
||||
};
|
||||
|
||||
chip.addEventListener('click', openDialog);
|
||||
@@ -462,12 +601,12 @@ export async function initKeySetup({
|
||||
if (current === 'oauth') {
|
||||
writeStoredCloudVoiceAuthMode('api-key');
|
||||
syncCloudVoiceAuth();
|
||||
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
|
||||
return;
|
||||
}
|
||||
oauthBusy = true;
|
||||
syncCloudVoiceAuth();
|
||||
say('Checking local ChatGPT OAuth sign-in…');
|
||||
say(t('Checking local ChatGPT OAuth sign-in…'));
|
||||
try {
|
||||
const response = await doFetch('/api/realtime/oauth-status', {
|
||||
cache: 'no-store',
|
||||
@@ -476,22 +615,25 @@ export async function initKeySetup({
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in.',
|
||||
throw oauthCheckError(
|
||||
payload.error,
|
||||
t('Could not check ChatGPT sign-in.'),
|
||||
);
|
||||
if (payload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
t(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
||||
: 'Opening ChatGPT sign-in in your browser…',
|
||||
? t('ChatGPT sign-in expired. Opening sign-in again…')
|
||||
: t('Opening ChatGPT sign-in in your browser…'),
|
||||
);
|
||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||
method: 'POST',
|
||||
@@ -504,7 +646,7 @@ export async function initKeySetup({
|
||||
say(
|
||||
loginPayload.error ||
|
||||
payload.error ||
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
t('Could not start ChatGPT sign-in on this machine.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -512,13 +654,17 @@ export async function initKeySetup({
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
t(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
t(
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
),
|
||||
);
|
||||
const available = await waitForChatGptOAuth({
|
||||
fetchImpl: doFetch,
|
||||
@@ -527,18 +673,22 @@ export async function initKeySetup({
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!available) {
|
||||
say(
|
||||
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||
t('ChatGPT sign-in timed out. Click {button} to try again.', {
|
||||
button: t('USE CHATGPT OAUTH'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
t(
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (lifetime.signal.aborted) return;
|
||||
say(`OAuth check failed: ${error?.message || error}`);
|
||||
say(oauthCheckFailureText(error));
|
||||
} finally {
|
||||
oauthBusy = false;
|
||||
if (!disposed) syncCloudVoiceAuth();
|
||||
@@ -560,11 +710,11 @@ export async function initKeySetup({
|
||||
// a deliberate two-step the lure cannot pre-satisfy.
|
||||
const ok =
|
||||
typeof globalThis.confirm !== 'function' ||
|
||||
globalThis.confirm('Remove this key from your saved configuration?');
|
||||
globalThis.confirm(t('Remove this key from your saved configuration?'));
|
||||
if (!ok) return;
|
||||
void submitUpdates(
|
||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||
'Removed from',
|
||||
msg('Removed from {store}. Restarting — this page reloads itself.'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+69
-11
@@ -6,29 +6,47 @@ import {
|
||||
stripKeylessBasemapFromHash,
|
||||
bindKeySetupPlacement,
|
||||
waitForChatGptOAuth,
|
||||
initKeySetup,
|
||||
} from './keySetup.js';
|
||||
import { describeUnsavedWork } from './i18n/index.js';
|
||||
|
||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||
const home = {};
|
||||
const home = {
|
||||
prepend: (node) => {
|
||||
node.parentNode = home;
|
||||
},
|
||||
};
|
||||
const chip = { parentNode: home, hidden: true };
|
||||
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
|
||||
let moves = 0;
|
||||
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
||||
const toolbar = {
|
||||
append: (node) => {
|
||||
moves++;
|
||||
node.parentNode = toolbar;
|
||||
},
|
||||
};
|
||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
||||
let notify, disconnected = false;
|
||||
let notify,
|
||||
disconnected = false;
|
||||
class Observer {
|
||||
constructor(callback) { notify = callback; }
|
||||
constructor(callback) {
|
||||
notify = callback;
|
||||
}
|
||||
observe(target, options) {
|
||||
assert.equal(target, theme);
|
||||
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
||||
}
|
||||
disconnect() { disconnected = true; }
|
||||
disconnect() {
|
||||
disconnected = true;
|
||||
}
|
||||
}
|
||||
const dispose = bindKeySetupPlacement({
|
||||
documentElement: theme,
|
||||
getElementById: () => toolbar,
|
||||
defaultView: { MutationObserver: Observer },
|
||||
}, chip, root);
|
||||
const dispose = bindKeySetupPlacement(
|
||||
{
|
||||
documentElement: theme,
|
||||
getElementById: () => toolbar,
|
||||
defaultView: { MutationObserver: Observer },
|
||||
},
|
||||
chip,
|
||||
);
|
||||
assert.equal(chip.parentNode, home);
|
||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||
theme.dataset.uiTheme = variant;
|
||||
@@ -156,3 +174,43 @@ test('aborting pending setup removes its surface and ignores a late response', a
|
||||
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
||||
assert.equal(await pending, null);
|
||||
});
|
||||
|
||||
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
|
||||
const node = () => ({
|
||||
dataset: {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute() {},
|
||||
querySelector: () => null,
|
||||
remove() {},
|
||||
});
|
||||
const chip = node();
|
||||
const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' };
|
||||
const root = {
|
||||
...node(),
|
||||
querySelectorAll: (selector) =>
|
||||
selector === 'input[data-env-var]' ? [input] : [],
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const setup = await initKeySetup({
|
||||
documentRef: {
|
||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
signal: controller.signal,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ keys: [], setCount: 0, total: 1 }),
|
||||
}),
|
||||
});
|
||||
assert.ok(setup);
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
input.value = ' ';
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
input.value = 'AIza-pasted';
|
||||
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
|
||||
// The field still holds the key, so only teardown dropping the check clears it.
|
||||
controller.abort();
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
});
|
||||
+89
-29
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Key setup ("POWER UP") — the pure core.
|
||||
*
|
||||
* One registry and pure functions; its only import is the proxy-header check
|
||||
* shared with the other local gates (localRequestGate.mjs). The dev server's
|
||||
* One registry and pure functions; it imports only the proxy-header check
|
||||
* shared with the other local gates (localRequestGate.mjs) and the msg()
|
||||
* marker (src/i18n/marker.js). The dev server's
|
||||
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
|
||||
* are both thin shells over this module, so what a key is called, what it
|
||||
* unlocks, and how a .env line is written each live in exactly one place.
|
||||
@@ -12,6 +13,7 @@
|
||||
* behavior below unit-testable.
|
||||
*/
|
||||
|
||||
import { msg } from './i18n/marker.js';
|
||||
import { hasProxySignals } from './localRequestGate.mjs';
|
||||
|
||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
||||
@@ -35,8 +37,8 @@ export const KEY_SETUP_APPEND_HEADER =
|
||||
export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
unlocks: 'The photorealistic 3D planet, place search + Street View',
|
||||
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
|
||||
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
@@ -44,8 +46,10 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'google-maps-server',
|
||||
title: 'GOOGLE MAPS — SERVER',
|
||||
unlocks: 'Places context + Street View fallback; optional separate key',
|
||||
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
|
||||
unlocks: msg(
|
||||
'Places context + Street View fallback; optional separate key',
|
||||
),
|
||||
getUrl:
|
||||
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
|
||||
@@ -54,40 +58,40 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'openai',
|
||||
title: 'OPENAI',
|
||||
unlocks: 'Voice control — API key or ChatGPT OAuth',
|
||||
title: 'OPENAI', // i18n-ignore: provider product name
|
||||
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
||||
getUrl: 'https://platform.openai.com/api-keys',
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
tier: 'metered',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'aisstream',
|
||||
title: 'AISSTREAM',
|
||||
unlocks: 'Live ships, worldwide',
|
||||
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||
unlocks: msg('Live ships, worldwide'),
|
||||
getUrl: 'https://aisstream.io',
|
||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'firms',
|
||||
title: 'NASA FIRMS',
|
||||
unlocks: 'Live active-fire detections',
|
||||
title: 'NASA FIRMS', // i18n-ignore: provider product name
|
||||
unlocks: msg('Live active-fire detections'),
|
||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'tomtom',
|
||||
title: 'TOMTOM',
|
||||
unlocks: 'Real live traffic (keyless runs a simulation)',
|
||||
title: 'TOMTOM', // i18n-ignore: provider product name
|
||||
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
||||
getUrl: 'https://my.tomtom.com/keys',
|
||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'cesium-ion',
|
||||
title: 'CESIUM ION',
|
||||
unlocks: 'Bing imagery map stacks + world terrain',
|
||||
title: 'CESIUM ION', // i18n-ignore: provider product name
|
||||
unlocks: msg('Bing imagery map stacks + world terrain'),
|
||||
getUrl: 'https://ion.cesium.com/tokens',
|
||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||
tier: 'free',
|
||||
@@ -95,17 +99,18 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'opensky',
|
||||
title: 'OPENSKY',
|
||||
unlocks: 'More flight-polling credits (anonymous works without)',
|
||||
title: 'OPENSKY', // i18n-ignore: provider product name
|
||||
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
||||
getUrl: 'https://opensky-network.org',
|
||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||
tier: 'free',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'mapillary',
|
||||
title: 'MAPILLARY',
|
||||
unlocks:
|
||||
title: 'MAPILLARY', // i18n-ignore: provider product name
|
||||
unlocks: msg(
|
||||
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
||||
),
|
||||
getUrl: 'https://www.mapillary.com/dashboard/developers',
|
||||
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
|
||||
tier: 'free',
|
||||
@@ -113,8 +118,8 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY',
|
||||
unlocks: 'Higher space-missions request allowance',
|
||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
tier: 'free',
|
||||
@@ -262,7 +267,9 @@ export function isSharingEnabled(env = {}) {
|
||||
* preflight this server never answers, closing the simple-request CSRF
|
||||
* write primitive.
|
||||
*
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string}}
|
||||
* A refusal also carries `code`, which the panel turns into its own localized
|
||||
* sentence (keySetup.js); `error` stays the English for logs and API callers.
|
||||
* @returns {{ok: true} | {ok: false, status: number, error: string, code: string}}
|
||||
*/
|
||||
export function admitKeySetupRequest({
|
||||
method,
|
||||
@@ -283,6 +290,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings does not answer proxied requests',
|
||||
code: 'proxied',
|
||||
};
|
||||
}
|
||||
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
|
||||
@@ -300,6 +308,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings is disabled while sharing is enabled',
|
||||
code: 'sharing',
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -310,6 +319,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings answers only the machine running the server',
|
||||
code: 'not-loopback',
|
||||
};
|
||||
}
|
||||
const authority = localAuthority(hostHeader, protocol);
|
||||
@@ -318,6 +328,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings answers only local hostnames',
|
||||
code: 'not-local-host',
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -328,6 +339,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Provider Settings requires an exact local Origin',
|
||||
code: 'origin-required',
|
||||
};
|
||||
}
|
||||
if (origin !== undefined && origin !== null && origin !== '') {
|
||||
@@ -335,7 +347,12 @@ export function admitKeySetupRequest({
|
||||
try {
|
||||
parsedOrigin = new URL(String(origin));
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Unrecognized Origin refused',
|
||||
code: 'origin-unrecognized',
|
||||
};
|
||||
}
|
||||
const exactOrigin =
|
||||
parsedOrigin.username === '' &&
|
||||
@@ -349,6 +366,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: 'Cross-origin requests are refused',
|
||||
code: 'cross-origin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -362,6 +380,7 @@ export function admitKeySetupRequest({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: 'Content-Type must be application/json',
|
||||
code: 'content-type',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -376,7 +395,19 @@ export function knownKeySetupEnvVars() {
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Tooltip guidance for a control gated by one registry entry. */
|
||||
/**
|
||||
* Env-var names a registry entry needs, joined with ' + ', for callers that
|
||||
* build the guidance sentence in the UI language (src/ui/keyGuidance.js).
|
||||
* @param {string} id Registry entry id.
|
||||
* @returns {string|null} null for an unknown or hidden entry.
|
||||
*/
|
||||
export function keySetupRequirementEnvVars(id) {
|
||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||
if (!entry || entry.hidden) return null;
|
||||
return entry.envVars.join(' + ');
|
||||
}
|
||||
|
||||
/** English tooltip guidance for a control gated by one registry entry. */
|
||||
export function keySetupRequirement(id) {
|
||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||
if (!entry || entry.hidden) return '';
|
||||
@@ -458,6 +489,8 @@ export function keySetupStatus(env = {}) {
|
||||
* below safe to write without quoting rules. A `null` value means REMOVE:
|
||||
* the writer comments the assignment back out, returning the file to its
|
||||
* template state for that key.
|
||||
* A refusal carries `code` and `params` for the panel's localized sentence
|
||||
* (keySetup.js) besides the English `error`.
|
||||
* @param {unknown} body Parsed JSON from the request.
|
||||
*/
|
||||
export function validateKeySetupUpdates(body) {
|
||||
@@ -465,38 +498,63 @@ export function validateKeySetupUpdates(body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Body must be a JSON object of {ENV_VAR: value}',
|
||||
code: 'not-object',
|
||||
};
|
||||
}
|
||||
const entries = Object.entries(body);
|
||||
if (entries.length === 0) return { ok: false, error: 'No keys provided' };
|
||||
if (entries.length === 0)
|
||||
return { ok: false, error: 'No keys provided', code: 'no-keys' };
|
||||
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
|
||||
code: 'too-many',
|
||||
params: { limit: KEY_SETUP_UPDATE_LIMIT },
|
||||
};
|
||||
}
|
||||
const known = knownKeySetupEnvVars();
|
||||
const updates = {};
|
||||
for (const [name, raw] of entries) {
|
||||
if (!known.has(name)) return { ok: false, error: `Unknown key: ${name}` };
|
||||
if (!known.has(name))
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown key: ${name}`,
|
||||
code: 'unknown-key',
|
||||
params: { name },
|
||||
};
|
||||
if (raw === null) {
|
||||
updates[name] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== 'string')
|
||||
return { ok: false, error: `${name} must be a string` };
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} must be a string`,
|
||||
code: 'not-string',
|
||||
params: { name },
|
||||
};
|
||||
const value = raw.trim();
|
||||
if (!value) return { ok: false, error: `${name} is empty` };
|
||||
if (!value)
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is empty`,
|
||||
code: 'empty',
|
||||
params: { name },
|
||||
};
|
||||
if (value.length > KEY_SETUP_VALUE_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
|
||||
code: 'too-long',
|
||||
params: { name, limit: KEY_SETUP_VALUE_LIMIT },
|
||||
};
|
||||
}
|
||||
if (!/^[\x21-\x7e]+$/.test(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} may only contain printable characters with no spaces`,
|
||||
code: 'not-printable',
|
||||
params: { name },
|
||||
};
|
||||
}
|
||||
// Reject the dotenv metacharacters that would round-trip WRONG when written
|
||||
@@ -508,6 +566,8 @@ export function validateKeySetupUpdates(body) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
|
||||
code: 'invalid-char',
|
||||
params: { name },
|
||||
};
|
||||
}
|
||||
updates[name] = value;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// A refused key save reads in the panel's language: the server sends `code`
|
||||
// and `params` beside its English `error`, the panel builds the sentence.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
admitKeySetupRequest,
|
||||
KEY_SETUP_UPDATE_LIMIT,
|
||||
validateKeySetupUpdates,
|
||||
} from './keySetupCore.mjs';
|
||||
import { collectKeyUpdates, saveRefusalText } from './keySetup.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
import { extractMessages } from '../scripts/i18n.mjs';
|
||||
|
||||
const local = {
|
||||
method: 'POST',
|
||||
remoteAddress: '127.0.0.1',
|
||||
hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
|
||||
// Every refusal the core can return, plus the ones only the endpoint sends
|
||||
// (server/standalone/key-setup.js), with the English the server writes.
|
||||
const refusals = () => [
|
||||
validateKeySetupUpdates(
|
||||
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: '"sk-abc123"' }]),
|
||||
),
|
||||
validateKeySetupUpdates(
|
||||
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: 'sk abc123' }]),
|
||||
),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(600) }),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }),
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 42 }),
|
||||
validateKeySetupUpdates({ PATH: '/usr/bin' }),
|
||||
validateKeySetupUpdates({}),
|
||||
validateKeySetupUpdates([]),
|
||||
validateKeySetupUpdates(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: KEY_SETUP_UPDATE_LIMIT + 1 }, (_, i) => [
|
||||
`K${i}`,
|
||||
'v',
|
||||
]),
|
||||
),
|
||||
),
|
||||
admitKeySetupRequest({ ...local, proxyHeaders: { 'x-forwarded-for': '1' } }),
|
||||
admitKeySetupRequest({
|
||||
...local,
|
||||
env: { PINOKIO_SHARE_CLOUDFLARE: 'true' },
|
||||
}),
|
||||
admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }),
|
||||
admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }),
|
||||
admitKeySetupRequest({ ...local, origin: undefined }),
|
||||
admitKeySetupRequest({ ...local, origin: 'not a url' }),
|
||||
admitKeySetupRequest({ ...local, origin: 'https://evil.example' }),
|
||||
admitKeySetupRequest({ ...local, contentType: 'text/plain' }),
|
||||
{ error: 'Method not allowed', code: 'method' },
|
||||
{ error: 'Request too large', code: 'too-large' },
|
||||
{ error: 'Invalid JSON', code: 'invalid-json' },
|
||||
{
|
||||
error:
|
||||
'OPENAI_API_KEY is configured outside Provider Settings and can only be changed where it was set',
|
||||
code: 'external',
|
||||
params: { name: 'OPENAI_API_KEY' },
|
||||
},
|
||||
{
|
||||
error:
|
||||
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
|
||||
code: 'harden-failed',
|
||||
},
|
||||
{
|
||||
error:
|
||||
'The key was not saved: the existing configuration could not be read, so nothing was changed',
|
||||
code: 'store-unreadable',
|
||||
},
|
||||
{
|
||||
error: 'Could not write the env-file store',
|
||||
code: 'write-failed',
|
||||
params: { store: 'env-file' },
|
||||
},
|
||||
];
|
||||
|
||||
test('every refusal carries a code', () => {
|
||||
for (const refusal of refusals()) {
|
||||
assert.equal(refusal.ok ?? false, false);
|
||||
assert.equal(typeof refusal.code, 'string', refusal.error);
|
||||
}
|
||||
});
|
||||
|
||||
test('in English the panel shows exactly the server sentence', () => {
|
||||
setLocale('en');
|
||||
for (const refusal of refusals())
|
||||
assert.equal(saveRefusalText(refusal, 400), refusal.error);
|
||||
});
|
||||
|
||||
test('an unknown code or a bare error falls back to the server text or the status', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
saveRefusalText({ error: 'Something new', code: 'new' }, 400),
|
||||
'Something new',
|
||||
);
|
||||
assert.equal(saveRefusalText({}, 502), 'Save failed (502).');
|
||||
});
|
||||
|
||||
test('the refusal sentences are catalog messages', () => {
|
||||
const messages = extractMessages();
|
||||
setLocale('en');
|
||||
for (const refusal of refusals()) {
|
||||
const pattern = saveRefusalText(
|
||||
{
|
||||
code: refusal.code,
|
||||
params: { name: '{name}', limit: '{limit}', store: '{store}' },
|
||||
},
|
||||
400,
|
||||
);
|
||||
assert.ok(messages.has(pattern), pattern);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a refused save reads in the panel language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const refusal of refusals()) {
|
||||
const text = saveRefusalText(refusal, 400);
|
||||
assert.notEqual(text, refusal.error, `${locale}: ${refusal.error}`);
|
||||
if (refusal.params?.name) assert.ok(text.includes(refusal.params.name));
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// A failed ChatGPT OAuth check whose only reason is our own fallback sentence
|
||||
// shows it alone; a server-sent reason keeps the «OAuth check failed» wrapper.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js';
|
||||
import { setLocale } from './i18n/index.js';
|
||||
|
||||
const failWith = (payload, ok = true) =>
|
||||
waitForChatGptOAuth({
|
||||
fetchImpl: async () => ({ ok, json: async () => payload }),
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}).then(
|
||||
() => assert.fail('expected a rejection'),
|
||||
(error) => error,
|
||||
);
|
||||
|
||||
test('a fallback-only OAuth failure is shown without the wrapper in every locale', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const expected = {
|
||||
en: 'Could not check ChatGPT sign-in. Try again.',
|
||||
ru: 'Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.',
|
||||
es: 'No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.',
|
||||
};
|
||||
for (const [locale, text] of Object.entries(expected)) {
|
||||
setLocale(locale);
|
||||
const loginFailed = await failWith({ available: false, loginFailed: true });
|
||||
assert.equal(oauthCheckFailureText(loginFailed), text);
|
||||
const httpFailed = await failWith({}, false);
|
||||
assert.equal(oauthCheckFailureText(httpFailed), text);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server-sent or network OAuth failure keeps the «OAuth check failed» wrapper', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('en');
|
||||
const server = await failWith({
|
||||
loginFailed: true,
|
||||
error: 'Sign-in is local only.',
|
||||
});
|
||||
assert.equal(
|
||||
oauthCheckFailureText(server),
|
||||
'OAuth check failed: Sign-in is local only.',
|
||||
);
|
||||
assert.equal(
|
||||
oauthCheckFailureText(new TypeError('Failed to fetch')),
|
||||
'OAuth check failed: Failed to fetch',
|
||||
);
|
||||
setLocale('ru');
|
||||
assert.match(
|
||||
oauthCheckFailureText(server),
|
||||
/^Не удалось проверить OAuth: Sign-in is local only\.$/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Every status line of the ChatGPT OAuth flow in Provider Settings goes
|
||||
// through t(), and the i18n check treats say() as a place UI text lands.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
unmarkedScript,
|
||||
} from '../scripts/i18n.mjs';
|
||||
|
||||
// Status-line texts of the ChatGPT OAuth flow in Provider Settings
|
||||
// (src/keySetup.js, handler of [data-cloud-voice-auth-toggle]).
|
||||
const OAUTH_STATUS_TEXTS = [
|
||||
'Cloud voice will use OPENAI_API_KEY on the next session.',
|
||||
'Checking local ChatGPT OAuth sign-in…',
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
'ChatGPT sign-in expired. Opening sign-in again…',
|
||||
'Opening ChatGPT sign-in in your browser…',
|
||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||
'ChatGPT sign-in timed out. Click {button} to try again.',
|
||||
'OAuth check failed: {detail}',
|
||||
'Could not check ChatGPT sign-in.',
|
||||
'Could not check ChatGPT sign-in. Try again.',
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||
];
|
||||
|
||||
test('OAuth status-line texts are extractable messages with es/ru entries', () => {
|
||||
const messages = extractMessages();
|
||||
const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text));
|
||||
assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js');
|
||||
for (const locale of shippedLocales()) {
|
||||
const catalog = readCatalog(locale);
|
||||
const untranslated = OAUTH_STATUS_TEXTS.filter((text) => !catalog[text]);
|
||||
assert.deepEqual(untranslated, [], `${locale}.json lacks entries`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the i18n check sees an unmarked literal passed to say()', () => {
|
||||
const sample =
|
||||
"const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n";
|
||||
const found = unmarkedScript('sample.js', sample);
|
||||
assert.ok(
|
||||
found.some((f) => f.text.includes('Checking local ChatGPT OAuth')),
|
||||
'say(...) is not among JS_SINKS in scripts/i18n.mjs',
|
||||
);
|
||||
});
|
||||
|
||||
test('src/keySetup.js has no unmarked say() literals', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./keySetup.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const literal = /\bsay\(\s*['"`]/g;
|
||||
const hits = [...source.matchAll(literal)].map(
|
||||
(m) => source.slice(0, m.index).split('\n').length,
|
||||
);
|
||||
assert.deepEqual(
|
||||
hits,
|
||||
[],
|
||||
`say() called with a bare literal at lines ${hits.join(', ')}`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// The Air quality layer (AQHI) reads in the UI language: layer name, Weather
|
||||
// card (title, coverage, status, legend, risk) and its own failure text.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import { createAirQualityLayer } from './index.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
function harness(source) {
|
||||
const layer = createAirQualityLayer({
|
||||
source,
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
});
|
||||
const viewer = { dataSources: { add() {}, remove() {} } };
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return layer;
|
||||
}
|
||||
|
||||
const reading = {
|
||||
id: 'eccc:A',
|
||||
provider: 'eccc',
|
||||
scale: 'AQHI',
|
||||
value: 2,
|
||||
band: 'low',
|
||||
stationId: 'A',
|
||||
name: 'Toronto',
|
||||
zone: 'ont',
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
observedMs: 1_700_000_000_000,
|
||||
color: '#4cc9f0',
|
||||
label: '2',
|
||||
risk: 'Low health risk',
|
||||
note: null,
|
||||
};
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the layer name is translated`, () => {
|
||||
inLocale(locale, () => {
|
||||
assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry');
|
||||
assert.equal(layerNameForId('air-quality'), t('Air quality'));
|
||||
assert.equal(localizeLayerName('Air quality'), t('Air quality'));
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: the Weather card of Air quality is translated`, async () => {
|
||||
const layer = harness({ getSnapshot: async () => [reading] });
|
||||
await layer.update();
|
||||
const controls = inLocale(locale, () => layer.getRowControls());
|
||||
const english = [
|
||||
controls.summary.label,
|
||||
controls.summary.coverage,
|
||||
controls.summary.status,
|
||||
...controls.legend.map((entry) => entry.label),
|
||||
...controls.list.items.map((item) => item.text),
|
||||
].filter((text) =>
|
||||
/\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test(
|
||||
text,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(english, [], `English left in the ${locale} card`);
|
||||
});
|
||||
|
||||
test(`${locale}: the Air quality failure text is translated in the row`, () => {
|
||||
const row = inLocale(locale, () =>
|
||||
LayerPanel.prototype._buildMetaText({
|
||||
id: 'air-quality',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
error: 'Air quality request failed',
|
||||
source: 'ECCC',
|
||||
lastUpdate: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.ok(!row.includes('Air quality request failed'), row);
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['en', 'es', 'ru']) {
|
||||
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
|
||||
const layer = harness({
|
||||
getSnapshot: async () => {
|
||||
throw new Error('ECCC HTTP 503');
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
const card = inLocale(locale, () => layer.getRowControls().summary.status);
|
||||
assert.equal(
|
||||
card,
|
||||
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg, t, tn } from '../../i18n/index.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import {
|
||||
AIR_QUALITY_BANDS,
|
||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||
@@ -109,7 +111,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (request.signal.aborted || _request !== request) return false;
|
||||
_lastError = error?.message || 'Air quality request failed';
|
||||
_lastError = error?.message || msg('Air quality request failed');
|
||||
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
|
||||
notify();
|
||||
return false;
|
||||
@@ -214,34 +216,41 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
||||
(max, reading) => Math.max(max, reading.observedMs || 0),
|
||||
0,
|
||||
);
|
||||
// Readings and errors stay English data (analyst records, stats); the
|
||||
// card translates them as the Layers panel row does. A provider's own
|
||||
// error text has no catalog entry and shows as it came.
|
||||
return {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: provider.label,
|
||||
coverage: provider.coverage,
|
||||
label: t(provider.label),
|
||||
coverage: t(provider.coverage),
|
||||
validTime: newest ? new Date(newest).toISOString() : undefined,
|
||||
detail: `${provider.network} · ${provider.scale}`,
|
||||
status: _lastError
|
||||
? _lastError
|
||||
? translateFeedError(_lastError)
|
||||
: _count === 0
|
||||
? _lastUpdate === null
|
||||
? 'No readings available'
|
||||
: 'No current readings'
|
||||
: `${_count} stations reporting`,
|
||||
? t('No readings available')
|
||||
: t('No current readings')
|
||||
: tn(
|
||||
_count,
|
||||
'{count} station reporting',
|
||||
'{count} stations reporting',
|
||||
),
|
||||
units: provider.scale,
|
||||
},
|
||||
legend: AIR_QUALITY_BANDS.map((band) => ({
|
||||
color: band.color,
|
||||
label: band.label,
|
||||
label: t(band.label),
|
||||
})),
|
||||
legendCategorical: true,
|
||||
list: {
|
||||
ariaLabel: 'Highest air-quality readings in view',
|
||||
ariaLabel: t('Highest air-quality readings in view'),
|
||||
items: inView.map((reading, index) => ({
|
||||
id: reading.id,
|
||||
ordinal: index + 1,
|
||||
lead: `${reading.scale} ${reading.label}`,
|
||||
text: `${reading.name} · ${reading.risk}`,
|
||||
text: `${reading.name} · ${t(reading.risk)}`,
|
||||
accent: reading.color,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -15,13 +15,36 @@
|
||||
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
||||
* later provider registers against.
|
||||
*/
|
||||
// A portable export (package.json): no i18n here. The labels stay English data;
|
||||
// the Weather card (index.js) shows them translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Band identifiers shared across providers, in ascending severity. */
|
||||
export const AIR_QUALITY_BANDS = Object.freeze([
|
||||
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
|
||||
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
|
||||
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
|
||||
Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }),
|
||||
Object.freeze({
|
||||
id: 'low',
|
||||
label: msg('Low'),
|
||||
risk: msg('Low health risk'),
|
||||
color: '#4cc9f0',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'moderate',
|
||||
label: msg('Moderate'),
|
||||
risk: msg('Moderate health risk'),
|
||||
color: '#ffd43b',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'high',
|
||||
label: msg('High'),
|
||||
risk: msg('High health risk'),
|
||||
color: '#ff6b35',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'very-high',
|
||||
label: msg('Very High'),
|
||||
risk: msg('Very High health risk'),
|
||||
color: '#c1121f',
|
||||
}),
|
||||
]);
|
||||
|
||||
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
|
||||
@@ -89,9 +112,9 @@ export const AIR_QUALITY_PROVIDERS = Object.freeze({
|
||||
eccc: Object.freeze({
|
||||
id: 'eccc',
|
||||
scale: 'AQHI',
|
||||
label: 'Air quality',
|
||||
label: msg('Air quality'),
|
||||
network: 'Environment and Climate Change Canada',
|
||||
coverage: 'Canada only · ECCC AQHI network',
|
||||
coverage: msg('Canada only · ECCC AQHI network'),
|
||||
/** ECCC's published band thresholds, evaluated in order. */
|
||||
bands: Object.freeze([
|
||||
Object.freeze({ id: 'low', max: 3 }),
|
||||
@@ -161,8 +184,7 @@ export function valueLabel(providerId, value) {
|
||||
|
||||
/** Plain-language risk phrase for a band id. */
|
||||
export function bandRiskText(bandId) {
|
||||
const band = airQualityBandById(bandId);
|
||||
return band ? `${band.label} health risk` : null;
|
||||
return airQualityBandById(bandId)?.risk ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
|
||||
|
||||
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||
// the Layers panel and the Weather card show them translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
|
||||
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
||||
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
||||
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('ECCC request timed out')),
|
||||
() => controller.abort(new Error(msg('ECCC request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
|
||||
signal,
|
||||
);
|
||||
const next = normalizeAqhiStations(payload);
|
||||
if (!next) throw new Error('Malformed AQHI station response');
|
||||
if (!next) throw new Error(msg('Malformed AQHI station response'));
|
||||
// An empty refresh must not wipe a good catalog.
|
||||
if (next.size > 0) {
|
||||
stations = next;
|
||||
stationsFetchedAt = clock;
|
||||
}
|
||||
}
|
||||
if (stations.size === 0) throw new Error('No AQHI stations available');
|
||||
if (stations.size === 0)
|
||||
throw new Error(msg('No AQHI stations available'));
|
||||
|
||||
const payload = await getJson(
|
||||
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
||||
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
|
||||
signal,
|
||||
);
|
||||
const readings = normalizeAqhiObservations(payload, stations, clock);
|
||||
if (!readings) throw new Error('Malformed AQHI observation response');
|
||||
if (!readings)
|
||||
throw new Error(msg('Malformed AQHI observation response'));
|
||||
return readings;
|
||||
},
|
||||
};
|
||||
|
||||
Loaded 100 of 543 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user