i18n: fix the repeat-review findings

- Status pieces translate in one place (src/ui/statusText.js): a Local
  ADS-B error with a single piece now translates, and the analyst card
  splits empty-layer notes into pieces (Transit, Local ADS-B).
- Translate the ALPR retry chip text, the ECCC error on the Air quality
  card, "Upstream response too large" and the "entry {n}" feed label.
- The unmarked-text check also sees error/loadingLabel/statusMessage/
  status assignments, `status:` properties and showToast?.( /
  this._showToast( calls; the scene import preview marks its pack
  statuses, data and model-facing text carry i18n-ignore with a reason.
- Tests check the real code: the HUD locality formatter moves to
  src/hudLocalityFormat.js, the unsaved-keys test pins the teardown.
- One copy each: city names come from src/cityPois.js, the GNSS HTTP
  template and wind "field unavailable" texts; dead fallbacks removed.
- Leftovers of the reverted vessel-label change and the reformatting of
  detectionHost.test.mjs / keySetup.test.mjs are back to main.

Catalogs: es/ru 2617 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-16 19:28:59 +04:00
1 parent a2463ca2d2
commit 3157888780
60 files changed
+1577 -1606

No files matched your search

+515
View File
@@ -0,0 +1,515 @@
/**
* @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.
* `locations.js` re-exports 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,
},
],
},
};
+4
View File
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) {
const actions = terminateOwned('key-absent');
status = 'missing-key';
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
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; the chip shows a translated reason (vessels/queries.js)
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; the chip shows a translated reason (vessels/queries.js)
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; the chip shows a translated reason (vessels/queries.js)
if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport');
return [];
+154 -431
View File
@@ -27,25 +27,17 @@ import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
assert.equal(
countFadingRenderEntries([
{ selected: true },
{ selected: false },
{ selected: true },
{ selected: false },
]),
2,
);
assert.equal(
countFadingRenderEntries([{ selected: true }, { selected: true }]),
0,
);
assert.equal(countFadingRenderEntries([
{ selected: true },
{ selected: false },
{ selected: true },
{ selected: false },
]), 2);
assert.equal(countFadingRenderEntries([{ selected: true }, { selected: true }]), 0);
});
class MockEvent {
constructor() {
this.listeners = new Set();
}
constructor() { this.listeners = new Set(); }
addEventListener(listener) {
this.listeners.add(listener);
@@ -77,127 +69,62 @@ function mockContext(target, trace) {
return {
calls,
font: '',
get filter() {
return filter;
},
set filter(value) {
filter = value;
record('filter', value);
},
get filter() { return filter; },
set filter(value) { filter = value; record('filter', value); },
fillStyle: '',
get strokeStyle() {
return strokeStyle;
},
set strokeStyle(value) {
strokeStyle = value;
record('strokeStyle', value);
},
get strokeStyle() { return strokeStyle; },
set strokeStyle(value) { strokeStyle = value; record('strokeStyle', value); },
globalAlpha: 1,
get globalCompositeOperation() {
return globalCompositeOperation;
},
set globalCompositeOperation(value) {
globalCompositeOperation = value;
record('globalCompositeOperation', value);
},
get globalCompositeOperation() { return globalCompositeOperation; },
set globalCompositeOperation(value) { globalCompositeOperation = value; record('globalCompositeOperation', value); },
lineWidth: 1,
measureText(text) {
return { width: String(text).length * 6 };
},
setTransform(...args) {
record('setTransform', ...args);
},
clearRect(...args) {
record('clearRect', ...args);
},
save() {
record('save');
},
restore() {
record('restore');
},
translate(...args) {
record('translate', ...args);
},
scale(...args) {
record('scale', ...args);
},
beginPath() {
record('beginPath');
},
rect(...args) {
record('rect', ...args);
},
clip(...args) {
record('clip', ...args);
},
moveTo(...args) {
record('moveTo', ...args);
},
lineTo(...args) {
record('lineTo', ...args);
},
arc(...args) {
record('arc', ...args);
},
arcTo(...args) {
record('arcTo', ...args);
},
closePath() {
record('closePath');
},
measureText(text) { return { width: String(text).length * 6 }; },
setTransform(...args) { record('setTransform', ...args); },
clearRect(...args) { record('clearRect', ...args); },
save() { record('save'); },
restore() { record('restore'); },
translate(...args) { record('translate', ...args); },
scale(...args) { record('scale', ...args); },
beginPath() { record('beginPath'); },
rect(...args) { record('rect', ...args); },
clip(...args) { record('clip', ...args); },
moveTo(...args) { record('moveTo', ...args); },
lineTo(...args) { record('lineTo', ...args); },
arc(...args) { record('arc', ...args); },
arcTo(...args) { record('arcTo', ...args); },
closePath() { record('closePath'); },
// The style is recorded AT fill time, not on assignment, so a pin can prove
// which plate token actually reached the canvas rather than which one was
// set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour.
fill(path) {
record('fill', path, this.fillStyle, this.globalAlpha);
},
stroke(path) {
record('stroke', path, this.strokeStyle, this.lineWidth);
},
fillRect(...args) {
record('fillRect', ...args);
},
fillText(...args) {
record('fillText', ...args);
},
drawImage(...args) {
record('drawImage', ...args);
},
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
};
}
// `search` seeds window.location.search. The detection mode banner is developer
// telemetry gated behind ?detectDebug=1, so any test that uses the banner as its
// "did this frame repaint" probe must opt in explicitly.
function installEnvironment({
width = 800,
height = 600,
dpr = 2,
search = '',
} = {}) {
function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } = {}) {
const byId = new Map();
const paintTrace = [];
const ctx = mockContext('shared', paintTrace);
const detectionCtx = mockContext('detection', paintTrace);
const originalPerformance = Object.getOwnPropertyDescriptor(
globalThis,
'performance',
);
const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, 'performance');
const originalDateNow = Date.now;
const originalPath2D = globalThis.Path2D;
let currentTime = 1_000;
let performanceStep = 0;
Object.defineProperty(globalThis, 'performance', {
configurable: true,
value: {
now: () => {
const value = currentTime;
currentTime += performanceStep;
return value;
},
},
value: { now: () => {
const value = currentTime;
currentTime += performanceStep;
return value;
} },
});
Date.now = () => currentTime;
globalThis.Path2D = MockPath2D;
@@ -240,9 +167,7 @@ function installEnvironment({
register(this);
}
getBoundingClientRect() {
return this._rect;
}
getBoundingClientRect() { return this._rect; }
getContext() {
if (this.tagName !== 'CANVAS') return null;
return this.id === 'world-overlay-detection-surface' ? detectionCtx : ctx;
@@ -255,17 +180,13 @@ function installEnvironment({
remove() {
if (this.parentElement) {
this.parentElement.children = this.parentElement.children.filter(
(child) => child !== this,
);
this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
}
unregister(this);
this.parentElement = null;
}
get nextSibling() {
return null;
}
get nextSibling() { return null; }
}
function register(element) {
@@ -279,26 +200,14 @@ function installEnvironment({
}
const body = new MockElement('body');
body.classList = {
contains() {
return false;
},
};
body.classList = { contains() { return false; } };
const document = {
body,
createElement(tagName) {
return new MockElement(tagName);
},
getElementById(id) {
return byId.get(id) || null;
},
querySelector(selector) {
return byId.get(selector.slice(1)) || null;
},
createElement(tagName) { return new MockElement(tagName); },
getElementById(id) { return byId.get(id) || null; },
querySelector(selector) { return byId.get(selector.slice(1)) || null; },
querySelectorAll(selector) {
const element = selector.startsWith('#')
? byId.get(selector.slice(1))
: null;
const element = selector.startsWith('#') ? byId.get(selector.slice(1)) : null;
return element ? [element] : [];
},
};
@@ -307,18 +216,10 @@ function installEnvironment({
location: { search },
addEventListener() {},
removeEventListener() {},
getComputedStyle() {
return { display: 'block', visibility: 'visible', opacity: '1' };
},
};
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
};
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
};
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.MutationObserver = class { observe() {} disconnect() {} };
globalThis.document = document;
globalThis.window = window;
@@ -340,9 +241,7 @@ function installEnvironment({
positionWC: new Cesium.Cartesian3(0, 0, 10_000_000),
positionCartographic: { height: 1_000_000 },
viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
frustum: {
projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
},
frustum: { projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) },
moveEnd: new MockEvent(),
},
scene: { postRender, requestRender() {} },
@@ -355,12 +254,8 @@ function installEnvironment({
ctx,
detectionCtx,
paintTrace,
advance(ms) {
currentTime += ms;
},
setPerformanceStep(ms) {
performanceStep = ms;
},
advance(ms) { currentTime += ms; },
setPerformanceStep(ms) { performanceStep = ms; },
cleanup() {
destroyDetection();
destroyWorldOverlay();
@@ -371,8 +266,7 @@ function installEnvironment({
delete globalThis.window;
delete globalThis.ResizeObserver;
delete globalThis.MutationObserver;
if (originalPerformance)
Object.defineProperty(globalThis, 'performance', originalPerformance);
if (originalPerformance) Object.defineProperty(globalThis, 'performance', originalPerformance);
else delete globalThis.performance;
},
};
@@ -401,16 +295,14 @@ function militaryLayer() {
return {
id: 'military',
getDetectableObjects() {
return [
{
position: new Cesium.Cartesian3(0, 0, 6_356_752),
sourceId: 'military-pin',
id: 'MIL-PIN',
metric: 'FL450',
type: 'AIR',
tier: 'military',
},
];
return [{
position: new Cesium.Cartesian3(0, 0, 6_356_752),
sourceId: 'military-pin',
id: 'MIL-PIN',
metric: 'FL450',
type: 'AIR',
tier: 'military',
}];
},
};
}
@@ -466,9 +358,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
assert.equal(env.postRender.listeners.size, 1);
assert.equal(env.document.getElementById('detection-overlay'), null);
assert.ok(env.document.getElementById('world-overlay-canvas'));
const surface = env.document.getElementById(
'world-overlay-detection-surface',
);
const surface = env.document.getElementById('world-overlay-detection-surface');
const root = env.document.getElementById('world-overlay-root');
const mainCanvas = env.document.getElementById('world-overlay-canvas');
assert.ok(surface);
@@ -491,36 +381,19 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
assert.equal(first.profile, 'DENSE');
assert.ok(Array.isArray(first.calloutRects));
if (first.calloutRects.length) {
const x = first.calloutRects[0].x;
first.calloutRects[0].x = NaN;
assert.equal(
getDetectionDiagnostics().calloutRects[0].x,
x,
'QA owns a copy, never a live plate',
);
const x=first.calloutRects[0].x;
first.calloutRects[0].x=NaN;
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
}
assert.equal(first.observationCount, 2);
assert.equal(first.didSolve, true);
assert.ok(first.solveRevision > 0);
assert.ok(first.collectiveLabelBudget > 0);
assert.ok(
first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0,
);
assert.ok(
env.detectionCtx.calls.some(
([name, text]) =>
name === 'fillText' && String(text).startsWith('DENSE VIS:'),
),
);
assert.ok(
env.detectionCtx.calls.some(
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
),
);
assert.ok(
env.detectionCtx.calls.some(([name]) => name === 'fillRect'),
'scanlines and banner still paint',
);
assert.ok(first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0);
assert.ok(env.detectionCtx.calls.some(([name, text]) => name === 'fillText'
&& String(text).startsWith('DENSE VIS:')));
assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D));
assert.ok(env.detectionCtx.calls.some(([name]) => name === 'fillRect'), 'scanlines and banner still paint');
assert.equal(env.detectionCtx.globalCompositeOperation, 'source-over');
assert.equal(env.detectionCtx.filter, 'none');
assert.equal(surface.style.display, 'block');
@@ -536,16 +409,8 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
// screen-blended sensor surface: `screen` can only lighten, so a callout's
// dark backing plate is a no-op over sunlit ground and the text dissolves
// into it. Brackets, banner and scanlines stay on the sensor surface.
assert.ok(
env.ctx.calls.some(
([name, text]) => name === 'fillText' && text === 'TEST0',
),
);
assert.ok(
!env.detectionCtx.calls.some(
([name, text]) => name === 'fillText' && text === 'TEST0',
),
);
assert.ok(env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
assert.ok(!env.detectionCtx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
env.advance(1);
env.postRender.raise();
assert.ok(getDetectionDiagnostics().solveRevision > first.solveRevision);
@@ -555,10 +420,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
assert.equal(getMode(), 'SPARSE');
assert.equal(getDetectionDiagnostics().profile, 'SPARSE');
assert.equal(getDetectionDiagnostics().allocationStrategy, 'WEIGHTED');
assert.ok(
env.detectionCtx.calls.some(([name]) => name === 'arc'),
'sparse focus ring remains available',
);
assert.ok(env.detectionCtx.calls.some(([name]) => name === 'arc'), 'sparse focus ring remains available');
setMode('BALANCED');
env.postRender.raise();
@@ -567,12 +429,9 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
env.postRender.raise();
assert.equal(getDetectionDiagnostics().profile, 'DENSE');
const bannerCount = () =>
env.detectionCtx.calls.filter(
([name, text]) =>
name === 'fillText' &&
/^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)),
).length;
const bannerCount = () => env.detectionCtx.calls.filter(([name, text]) => (
name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
)).length;
const beforeSuspend = bannerCount();
suspendDetection('intercity');
assert.equal(isDetectionSuspended(), true);
@@ -589,12 +448,9 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
// These are the exact engine calls made by military style presets after
// their production UI gate chooses CRT/NVG/FLIR defaults.
const expectedThemes = {
retro:
'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))',
surveillance:
'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))',
thermal:
'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))',
retro: 'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))',
surveillance: 'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))',
thermal: 'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))',
};
for (const style of ['retro', 'surveillance', 'thermal']) {
setMode('OFF');
@@ -620,28 +476,17 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
assert.equal(getMode(), 'OFF');
assert.equal(surface.style.display, 'none');
assert.equal(env.postRender.listeners.size, 1);
assert.ok(
modes.includes('SPARSE') &&
modes.includes('BALANCED') &&
modes.includes('DENSE'),
);
assert.ok(modes.includes('SPARSE') && modes.includes('BALANCED') && modes.includes('DENSE'));
destroyDetection();
assert.equal(
env.postRender.listeners.size,
1,
'detection never owns the listener',
);
assert.equal(env.postRender.listeners.size, 1, 'detection never owns the listener');
setMode('DENSE');
env.postRender.raise();
assert.equal(env.document.getElementById('detection-overlay'), null);
destroyWorldOverlay();
assert.equal(env.postRender.listeners.size, 0);
assert.equal(env.document.getElementById('world-overlay-canvas'), null);
assert.equal(
env.document.getElementById('world-overlay-detection-surface'),
null,
);
assert.equal(env.document.getElementById('world-overlay-detection-surface'), null);
} finally {
env.cleanup();
}
@@ -654,22 +499,16 @@ test('callouts stop painting the moment the last detectable object goes away', (
// clear the replay buffer, or the final callsigns stay stranded on the
// shared canvas after the last data layer is switched off.
const env = installEnvironment();
let objects = [
{
position: new Cesium.Cartesian3(0, 0, 6_356_752),
sourceId: 'lonely-1',
id: 'LASTONE',
metric: 'FL120',
type: 'AIR',
},
];
let objects = [{
position: new Cesium.Cartesian3(0, 0, 6_356_752),
sourceId: 'lonely-1',
id: 'LASTONE',
metric: 'FL120',
type: 'AIR',
}];
try {
initWorldOverlay(env.viewer);
initDetection(
env.viewer,
[{ id: 'flights', getDetectableObjects: () => objects }],
() => {},
);
initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {});
setMode('DENSE');
env.advance(250);
env.postRender.raise();
@@ -678,9 +517,7 @@ test('callouts stop painting the moment the last detectable object goes away', (
env.advance(250);
env.postRender.raise();
assert.ok(
env.ctx.calls.some(
([name, text]) => name === 'fillText' && text === 'LASTONE',
),
env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
'the callout paints on the shared canvas while the contact exists',
);
@@ -691,9 +528,7 @@ test('callouts stop painting the moment the last detectable object goes away', (
env.advance(250);
env.postRender.raise();
assert.ok(
!env.ctx.calls.some(
([name, text]) => name === 'fillText' && text === 'LASTONE',
),
!env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
'an empty field must drop the replay buffer, not strand the last callsign',
);
} finally {
@@ -760,24 +595,12 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig
theme.calloutPlateSpace,
'the fixture is only meaningful while the two tiers differ',
);
const painted = (text) =>
env.ctx.calls.some(
([name, value]) => name === 'fillText' && value === text,
);
assert.ok(
painted('AIRONE') && painted('SATONE'),
'both tiers reached the frame under test',
);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
assert.ok(painted('AIRONE') && painted('SATONE'), 'both tiers reached the frame under test');
const fills = sharedFillStyles(env);
assert.ok(
fills.includes(theme.calloutPlate),
'the air contact keeps the light plate',
);
assert.ok(
fills.includes(theme.calloutPlateSpace),
'the space contact takes the heavier plate',
);
assert.ok(fills.includes(theme.calloutPlate), 'the air contact keeps the light plate');
assert.ok(fills.includes(theme.calloutPlateSpace), 'the space contact takes the heavier plate');
} finally {
env.cleanup();
}
@@ -794,16 +617,11 @@ test('surveillance military tier reaches the dedicated render target as shipped
env.postRender.raise();
assert.ok(
env.detectionCtx.calls.some(
([name, value]) => name === 'strokeStyle' && value === '#ff5a47',
),
env.detectionCtx.calls.some(([name, value]) => name === 'strokeStyle' && value === '#ff5a47'),
'the production projection/tier/batched-bracket paint path emits surveillance military red',
);
assert.ok(
env.detectionCtx.calls.some(
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
),
);
assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke'
&& path instanceof MockPath2D));
} finally {
env.cleanup();
}
@@ -815,71 +633,44 @@ test('pathological detection paint holds alternate frames without freezing share
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [detectableLayer()], () => {});
setOverlayEntries('valve-card', [
{
id: 'ambient-card',
position: new Cesium.Cartesian3(0, 0, 0),
variant: 'label',
title: 'AMBIENT-CONTINUES',
selected: true,
protected: true,
horizonCull: false,
edgeFade: 'none',
},
]);
setOverlayEntries('valve-card', [{
id: 'ambient-card',
position: new Cesium.Cartesian3(0, 0, 0),
variant: 'label',
title: 'AMBIENT-CONTINUES',
selected: true,
protected: true,
horizonCull: false,
edgeFade: 'none',
}]);
setMode('DENSE');
env.advance(250);
env.setPerformanceStep(30);
env.postRender.raise();
env.postRender.raise();
const detectionClears = env.detectionCtx.calls.filter(
([name]) => name === 'clearRect',
).length;
const detectionBanners = env.detectionCtx.calls.filter(
([name, text]) =>
name === 'fillText' && String(text).startsWith('DENSE VIS:'),
).length;
const sharedClears = env.ctx.calls.filter(
([name]) => name === 'clearRect',
).length;
const sharedCards = env.ctx.calls.filter(
([name, text]) => name === 'fillText' && text === 'AMBIENT-CONTINUES',
).length;
const detectionClears = env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length;
const detectionBanners = env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
&& String(text).startsWith('DENSE VIS:')).length;
const sharedClears = env.ctx.calls.filter(([name]) => name === 'clearRect').length;
const sharedCards = env.ctx.calls.filter(([name, text]) => name === 'fillText'
&& text === 'AMBIENT-CONTINUES').length;
env.postRender.raise();
assert.equal(
env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
detectionClears,
'the host preserves detection pixels on the held odd frame',
);
assert.equal(
env.detectionCtx.calls.filter(
([name, text]) =>
name === 'fillText' && String(text).startsWith('DENSE VIS:'),
).length,
detectionBanners,
);
assert.equal(
env.ctx.calls.filter(([name]) => name === 'clearRect').length,
sharedClears + 1,
'the shared surface still clears',
);
assert.equal(
env.ctx.calls.filter(
([name, text]) => name === 'fillText' && text === 'AMBIENT-CONTINUES',
).length,
sharedCards + 1,
'the shared lane still repaints',
);
assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, detectionClears,
'the host preserves detection pixels on the held odd frame');
assert.equal(env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
&& String(text).startsWith('DENSE VIS:')).length, detectionBanners);
assert.equal(env.ctx.calls.filter(([name]) => name === 'clearRect').length, sharedClears + 1,
'the shared surface still clears');
assert.equal(env.ctx.calls.filter(([name, text]) => name === 'fillText'
&& text === 'AMBIENT-CONTINUES').length, sharedCards + 1,
'the shared lane still repaints');
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
env.postRender.raise();
assert.equal(
env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
detectionClears + 1,
'the next even frame repaints detection',
);
assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
detectionClears + 1, 'the next even frame repaints detection');
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
} finally {
env.cleanup();
@@ -887,14 +678,9 @@ test('pathological detection paint holds alternate frames without freezing share
});
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
const source = readFileSync(
new URL('./detection.js', import.meta.url),
'utf8',
);
const uiSource =
readShellSource() +
'\n' +
readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
const uiSource = readShellSource()
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
assert.doesNotMatch(source, /postRender\.addEventListener/);
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
@@ -904,20 +690,11 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
assert.doesNotMatch(source, /CALLOUT_OCCLUSION_SELECTORS/);
assert.doesNotMatch(source, /_ctx\.(?:filter|globalCompositeOperation)\s*=/);
assert.match(source, /_hostSurface\.style\.mixBlendMode = _theme\.blend;/);
assert.match(
source,
/_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/,
);
assert.match(source, /_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/);
assert.match(source, /const LABEL_SOLVE_INTERVAL_MS = 125;/);
assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/);
assert.match(
source,
/const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/,
);
assert.match(
source,
/registerWorldOverlayPaintLane\('detection', _paintDetectionLane/,
);
assert.match(source, /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/);
assert.match(source, /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/);
assert.match(source, /target: 'detection'/);
assert.match(source, /shouldPaint: _shouldPaintDetectionLane/);
// The three military styles used to carry three copies of these numbers.
@@ -934,10 +711,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
3,
'CRT, NVG, and FLIR retain their Dense auto-enable defaults',
);
assert.match(
uiSource,
/preset\.detection && !this\._detectionUserOverridden/,
);
assert.match(uiSource, /preset\.detection && !this\._detectionUserOverridden/);
});
// ── Developer telemetry gate (2026-08-20 QA hunt) ───────────────────────────
@@ -951,22 +725,15 @@ test('detectionDebugRequested parses the query-string gate and nothing else', ()
assert.equal(detectionDebugRequested(''), false);
assert.equal(detectionDebugRequested('?detectDebug=0'), false);
assert.equal(detectionDebugRequested('?detectDebug'), false);
assert.equal(
detectionDebugRequested('?detectdebug=1'),
false,
'the flag is case-sensitive',
);
assert.equal(detectionDebugRequested('?detectdebug=1'), false, 'the flag is case-sensitive');
assert.equal(detectionDebugRequested(undefined), false);
assert.equal(detectionDebugRequested(null), false);
});
test('the mode banner is absent by default and present behind the flag', () => {
const bannerPaints = (env) =>
env.detectionCtx.calls.filter(
([name, text]) =>
name === 'fillText' &&
/^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)),
).length;
const bannerPaints = (env) => env.detectionCtx.calls.filter(([name, text]) => (
name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
)).length;
const painted = (search) => {
const env = installEnvironment({ search });
@@ -978,9 +745,7 @@ test('the mode banner is absent by default and present behind the flag', () => {
env.postRender.raise();
// The overlay is alive either way — brackets still stroke.
assert.ok(
env.detectionCtx.calls.some(
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
),
env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D),
'detection still paints its contacts regardless of the debug gate',
);
return bannerPaints(env);
@@ -991,15 +756,8 @@ test('the mode banner is absent by default and present behind the flag', () => {
}
};
assert.equal(
painted(''),
0,
'users must never see the engine telemetry banner',
);
assert.ok(
painted('?detectDebug=1') > 0,
'the flag brings the telemetry back',
);
assert.equal(painted(''), 0, 'users must never see the engine telemetry banner');
assert.ok(painted('?detectDebug=1') > 0, 'the flag brings the telemetry back');
});
test('detection telemetry stays reachable programmatically with the banner hidden', () => {
@@ -1036,11 +794,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse
type: 'AIR',
}));
initWorldOverlay(env.viewer);
initDetection(
env.viewer,
[{ id: layerId, getDetectableObjects: () => objects }],
() => {},
);
initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {});
setDetectionTuning({ densityPct: 25 });
setMode('SPARSE');
settleFrame(env);
@@ -1053,10 +807,8 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse
assert.equal(diagnostics.visibleCount, 3);
assert.equal(diagnostics.densityPct, 25);
assert.equal(diagnostics.profile, 'SPARSE');
assert.ok(
diagnostics.selectedCount <= diagnostics.collectiveLabelBudget,
'Sparse keeps its documented budget instead of silently becoming Dense',
);
assert.ok(diagnostics.selectedCount <= diagnostics.collectiveLabelBudget,
'Sparse keeps its documented budget instead of silently becoming Dense');
} finally {
destroyDetection();
destroyWorldOverlay();
@@ -1081,17 +833,11 @@ function backdropLayer() {
return [
{
position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752),
sourceId: 'ground-1',
id: 'GROUNDED',
metric: 'FL100',
type: 'AIR',
sourceId: 'ground-1', id: 'GROUNDED', metric: 'FL100', type: 'AIR',
},
{
position: new Cesium.Cartesian3(0.4, -0.2, 20_000_000),
sourceId: 'sky-1',
id: 'SKYBACK',
metric: 'FL400',
type: 'AIR',
sourceId: 'sky-1', id: 'SKYBACK', metric: 'FL400', type: 'AIR',
},
];
},
@@ -1113,14 +859,8 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
env.ctx.calls.length = 0;
settleFrame(env);
const painted = (text) =>
env.ctx.calls.some(
([name, value]) => name === 'fillText' && value === text,
);
assert.ok(
painted('GROUNDED') && painted('SKYBACK'),
'both backdrops reached the frame under test',
);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
assert.ok(painted('GROUNDED') && painted('SKYBACK'), 'both backdrops reached the frame under test');
const plate = DETECTION_THEME_MAP._default.calloutPlate;
const plateAlphas = env.ctx.calls
@@ -1131,10 +871,7 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
const heaviest = Math.max(...plateAlphas);
const lightest = Math.min(...plateAlphas);
assert.ok(heaviest > 0, 'the grounded contact still gets a plate');
assert.ok(
lightest > 0,
'the sky contact keeps a whisper rather than vanishing',
);
assert.ok(lightest > 0, 'the sky contact keeps a whisper rather than vanishing');
// Reverting the feature makes both plates equal, which fails here first.
assert.ok(
lightest < heaviest * 0.5,
@@ -1200,23 +937,9 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share
painted.some((text) => String(text).includes('34 KM/H')),
'and so does what the vehicle is doing',
);
assert.ok(
env.ctx.calls.some(
([name, , color, width]) =>
name === 'stroke' && color === '#5EF08A' && width === 1.25,
),
);
assert.ok(
env.ctx.calls.some(
([name, , color, width]) =>
name === 'stroke' && color === '#05080C' && width === 3.25,
),
);
assert.ok(
!env.detectionCtx.calls.some(
([name, , color]) => name === 'stroke' && color === '#5EF08A',
),
);
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
// The operator travels on the object for the two-tier card form, which is
// where `klass` is read; the host's track label does not paint it.
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
+8 -4
View File
@@ -1,5 +1,9 @@
import { BUNDLE_SOURCE } from './bundle.js';
// Pack statuses stay English in the report; the import dialog shows them
// through t() (scenes/sharing.js).
const msg = (text) => text;
/** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare(
{ project, assets },
@@ -16,11 +20,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 {
+2 -35
View File
@@ -18,6 +18,7 @@ import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js';
import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import {
ellipsoidalToMslDisplayM,
ensureGeoidReady,
@@ -35,10 +36,8 @@ import {
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, getLocale, msg, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { localizePlaceName } from './ui/placeNames.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -138,38 +137,6 @@ function viewBandLabel(band) {
}
}
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
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). */
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')),
}),
});
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
+20 -1
View File
@@ -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' };
@@ -123,7 +125,7 @@ test('a missing or malformed nearest POI never crashes the summary', () => {
});
// The names reach a formatter as catalogued, so the HUD can translate the
// city before upper-casing it (N-04: `TOKYO` never matched the `Tokyo` key).
// city before upper-casing it: the place-name table knows `Tokyo`, not `TOKYO`.
test('a formatter builds the line from the same parts', () => {
const format = {
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
@@ -138,3 +140,20 @@ test('a formatter builds the line from the same parts', () => {
'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');
}
});
-26
View File
@@ -1,26 +0,0 @@
// N-04: the HUD locality line passes the city upper-cased ("TOKYO") to the
// place-name lookup, which knows only "Tokyo", so the city never translates.
// The formatter below is the HUD's own (src/hud.js LOCALIZED_LOCALITY.near);
// hud.js itself does not load under node (mgrs CommonJS import).
// Place: src/hudLocalityCity.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from './i18n/index.js';
import { composeLocalityTag } from './hudLocality.js';
import { localizePlaceName } from './ui/placeNames.js';
const nearest = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
const hudFormat = {
near: ({ city }) => localizePlaceName(city),
sector: () => '',
};
test('ru: the HUD locality names Tokyo in Russian', () => {
setLocale('ru');
try {
const shown = composeLocalityTag(nearest, 35.66, 139.75, hudFormat);
assert.match(shown, /токио/i);
} finally {
setLocale('en');
}
});
+41
View File
@@ -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')),
}),
});
+75
View File
@@ -136,3 +136,78 @@ test('unmarkedScript finds literals that reach the page without t/tn/msg', () =>
[],
);
});
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') };"), []);
});
+6 -2
View File
@@ -243,6 +243,10 @@ test('tn() shows a caller-formatted count and still picks the form by number', (
assert.equal(label(12345), '12,345 map objects');
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
setLocale('ru');
assert.equal(label(12345), '12 345 объектов на карте');
assert.equal(label(1), '1 объект на карте');
try {
assert.equal(label(12345), '12 345 объектов на карте');
assert.equal(label(1), '1 объект на карте');
} finally {
setLocale('en');
}
});
+8 -2
View File
@@ -2440,7 +2440,6 @@
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta",
"Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo",
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
"Exit cockpit to fly to a {noun}": "Sal de la cabina para volar a {noun}",
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
@@ -2877,5 +2876,12 @@
"unsaved provider keys": "las claves de proveedores sin guardar",
"Exit cockpit to fly to an aircraft": "Sal de la cabina para volar a una aeronave",
"Exit cockpit to fly to a satellite": "Sal de la cabina para volar a un satélite",
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos"
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos",
"{feed} {count} ({seconds}s old)": "{feed} {count} (hace {seconds} s)",
"entry {n}": "n.º {n}",
"Upstream response too large": "Respuesta del origen demasiado grande",
"Included in bundle": "Incluido en el paquete",
"Missing bundle file — reimport its bundle": "Falta el archivo en el paquete — vuelve a importar su paquete",
"Source configured; file checked when loaded": "Fuente configurada; el archivo se comprobará al cargarlo",
"Source unavailable": "Fuente no disponible"
}
+8 -2
View File
@@ -2481,7 +2481,6 @@
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
"Exit cockpit to fly to a {noun}": "Выйдите из кабины, чтобы перелететь: {noun}",
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
@@ -2944,5 +2943,12 @@
"unsaved provider keys": "несохранённые ключи провайдеров",
"Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту",
"Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику",
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре"
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре",
"{feed} {count} ({seconds}s old)": "{feed} {count} ({seconds} с назад)",
"entry {n}": "№ {n}",
"Upstream response too large": "Слишком большой ответ источника",
"Included in bundle": "Входит в архив",
"Missing bundle file — reimport its bundle": "Файла нет в архиве — импортируйте этот архив снова",
"Source configured; file checked when loaded": "Источник настроен; файл будет проверен при загрузке",
"Source unavailable": "Источник недоступен"
}
+3 -8
View File
@@ -389,14 +389,9 @@ export async function initKeySetup({
// A pasted key lives only in its field until Save: switching language (a
// reload) asks first while any field holds one.
const unregisterUnsavedWork = registerUnsavedWork(() =>
!disposed &&
Object.keys(
collectKeyUpdates(
[...(root.querySelectorAll?.('input[data-env-var]') || [])].map(
(input) => ({ envVar: input.dataset?.envVar, value: input.value }),
),
),
).length
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
String(input.value ?? '').trim(),
)
? msg('unsaved provider keys')
: null,
);
+19 -80
View File
@@ -6,7 +6,9 @@ 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 = {
@@ -59,20 +61,10 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
});
test('the chip counts what is missing, and retires the count at zero', () => {
assert.equal(
keySetupChipLabel({ setCount: 0, total: 8 }),
'POWER UP · 8 KEYS WAITING',
);
assert.equal(
keySetupChipLabel({ setCount: 7, total: 8 }),
'POWER UP · 1 KEY WAITING',
);
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP');
assert.equal(
keySetupChipLabel(null),
'POWERED UP',
'no status is not a broken label',
);
assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label');
});
test('collectKeyUpdates keeps only non-empty trimmed values', () => {
@@ -112,27 +104,14 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available'
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
for (const result of [
{
ok: true,
payload: {
available: false,
loginFailed: true,
error: 'Sign-in failed.',
},
},
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
{ ok: false, payload: { error: 'Sign-in is local only.' } },
]) {
let checks = 0;
await assert.rejects(
waitForChatGptOAuth({
fetchImpl: async () => {
checks += 1;
return { ok: result.ok, json: async () => result.payload };
},
sleep: () => assert.fail('failed login must stop polling'),
}),
{ message: result.payload.error },
);
await assert.rejects(waitForChatGptOAuth({
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
sleep: () => assert.fail('failed login must stop polling'),
}), { message: result.payload.error });
assert.equal(checks, 1);
}
});
@@ -140,34 +119,12 @@ test('OAuth login polling stops on login or HTTP failure instead of waiting for
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
let clock = 0;
let checks = 0;
const poll = {
fetchImpl: async () => {
checks += 1;
return {
ok: true,
json: async () => ({ available: false, pending: true }),
};
},
};
assert.equal(
await waitForChatGptOAuth({
...poll,
timeoutMs: 100,
pollMs: 100,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
}),
false,
);
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
assert.equal(checks, 2);
const controller = new AbortController();
controller.abort();
assert.equal(
await waitForChatGptOAuth({ ...poll, signal: controller.signal }),
false,
);
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
assert.equal(checks, 2, 'aborted polling does not fetch');
});
@@ -175,22 +132,14 @@ test('OAuth polling ignores a ready response received after the surface was abor
const controller = new AbortController();
const available = await waitForChatGptOAuth({
signal: controller.signal,
fetchImpl: async () => ({
ok: true,
json: async () => {
controller.abort();
return { available: true };
},
}),
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
sleep: () => assert.fail('aborted polling must not continue'),
});
assert.equal(available, false);
});
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
const stripped = stripKeylessBasemapFromHash(
'lat=30.2&lon=-97.7&map=osm&style=normal',
);
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
assert.ok(stripped !== null);
const params = new URLSearchParams(stripped);
assert.equal(params.get('map'), null, 'osm basemap removed');
@@ -198,11 +147,7 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha
assert.equal(params.get('style'), 'normal', 'style survives');
// A stack under any other name was chosen or shared on purpose.
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
assert.equal(
stripKeylessBasemapFromHash('lat=1&lon=2'),
null,
'no stack, nothing to do',
);
assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do');
assert.equal(stripKeylessBasemapFromHash(''), null);
assert.equal(stripKeylessBasemapFromHash(undefined), null);
});
@@ -216,15 +161,11 @@ test('aborting pending setup removes its surface and ignores a late response', a
let requestSignal;
const controller = new AbortController();
const pending = initKeySetup({
documentRef: {
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
},
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
signal: controller.signal,
fetchImpl: (_url, { signal }) => {
requestSignal = signal;
return new Promise((resolve) => {
resolveResponse = resolve;
});
return new Promise((resolve) => { resolveResponse = resolve; });
},
});
controller.abort();
@@ -233,10 +174,7 @@ 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 { initKeySetup } = await import('./keySetup.js');
const { describeUnsavedWork } = await import('./i18n/index.js');
const node = () => ({
dataset: {},
addEventListener() {},
@@ -271,6 +209,7 @@ test('a pasted, unsaved key makes the language switch ask first until teardown',
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(), []);
});
@@ -1,11 +1,11 @@
// N-02: the Air quality layer (merged from main, AQHI) is fully English in
// es/ru: layer name, Weather card (title, coverage, status, legend, risk) and
// its own failure text. Place: src/layers/airQuality/airQualityLocale.test.mjs
// 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) {
@@ -89,3 +89,20 @@ for (const locale of ['es', 'ru']) {
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')),
);
assert.equal(card, locale === 'en' ? 'ECCC HTTP 503' : 'ECCC: HTTP 503');
});
}
+7 -5
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { msg, t, tn, translateKnown } from '../../i18n/index.js';
import { msg, t, tn } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -216,8 +217,9 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
0,
);
// Readings and errors stay English data (analyst records, stats); the
// card shows them in the UI language. A provider's own error text has
// no catalog entry and shows as it came.
// card shows them in the UI language, errors 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: {
@@ -226,7 +228,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? translateKnown(_lastError)
? translateFeedError(_lastError)
: _count === 0
? _lastUpdate === null
? t('No readings available')
@@ -249,7 +251,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`,
text: `${reading.name} · ${t(reading.risk)}`,
accent: reading.color,
})),
},
+1
View File
@@ -132,6 +132,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
+3 -10
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { formatNumber, msg, t, translateKnown } from '../../i18n/index.js';
import { formatNumber, msg, t } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
@@ -31,14 +32,6 @@ const LEVEL_LABELS = Object.freeze({
high: msg('Low-accuracy score > 10%'),
});
/** A source failure (English data, source.js) in the UI language. */
function shownSourceError(text) {
const http = /^GNSS feed unavailable \(HTTP (\d{3})\)$/.exec(text);
return http
? t('GNSS feed unavailable (HTTP {status})', { status: http[1] })
: translateKnown(text);
}
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
@@ -385,7 +378,7 @@ export function createGnssIntegrityLayer({
'Regional sample · 250 nm radius · cells from visited views over 30 min',
) +
(_lastError && _retryAt
? ` · ${shownSourceError(_lastError)} · ${
? ` · ${translateFeedError(_lastError)} · ${
_retryAt <= 8640000000000000
? t('Retrying automatically at {time} UTC', {
time: new Date(_retryAt).toISOString().slice(11, 19),
+1 -1
View File
@@ -42,7 +42,7 @@ export function createAdsbGnssSource({
throw Object.assign(
new Error(
// The status code form is matched by FEED_ERROR_PATTERNS
// (ui/layerPanel.js) and gnss/index.js.
// (ui/statusText.js) and gnss/index.js.
limited
? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`,
+1 -1
View File
@@ -1,4 +1,4 @@
// N-03: the GNSS source's own failures are English data (source.js); the
// The GNSS source's own failures are English data (source.js); the
// card's info line shows them in the UI language, the HTTP form included.
import assert from 'node:assert/strict';
import test from 'node:test';
+1
View File
@@ -192,6 +192,7 @@ export function createModel({ state: layerState, services, parts, source }) {
launch.id || launch.slug || launch.name || `launch-${date}`,
),
name: launch.name || 'Unnamed launch',
// i18n-ignore: Launch Library status names are provider data, shown as sent
status: launch.status?.name || 'Unknown',
launchTime: Number.isFinite(date)
? new Date(date).toISOString()
@@ -0,0 +1,41 @@
// A Local ADS-B feed problem can be the whole error (nothing heard, WebUSB
// not producing); the row shows it in the UI language like any other piece.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
const receiver = {
webUsbSupported: true,
connected: false,
mode: 'adsb',
status: 'idle',
};
function rowFor(status) {
return LayerPanel.prototype._buildMetaText({
id: 'local-adsb',
enabled: true,
stats: { count: 0, lastUpdate: Date.now(), ...status },
});
}
for (const locale of ['es', 'ru']) {
for (const status of ['unreachable', 'invalid']) {
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
setLocale(locale);
try {
const st = localAdsbStatus({
receiver,
feedState: {
configured: true,
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
},
heard: 0,
});
const row = rowFor(st);
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
} finally {
setLocale('en');
}
});
}
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { LAYER_SOURCE } from './policy.js';
// Marks English that the Layers panel translates when it shows the status.
// Texts with a number or a feed name stay English data too; the panel shows
// them through STATUS_PIECE_PATTERNS (ui/layerPanel.js), piece by piece.
// them through STATUS_PIECE_PATTERNS (ui/statusText.js), piece by piece.
const msg = (text) => text;
/**
@@ -75,6 +75,7 @@ function webUsbStatus(receiver, heard, feedState) {
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: msg('listening');
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
}
@@ -117,6 +118,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
return {
source,
status: 'streaming',
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
// N-10: the Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) have no
// catalog entries and stay English in es/ru. Place: src/layers/localAdsb/statusLocale.test.mjs
// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) read in
// the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
+1
View File
@@ -125,6 +125,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
+1 -1
View File
@@ -72,7 +72,7 @@ export function createIngestion({
// At least one group loaded — proceed with a fresh rebuild, but keep the
// partial outage visible at the layer control instead of presenting the
// reduced catalog as a fully healthy refresh. English data; the panel
// shows it through FEED_ERROR_PATTERNS (ui/layerPanel.js).
// shows it through FEED_ERROR_PATTERNS (ui/statusText.js).
layerState._lastError = failed.length
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null;
+1 -1
View File
@@ -477,7 +477,7 @@ export function createIngestion({ state, services, parts, source }) {
error?.message || error,
);
// English data; the panel shows it through FEED_ERROR_PATTERNS
// (ui/layerPanel.js).
// i18n-ignore: translated on display (ui/statusText.js)
status.error = `${feed.name} feed unavailable`;
status.retryInSec = Number.isFinite(error?.retryInSec)
? error.retryInSec
+3 -2
View File
@@ -278,9 +278,10 @@ export function createQueries({ state, parts }) {
: {}),
degraded: count > 0 && health.degraded,
loading: health.loading && count === 0,
// English data; the panel shows it through LOADING_LABEL_PATTERNS
// (ui/layerPanel.js).
// English data; the panel shows it through STATUS_PIECE_PATTERNS
// (ui/statusText.js).
loadingLabel:
// i18n-ignore: translated on display
health.loading && count === 0
? `Loading ${active.map((feed) => feed.name).join(', ')}`
: undefined,
@@ -1,71 +0,0 @@
// The speed unit of a vessel's detection callout follows the UI language.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import aisLiveVesselsLayer, {
_setVesselOverlayHostForTest,
_setVesselStateForTest,
_updateVesselCardsForTest,
} from '../../data/aisLiveVessels.js';
import { setLocale } from '../../i18n/index.js';
const record = (mmsi, name, screen, z = 0) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: { x: 0, y: 0, z, screen },
billboard: null,
});
function detectable(cameraZ) {
const carded = record('200', 'GOLDEN BEAR', { x: 40, y: 30 });
const plain = record('300', 'BAY PILOT', { x: 41, y: 31 });
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) =>
position.screen;
_setVesselOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
_setVesselStateForTest({
viewer: {
scene: {
canvas: { clientWidth: 1600, clientHeight: 900 },
camera: { positionWC: new Cesium.Cartesian3(0, 0, cameraZ) },
},
},
records: [carded, plain],
billboardCollection: { show: true, remove() {} },
});
try {
// Both share one 118 px grid cell, so only one of them gets a card.
_updateVesselCardsForTest([carded, plain]);
return Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
} finally {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
_setVesselStateForTest({ enabled: false });
_setVesselOverlayHostForTest();
}
}
test('ru: the callout speed unit is translated', () => {
setLocale('ru');
try {
const objects = detectable(4_000_000);
assert.equal(objects['GOLDEN BEAR'].metric, '10 уз');
} finally {
setLocale('en');
}
assert.equal(detectable(4_000_000)['GOLDEN BEAR'].metric, '10 kn');
});
-1
View File
@@ -69,7 +69,6 @@ export function createVesselState({ source, services }) {
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
activeFocusCount: 0,
activeLabelCount: 0,
/** @type {Set<Object>} Records that have an ambient card this pass. */
selectedRecord: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null,
@@ -1,10 +1,11 @@
// N-09: a scalar overlay that cannot be drawn reports a marked English status
// A scalar overlay that cannot be drawn reports a marked English status
// (rendering.js), which the Wind row shows through t() in the UI language.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js';
import { WIND_FIELDS } from './fields.js';
import { fieldUnavailableText } from './rendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
for (const locale of ['es', 'ru']) {
test(`${locale}: every field's unavailable status has a translation`, () => {
@@ -19,3 +20,10 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('only a known overlay has an unavailable status', () => {
assert.equal(fieldUnavailableText('constructor'), null);
assert.equal(fieldUnavailableText('none'), null);
for (const [overlay, text] of Object.entries(SCALAR_UNAVAILABLE))
assert.equal(fieldUnavailableText(overlay), text);
});
+1
View File
@@ -205,6 +205,7 @@ export function createWindGpuRendering({ cesium: C, getViewer }) {
} catch (error) {
clear();
diagnostics.error =
// i18n-ignore: GPU diagnostics only; the panel never shows this field
error instanceof Error ? error.message : 'Wind geometry unavailable';
return false;
}
+1
View File
@@ -91,6 +91,7 @@ export function createWindLayer({
model: model === 'ifs' ? 'ECMWF' : 'GFS',
validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
// i18n-ignore: English reading for AI tools; the card status uses t()
status: loading
? 'Loading forecast'
: error ||
+6 -4
View File
@@ -7,6 +7,7 @@ import { orderWeatherImagery } from '../weather/imageryOrder.js';
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
import { msg } from '../../i18n/index.js';
import { createWindGpuRendering } from './gpuRendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
import { advectParticle, sampleWind } from './model.js';
import {
createFieldRaster,
@@ -16,16 +17,17 @@ import {
// A scalar overlay that cannot be drawn, in English for diagnostics; the
// panel status shows it translated (index.js). The scalar texts are the ones
// the source reports (source.js).
// the source reports. setOptions only accepts these overlays.
const FIELD_UNAVAILABLE = Object.freeze({
speed: msg('Wind speed field unavailable'),
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
...SCALAR_UNAVAILABLE,
});
/** English status for a scalar overlay with no drawable field. */
export function fieldUnavailableText(overlay) {
return FIELD_UNAVAILABLE[overlay] || `${overlay} field unavailable`;
return Object.hasOwn(FIELD_UNAVAILABLE, overlay)
? FIELD_UNAVAILABLE[overlay]
: null;
}
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
const WIND_FIELD_FADE_LOW_METERS = 200_000;
+7 -4
View File
@@ -4,6 +4,12 @@ import { readWindBody } from '../../sources/windBody.js';
// wind layer and layer panel show them translated.
const msg = (text) => text;
/** Status of a scalar overlay the provider could not supply, by overlay. */
export const SCALAR_UNAVAILABLE = Object.freeze({
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
});
/** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -44,10 +50,7 @@ export function createWindSource({
const optionalUnavailable =
overlay !== 'none' &&
scalar === undefined &&
scalarError ===
(overlay === 'temperature'
? msg('Temperature field unavailable')
: msg('Mean sea level pressure field unavailable'));
scalarError === SCALAR_UNAVAILABLE[overlay];
const scalarValid =
overlay === 'none'
? scalar === undefined && scalarError === undefined
+3 -509
View File
@@ -10,519 +10,13 @@ import {
import { unavailablePlaceSearch } from './search/placeSearch.js';
import { cancelCameraArrival } from './data/cameraArrival.js';
import { guardCameraAboveGround } from './cameraGroundGuard.js';
import { CITY_POIS } from './cityPois.js';
export { CITY_POIS };
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
let arrivalGeneration = 0;
/**
* 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,
},
],
},
};
/**
* Absolute full-earth camera preset for the zoom_to_globe voice tool. The height
* must stay inside the app's 'global' view-scale band (>12,000 km — classifyViewScale
+2 -4
View File
@@ -12,8 +12,7 @@ import { t, msg } from './i18n/index.js';
export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken';
// The Cockpit refusal per destination noun. Nouns are English identifiers
// from the callers ('camera', 'location', 'photo'…); a noun without its own
// sentence falls back to the template with the noun as given.
// from the callers ('camera', 'location', 'photo'…);
// navigationPolicyFrame.test.mjs collects every noun the callers pass and
// fails when one has no sentence here.
const COCKPIT_REFUSALS = Object.freeze({
@@ -44,8 +43,7 @@ export const COCKPIT_REFUSAL_NOUNS = Object.freeze(
);
function cockpitRefusal(noun) {
const ready = COCKPIT_REFUSALS[noun];
return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun });
return t(COCKPIT_REFUSALS[noun]);
}
/**
+47 -68
View File
@@ -1,7 +1,5 @@
// N-06: voice frame_overhead under Cockpit passed the noun 'frame', which had
// no entry in COCKPIT_REFUSALS, so the toast fell back to the template with
// the English id («Sal de la cabina para volar a frame»). The second test
// collects every noun the callers hand to the navigation policy, so the next
// The Cockpit refusal names its destination in the UI language. The scan
// below collects every noun the callers hand to the navigation policy, so a
// noun without its own sentence fails here instead of reaching the page.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
@@ -59,17 +57,15 @@ function sourceFiles(dir) {
});
}
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
// Calls whose first argument is the destination noun.
const NOUN_FIRST =
/\b(runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|runNavigation|navigation\??\.(?:run|begin))\s*\(\s*([^,)]*)/g;
// runManagedVoiceNavigation(styleManager, noun, action, …) in gevActions.js.
const VOICE_NOUN = /\brunManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,\s*([^,)]*)/g;
// Parameter defaults: `noun = 'location'`.
const NOUN_DEFAULT = /\bnoun\s*=\s*'([^']+)'/g;
// A local noun picked by a condition: `const noun = … ? 'cyclone' : 'weather'`.
const NOUN_CHOICE = /\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
// The destination noun: the first argument of a navigation call, or the
// second of runManagedVoiceNavigation(styleManager, noun, …) in gevActions.js.
// `runNavigation(fly)` layer seams take only the flight (group 1).
const NOUN_ARGUMENT =
/\b(?:(runNavigation)\s*\(|(?:runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|navigation\??\.(?:run|begin))\s*\(|runManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,)\s*([^,)]*)/g;
// Literal nouns elsewhere: a parameter default (`noun = 'location'`) or both
// branches of `const noun = … ? 'cyclone' : 'weather'`.
const NOUN_LITERAL =
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
function trackableKinds() {
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
@@ -78,54 +74,36 @@ function trackableKinds() {
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
}
// Non-literal first arguments the test knows how to expand. Anything else is
// a new dynamic noun: list its values here.
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
// its arguments) passes on; its default is a NOUN_LITERAL. Anything else is a
// new dynamic noun: list its values here.
const DYNAMIC_NOUNS = new Map([
['family.kind', trackableKinds],
[
"detail?.kind || 'target'",
() => [...Object.keys(WORLD_FOCUS_FRAMING), 'target'],
],
['noun', () => []],
['...args', () => []],
['...arguments', () => []],
]);
// Wrappers that pass their own `noun` parameter (or all arguments) on.
const PASS_THROUGH = new Set(['noun', '...args', '...arguments']);
function collectNouns() {
const nouns = new Map();
const nouns = new Set();
const unknown = [];
const add = (noun, where) => {
if (!nouns.has(noun)) nouns.set(noun, where);
};
for (const file of sourceFiles(SRC)) {
const text = readFileSync(file, 'utf8');
const name = relative(SRC, file);
const take = (raw, where, { closures = false } = {}) => {
for (const [, seam, raw] of text.matchAll(NOUN_ARGUMENT)) {
const arg = raw.trim();
const literal = arg.match(/^'([^']*)'$/);
if (literal) return add(literal[1], where);
// A wrapper's own parameter; its default is collected by NOUN_DEFAULT.
if (PASS_THROUGH.has(arg) || /^noun\s*=\s*'[^']+'$/.test(arg)) return;
if (DYNAMIC_NOUNS.has(arg)) {
for (const noun of DYNAMIC_NOUNS.get(arg)()) add(noun, where);
return;
}
// The layer-side `runNavigation(fly)` seams take only the flight; their
// noun is bound where the seam is built.
if (closures) return;
unknown.push(`${where}: ${arg}`);
};
for (const m of text.matchAll(NOUN_FIRST))
take(m[2], `${name}:${lineOf(text, m.index)}`, {
closures: m[1] === 'runNavigation',
});
for (const m of text.matchAll(VOICE_NOUN))
take(m[1], `${name}:${lineOf(text, m.index)}`);
for (const m of text.matchAll(NOUN_DEFAULT))
add(m[1], `${name}:${lineOf(text, m.index)}`);
for (const m of text.matchAll(NOUN_CHOICE)) {
add(m[1], `${name}:${lineOf(text, m.index)}`);
add(m[2], `${name}:${lineOf(text, m.index)}`);
const literal = arg.match(/^'([^']*)'$/)?.[1];
if (literal) nouns.add(literal);
else if (DYNAMIC_NOUNS.has(arg))
DYNAMIC_NOUNS.get(arg)().forEach((noun) => nouns.add(noun));
else if (!seam && !/^noun\s*=/.test(arg))
unknown.push(`${relative(SRC, file)}: ${arg}`);
}
for (const m of text.matchAll(NOUN_LITERAL))
m.slice(1).forEach((noun) => noun && nouns.add(noun));
}
return { nouns, unknown };
}
@@ -134,25 +112,26 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
const { nouns, unknown } = collectNouns();
assert.deepEqual(unknown, [], 'a new dynamic noun: add it to DYNAMIC_NOUNS');
// The scan still sees the known callers (a guard against a broken regex).
for (const noun of ['location', 'camera', 'photo', 'shared view', 'file'])
for (const noun of [
'location',
'camera',
'photo',
'shared view',
'file',
'scene',
'route',
'weather',
'cyclone',
'vessel',
'fire',
'frame',
'aircraft',
'satellite',
'target',
])
assert.ok(nouns.has(noun), `found ${noun}`);
for (const noun of ['scene', 'route', 'weather', 'cyclone', 'vessel', 'fire'])
assert.ok(nouns.has(noun), `found ${noun}`);
for (const noun of ['frame', 'aircraft', 'satellite', 'target'])
assert.ok(nouns.has(noun), `found ${noun}`);
const missing = [...nouns]
.filter(([noun]) => !COCKPIT_REFUSAL_NOUNS.includes(noun))
.map(([noun, where]) => `${noun} (${where})`);
const missing = [...nouns].filter(
(noun) => !COCKPIT_REFUSAL_NOUNS.includes(noun),
);
assert.deepEqual(missing, [], 'add these nouns to COCKPIT_REFUSALS');
setLocale('es');
for (const noun of nouns.keys()) {
const shown = cockpitToast(noun);
assert.notEqual(
shown,
`Sal de la cabina para volar a ${noun}`,
`${noun}: the template with the English id`,
);
}
});
+1 -1
View File
@@ -119,7 +119,7 @@ export function createSceneSharing(director) {
{
scene: pack.scene,
pack: pack.id,
status: pack.status,
status: t(pack.status),
path: pack.path,
attribution: pack.attribution.text,
license: pack.attribution.license,
+9 -8
View File
@@ -1,13 +1,18 @@
// Imported by the server: no i18n here. The text stays English data; layers
// show it translated (translateKnown).
const msg = (text) => text;
const tooLarge = () =>
Object.assign(new Error(msg('Upstream response too large')), {
code: 'RESPONSE_TOO_LARGE',
});
/**
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an
* oversized Content-Length, then streams with a running cap so a chunked or
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
*/
export async function readResponseTextCapped(response, maxBytes, signal) {
const tooLarge = () =>
Object.assign(new Error('Upstream response too large'), {
code: 'RESPONSE_TOO_LARGE',
});
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
void response.body?.cancel().catch(() => {});
@@ -61,10 +66,6 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
* Throws { code:'RESPONSE_TOO_LARGE' }.
*/
export async function readResponseBytesCapped(response, maxBytes, signal) {
const tooLarge = () =>
Object.assign(new Error('Upstream response too large'), {
code: 'RESPONSE_TOO_LARGE',
});
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
await response.body?.cancel().catch(() => {});
+1 -1
View File
@@ -10,7 +10,7 @@
* Records contain observation data only, with no scene objects or transport data.
*/
// Messages are English data (AI tools read them too); the Layers panel shows
// them translated by their shape (translateFeedError in src/ui/layerPanel.js),
// them translated by their shape (translateFeedError in src/ui/statusText.js),
// so a new or reworded message is changed there as well.
export class LiveSourceError extends Error {
constructor(
-4
View File
@@ -714,10 +714,6 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
assert.ok(gpx.children[0].style.background);
});
// N-08: tn() replaces values.count with the raw number, so the
// formatNumber(...) passed by the import list is lost: "12345 map objects"
// (main: "12,345"). Append to src/ui/geoFileImport.test.mjs (uses its setup(),
// file(), rows() helpers).
test('the map-object count in a row title keeps digit grouping', async () => {
const t = setup({
load: async (f) => ({
-2
View File
@@ -117,8 +117,6 @@ const SOURCE_LABEL_KEYS = new Map(
msg('OSM routing'),
msg('Mapped camera locations'),
msg('OpenStreetMap · community mapped'),
msg('Decoder feeds'),
msg('WebUSB + decoder feeds'),
].map((key) => [key, key]),
);
+1 -190
View File
@@ -14,12 +14,12 @@ import {
} from './layerNames.js';
export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js';
import { translateFeedError, translateLoadingLabel } from './statusText.js';
import {
formatNumber,
getLocale,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
@@ -146,197 +146,8 @@ export function layerKeyRequirementTooltip(layer = {}) {
);
}
// Live-source failures arrive as English data (src/sources/live/contract.js,
// standalone.js, flights and military ingestion), some with a source name or a
// number in them; the row shows them in the UI language. Keep these in step
// with the producers. Anything else shows its catalog entry, or as it came.
const FEED_ERRORS = new Set([
msg('OpenSky OAuth client missing/invalid'),
msg('OpenSky OAuth rejected credentials'),
msg('OpenSky username/password rejected'),
msg('OpenSky auth missing'),
msg('OpenSky auth required'),
msg('OpenSky auth invalid'),
msg('OpenSky auth failed'),
]);
const FEED_ERROR_PATTERNS = [
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
// ahead of the generic "<source> HTTP <status>".
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^Panoramax answered HTTP (\d+)$/,
([, status]) => t('Panoramax answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
// (sources/vectorTiles.js) failures carry their own wording; the generic
// pattern below would take "Weather image" for a source name.
[
/^Weather (image )?HTTP (\d+)$/,
([, image, status]) =>
image
? t('Weather image HTTP {status}', { status })
: t('Weather HTTP {status}', { status }),
],
[
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
// Layer statuses with a number or a feed name in them: GNSS
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
// feeds (transit/ingestion.js).
[
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
],
[
/^(\d+) CelesTrak groups? unavailable$/,
([, groups]) =>
tn(
Number(groups),
'{count} CelesTrak group unavailable',
'{count} CelesTrak groups unavailable',
{ count: formatNumber(Number(groups)) },
),
],
[
/^(.+) feed unavailable$/,
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
],
[
/^(.+) rate limited \(anonymous\)$/,
([, source]) =>
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
],
[
/^(.+) rate limited$/,
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
],
[
/^(.+) HTTP (\d{3})$/,
([, source, status]) =>
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
],
[
/^(.+) network error$/,
([, source]) =>
t('{source} network error', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) aircraft rows$/,
([, source]) =>
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) response$/,
([, source]) =>
t('Malformed {source} response', { source: sourceLabel(source) }),
],
];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return null;
}
/**
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
* several statuses joined by " · " (Local ADS-B) translates piece by piece.
*/
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
const whole = feedErrorText(text);
if (whole !== null) return whole;
if (!text.includes(ROW_TEXT_SEPARATOR)) return text;
return text
.split(ROW_TEXT_SEPARATOR)
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
.join(ROW_TEXT_SEPARATOR);
}
const ROW_TEXT_SEPARATOR = ' · ';
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
const FEED_PROBLEM_FORMS = Object.freeze({
stale: (count, names) =>
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
unreachable: (count, names) =>
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
names,
}),
invalid: (count, names) =>
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
});
// Working texts with a number or a name in them arrive as English data, one
// piece of a " · " joined line each: transit (transit/queries.js) and Local
// ADS-B (localAdsb/status.js). Keep these in step with the producers.
const STATUS_PIECE_PATTERNS = [
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
[
/^(\d+) heard$/,
([, heard]) =>
tn(Number(heard), '{count} heard', '{count} heard', {
count: formatNumber(Number(heard)),
}),
],
[
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
([, usb, rate]) =>
usb
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
],
[
/^(\d+) feeds? live$/,
([, live]) =>
tn(Number(live), '{count} feed live', '{count} feeds live', {
count: formatNumber(Number(live)),
}),
],
[
/^feeds? (.+) (stale|unreachable|invalid)$/,
([, names, status]) =>
FEED_PROBLEM_FORMS[status](names.split(', ').length, names),
],
];
/** One piece of a status line in the UI language, or as it came. */
function translateStatusPiece(piece) {
const known = translateKnown(piece);
if (known !== piece) return known;
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
const match = pattern.exec(piece);
if (match) return format(match);
}
return piece;
}
/** A loading label is English data, at times several joined by " · ". */
function translateLoadingLabel(label) {
return label
.trim()
.split(ROW_TEXT_SEPARATOR)
.map(translateStatusPiece)
.join(ROW_TEXT_SEPARATOR);
}
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
// error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
+2 -3
View File
@@ -1,6 +1,5 @@
// N-03: layer status texts produced without msg()/catalog entries (GNSS,
// Transit, CelesTrak, AQHI) stay English in the es/ru DATA LAYERS row.
// Place: src/ui/layerStatusLocale.test.mjs
// The DATA LAYERS row shows the English status texts that GNSS, Transit,
// CelesTrak and AQHI produce in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
+3 -1
View File
@@ -10,7 +10,8 @@ import {
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { getLocale, t, msg } from '../i18n/index.js';
import { localizeLayerName, translateFeedError } from './layerPanel.js';
import { localizeLayerName } from './layerPanel.js';
import { translateFeedError } from './statusText.js';
// loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so
@@ -56,6 +57,7 @@ const CHIP_ERROR_KEYS = new Map(
msg('Vector tile request timed out'),
msg('Camera coverage unavailable'),
msg('Camera source temporarily unavailable'),
msg('Overpass returned an incomplete camera response'),
].map((key) => [key.toUpperCase(), key]),
);
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
@@ -5,7 +5,13 @@
// catalog entry, not only the default one.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { setLocale, t } from '../i18n/index.js';
import {
aggregateLayerLoading,
createLoadingFeedbackState,
presentLoadingFeedback,
reduceLoadingFeedback,
} from '../loadingFeedback.js';
const elements = {
'global-loading-status': { hidden: true, dataset: {} },
@@ -107,4 +113,30 @@ for (const locale of ['es', 'ru']) {
assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
assert.notEqual(localized.detail, 'retry pending');
});
// An incomplete Overpass answer (alpr/source.js) on the ALPR retry chip,
// which shows the layer's error upper-cased.
test(`${locale}: the ALPR retry chip translates an incomplete Overpass answer`, () => {
setLocale(locale);
const error = 'Overpass returned an incomplete camera response';
assert.notEqual(t(error), error, 'the catalog has the entry');
const now = Date.now();
const layer = {
id: 'alpr-cameras',
name: 'ALPR cameras',
enabled: true,
stats: { status: 'unavailable', error, retryAt: now + 12000 },
};
const summary = aggregateLayerLoading([layer]);
const state = reduceLoadingFeedback(
createLoadingFeedbackState(),
summary,
now,
);
const shown = localizeLoadingPresentation(
presentLoadingFeedback(state, summary, now),
);
assert.equal(shown.state, 'retry', 'the ALPR retry chip is up');
assert.equal(shown.label, t(error).toLocaleUpperCase(locale));
});
}
+4 -8
View File
@@ -1,8 +1,7 @@
// Shell toasts, prompts and launcher labels that reach the page through
// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant
// are invisible to the unmarked-text checker, so pin them here: marked in the
// code, present in every shipped catalog, and no bare literal left at the
// call sites.
// Shell toasts, prompts and launcher labels: pin that they are marked in the
// code, present in every shipped catalog, and that no bare literal is left at
// the call sites (`window.prompt/confirm` and constants are invisible to the
// unmarked-text checker).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -27,7 +26,6 @@ const KEYS = [
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a photo',
'Exit cockpit to fly to a shared view',
'Exit cockpit to fly to a {noun}',
'Go to expanded Radio section',
'Expand Radio section in Context',
'Double-click the header to snap the panel back',
@@ -84,12 +82,10 @@ test('the Cockpit refusal keeps its English wording per noun', () => {
cockpitActive: true,
showToast,
});
runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast });
assert.deepEqual(log, [
'Exit cockpit to fly to a camera',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a location',
'Exit cockpit to fly to a contact',
]);
});
+231
View File
@@ -0,0 +1,231 @@
/**
* Layer status texts in the UI language.
*
* Layers report errors, loading labels and status notes as English data
* (stats, analyst records, what the model receives); the Layers panel, the
* loading chip, layer cards and the voice card show them translated through
* here, by their shape.
*
* Pure: imports only i18n and layer names, no DOM.
* @module ui/statusText
*/
import { sourceLabel } from './layerNames.js';
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
const STATUS_SEPARATOR = ' · ';
// Live-source failures arrive as English data (src/sources/live/contract.js,
// standalone.js, flights and military ingestion), some with a source name or a
// number in them; the row shows them in the UI language. Keep these in step
// with the producers. Anything else shows its catalog entry, or as it came.
const FEED_ERRORS = new Set([
msg('OpenSky OAuth client missing/invalid'),
msg('OpenSky OAuth rejected credentials'),
msg('OpenSky username/password rejected'),
msg('OpenSky auth missing'),
msg('OpenSky auth required'),
msg('OpenSky auth invalid'),
msg('OpenSky auth failed'),
]);
const FEED_ERROR_PATTERNS = [
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
// ahead of the generic "<source> HTTP <status>".
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^Panoramax answered HTTP (\d+)$/,
([, status]) => t('Panoramax answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
// (sources/vectorTiles.js) failures carry their own wording; the generic
// pattern below would take "Weather image" for a source name.
[
/^Weather (image )?HTTP (\d+)$/,
([, image, status]) =>
image
? t('Weather image HTTP {status}', { status })
: t('Weather HTTP {status}', { status }),
],
[
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
// Layer statuses with a number or a feed name in them: GNSS
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
// feeds (transit/ingestion.js).
[
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
],
[
/^(\d+) CelesTrak groups? unavailable$/,
([, groups]) =>
tn(
Number(groups),
'{count} CelesTrak group unavailable',
'{count} CelesTrak groups unavailable',
{ count: formatNumber(Number(groups)) },
),
],
[
/^(.+) feed unavailable$/,
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
],
[
/^(.+) rate limited \(anonymous\)$/,
([, source]) =>
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
],
[
/^(.+) rate limited$/,
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
],
[
/^(.+) HTTP (\d{3})$/,
([, source, status]) =>
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
],
[
/^(.+) network error$/,
([, source]) =>
t('{source} network error', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) aircraft rows$/,
([, source]) =>
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) response$/,
([, source]) =>
t('Malformed {source} response', { source: sourceLabel(source) }),
],
];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return null;
}
/**
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
* several statuses joined by " · " (Local ADS-B) translates piece by piece,
* and so does a lone status piece ("feed 1090 unreachable").
*/
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
const whole = feedErrorText(text);
if (whole !== null) return whole;
return text
.split(STATUS_SEPARATOR)
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
.join(STATUS_SEPARATOR);
}
// A decoder feed with no band is named by the server's "entry N" (its place
// in LOCAL_RECEIVER_FEEDS, server/providers/local-receivers.js).
const FEED_ENTRY = /^entry (\d+)$/;
function shownFeedName(name) {
const entry = FEED_ENTRY.exec(name);
return entry ? t('entry {n}', { n: entry[1] }) : name;
}
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
const FEED_PROBLEM_FORMS = Object.freeze({
stale: (count, names) =>
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
unreachable: (count, names) =>
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
names,
}),
invalid: (count, names) =>
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
});
// Working texts with a number or a name in them arrive as English data, one
// piece of a " · " joined line each: transit (transit/queries.js) and Local
// ADS-B (localAdsb/status.js). The Layers panel and the voice card's analyst
// notes show them through here. Keep these in step with the producers.
const STATUS_PIECE_PATTERNS = [
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
[
/^(\d+) heard$/,
([, heard]) =>
tn(Number(heard), '{count} heard', '{count} heard', {
count: formatNumber(Number(heard)),
}),
],
[
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
([, usb, rate]) =>
usb
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
],
[
/^(\d+) feeds? live$/,
([, live]) =>
tn(Number(live), '{count} feed live', '{count} feeds live', {
count: formatNumber(Number(live)),
}),
],
[
/^feeds? (.+) (stale|unreachable|invalid)$/,
([, names, status]) => {
const list = names.split(', ');
return FEED_PROBLEM_FORMS[status](
list.length,
list.map(shownFeedName).join(', '),
);
},
],
// A quiet transit feed in the coverage line, "TTC 0 (95s old)"
// (transit/queries.js); the feed name and the numbers stay as they came.
[
/^(.+) (\d+) \((\d+)s old\)$/,
([, feed, count, seconds]) =>
t('{feed} {count} ({seconds}s old)', { feed, count, seconds }),
],
];
/** One piece of a status line in the UI language, or as it came. */
export function translateStatusPiece(piece) {
const known = translateKnown(piece);
if (known !== piece) return known;
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
const match = pattern.exec(piece);
if (match) return format(match);
}
return piece;
}
/** A status line of " · " joined pieces in the UI language, piece by piece. */
export function translateStatusLine(text) {
return text
.split(STATUS_SEPARATOR)
.map(translateStatusPiece)
.join(STATUS_SEPARATOR);
}
/** A loading label is English data, at times several joined by " · ". */
export function translateLoadingLabel(label) {
return translateStatusLine(label.trim());
}
+98
View File
@@ -0,0 +1,98 @@
// Layer status texts arrive as English data and show in the UI language by
// their shape; English shows exactly as it came.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t, translateKnown } from '../i18n/index.js';
import { sourceLabel } from './layerNames.js';
import {
translateFeedError,
translateLoadingLabel,
translateStatusLine,
} from './statusText.js';
import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js';
import { readResponseTextCapped } from '../sources/httpBody.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
const STATUS_LINES = [
'feed 1090 unreachable',
'feeds 1090, entry 2 invalid',
'3 heard · USB 5.8 msg/s · feed 1090 stale',
'2 feeds live · 14 heard',
'TTC 0 (95s old)',
'TTC 0 · GO Transit 4 (95s old)',
'Loading TTC, MiWay',
];
test('en: status lines and errors show as they came', () => {
setLocale('en');
for (const text of [
...STATUS_LINES,
'ECCC HTTP 503',
'GNSS feed unavailable (HTTP 503)',
'Upstream response too large',
]) {
assert.equal(translateFeedError(text), text);
assert.equal(translateStatusLine(text), text);
assert.equal(translateLoadingLabel(` ${text} `), text);
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: a lone feed problem translates like a joined one`, () => {
inLocale(locale, () => {
const lone = translateFeedError('feed 1090 unreachable');
assert.doesNotMatch(lone, /unreachable/, lone);
assert.equal(
translateFeedError('feed 1090 unreachable · 2 heard').split(' · ')[0],
lone,
);
});
});
test(`${locale}: a feed with no band is named in the UI language`, () => {
inLocale(locale, () => {
const shown = translateFeedError('feeds 1090, entry 2 invalid');
assert.doesNotMatch(shown, /\b(entry|invalid)\b/, shown);
assert.ok(shown.includes(t('entry {n}', { n: 2 })), shown);
assert.ok(shown.includes('1090'), shown);
});
});
test(`${locale}: a quiet transit feed keeps its name and numbers`, () => {
inLocale(locale, () => {
const shown = translateStatusLine('TTC 0 · GO Transit 4 (95s old)');
assert.doesNotMatch(shown, /\bold\b/, shown);
assert.ok(shown.startsWith('TTC 0 · GO Transit 4 '), shown);
assert.ok(shown.includes('95'), shown);
});
});
test(`${locale}: an oversized upstream response is translated`, async () => {
const response = new Response('x'.repeat(10), {
headers: { 'content-length': '10' },
});
const error = await readResponseTextCapped(response, 4).catch((e) => e);
assert.equal(error.message, 'Upstream response too large');
inLocale(locale, () => {
assert.notEqual(translateKnown(error.message), error.message);
assert.equal(translateFeedError(error.message), t(error.message));
});
});
test(`${locale}: Local ADS-B source names translate without their own entry`, () => {
inLocale(locale, () => {
for (const source of [FEED_SOURCE, COMBINED_SOURCE]) {
assert.notEqual(t(source), source, 'the catalog has the entry');
assert.equal(sourceLabel(source), t(source));
}
});
});
}
+2 -2
View File
@@ -79,8 +79,8 @@ html[lang='es'] .cctv-controls {
}
/* A translated label longer than its button wraps inside the button instead
of running past the panel edge. English keeps its one-line buttons (unguarded,
this rule broke AUTO HOP OFF onto two lines). */
of running past the panel edge. English is excluded so its labels (AUTO HOP
OFF) stay on one line. */
html:not([lang='en']) .cctv-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
+80
View File
@@ -0,0 +1,80 @@
// The analyst card's empty notes can be a layer's status line: Local ADS-B
// with every decoder feed stale, Transit with a quiet feed ("(95s old)"). The
// card shows them in the UI language; the model gets the English.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { attachVoiceResult } from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
function notesFor(layerKey, caveat) {
const shaped = attachVoiceResult(
'analyst_query',
{
ok: true,
count: 0,
complete: true,
scopeLabel: 'in view',
feedState: 'nominal',
unanswered: [],
coverage: { layersQueried: [{ layerKey, status: 'empty' }] },
display: { scope: 'in view', caveat },
items: [],
},
{},
);
return voiceCardView(
[
{
type: 'action-call',
callId: 'c1',
name: 'analyst_query',
arguments: {},
},
{
type: 'action-result',
callId: 'c1',
name: 'analyst_query',
result: shaped,
},
].reduce(reduceVoiceCard, initialVoiceCardState()),
).result.notes;
}
// Caveat = `${layerKey}: ${getEmptyNote(layerKey)}` (analystEngine.js:1026,
// gevActions.js getEmptyNote → stats.coverage || stats.statusMessage).
const cases = [
['local-adsb', 'local-adsb: feeds 1090, 978 stale · 0 heard'],
['local-adsb', 'local-adsb: feed 1090 stale · 0 heard'],
['transit', 'transit: TTC 0 (95s old)'],
['transit', 'transit: TTC 0 · GO Transit 0 (95s old)'],
];
for (const locale of ['es', 'ru']) {
for (const [layerKey, caveat] of cases) {
test(`${locale}: analyst note "${caveat}" is translated`, () => {
setLocale(locale);
try {
const notes = notesFor(layerKey, caveat);
assert.ok(notes.length > 0, 'the note is shown');
assert.doesNotMatch(notes.join(' | '), /\b(stale|heard|old)\b/);
} finally {
setLocale('en');
}
});
}
}
for (const [layerKey, caveat] of cases) {
test(`en: analyst note "${caveat}" keeps its status line`, () => {
setLocale('en');
const line = caveat.slice(caveat.indexOf(': ') + 2);
assert.ok(
notesFor(layerKey, caveat).some((note) => note.endsWith(`: ${line}`)),
);
});
}
+2 -3
View File
@@ -1,6 +1,5 @@
// N-07: the analyst card shows a layer's "empty" note from getStats().coverage
// as is; Transit's notes are English template strings with no catalog entry,
// so the es/ru card note stays English. Place: src/voice/analystTransitNote.test.mjs
// The analyst card shows a layer's "empty" note (Transit's English
// getStats().coverage text) in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
-67
View File
@@ -1,67 +0,0 @@
// N-05: plan steps for preset ids from the tool schema (sf, nyc, dc) read
// "Sf" / "Nyc" / "Dc" in every language; 'tokyo' translates.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { CITY_POIS } from '../locations.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { narrationLabel, planStepLabel } from './speech.js';
test.afterEach(() => setLocale('en'));
test('es: preset location ids show the city name', () => {
setLocale('es');
assert.equal(
planStepLabel('fly_to_location', { locationId: 'tokyo' }),
'Volar a Tokio',
);
for (const id of ['sf', 'nyc', 'dc']) {
const label = planStepLabel('fly_to_location', { locationId: id });
assert.doesNotMatch(label, /\b(Sf|Nyc|Dc)$/, label);
}
assert.equal(
planStepLabel('fly_to_location', { locationId: 'nyc' }),
'Volar a Nueva York',
);
});
test('ru: a preset id in the nearest-aircraft step names the city', () => {
setLocale('ru');
assert.match(
planStepLabel('select_nearest_aircraft', { locationId: 'dc' }),
/Вашингтон/,
);
});
test('en: every preset id reads as its CITY_POIS name', () => {
setLocale('en');
for (const [id, city] of Object.entries(CITY_POIS)) {
assert.equal(
planStepLabel('fly_to_location', { locationId: id }),
`Fly to ${city.name}`,
id,
);
assert.equal(
narrationLabel('fly_to_location', { locationId: id }),
city.name,
);
}
// Other places are shown as given.
assert.equal(
planStepLabel('fly_to_location', { query: 'Ulaanbaatar' }),
'Fly to Ulaanbaatar',
);
});
test('every locationId the voice tools accept is a preset city', () => {
const ids = new Set();
const visit = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.locationId?.enum))
node.locationId.enum.forEach((id) => ids.add(id));
Object.values(node).forEach(visit);
};
visit(GEV_ACTION_SCHEMAS);
assert.ok(ids.size > 0, 'found the locationId enums');
for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id);
});
+5 -1
View File
@@ -28,6 +28,7 @@ import {
} from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { translateStatusLine } from '../ui/statusText.js';
import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({
@@ -372,7 +373,10 @@ function shownNote(text) {
const shown = match ? show(match) : null;
if (shown) return shown;
}
return translateKnown(text);
// A layer's empty note can be its status line, English pieces joined by
// " · " ("feeds 1090, 978 stale · 0 heard", "TTC 0 (95s old)").
const known = translateKnown(text);
return known !== text ? known : translateStatusLine(text);
}
/** Feed states as the card's chips show them; the values stay English. */
+3 -21
View File
@@ -39,6 +39,7 @@ import {
sourceLabel,
} from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js';
import { CITY_POIS } from '../cityPois.js';
import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({
@@ -344,28 +345,10 @@ function shownPair(items) {
: items[0] || '';
}
/**
* The preset city ids the voice tools take (the `locationId` enum in
* actionSchemas.js) and the CITY_POIS names they stand for, so `sf` reads
* "San Francisco", not "Sf". A copy, not an import: src/locations.js pulls in
* Cesium. planStepPresetIds.i18n.test.mjs keeps it in step with CITY_POIS.
*/
const PRESET_PLACE_NAMES = Object.freeze({
austin: 'Austin',
sf: 'San Francisco',
nyc: 'New York',
tokyo: 'Tokyo',
london: 'London',
paris: 'Paris',
dubai: 'Dubai',
dc: 'Washington DC',
tallinn: 'Tallinn',
});
/** A preset city id as its English name; any other value unchanged. */
/** A preset city id (`sf`, `nyc`) as its English name; any other value unchanged. */
function presetPlaceName(value) {
const id = typeof value === 'string' ? value.trim() : '';
return Object.hasOwn(PRESET_PLACE_NAMES, id) ? PRESET_PLACE_NAMES[id] : value;
return Object.hasOwn(CITY_POIS, id) ? CITY_POIS[id].name : value;
}
// A place as the card shows it: a preset city in the interface language,
@@ -375,7 +358,6 @@ function shownPlace(value, max) {
}
/** Ids such as `austin` or `local-firms` read as names on screen. */
function displayName(value, max) {
const label = spokenLabel(value, max);
return /^[a-z0-9-]+$/.test(label)
+40 -1
View File
@@ -7,7 +7,8 @@ import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { presentResult, shownSnapshotTime } from './resultDisplay.js';
import { attachVoiceResult, planStepLabel } from './speech.js';
import { CITY_POIS } from '../cityPois.js';
import { attachVoiceResult, narrationLabel, planStepLabel } from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
@@ -244,3 +245,41 @@ test('C12: the analyst scope names Los Angeles in the UI language', () => {
assert.match(title, /Los Ángeles$/, 'the headline is not cut at 64');
assert.equal(result.scopeLabel, 'within 40 km of Los Angeles');
});
// A preset city id from the tool schema (sf, nyc, dc) reads as its city name.
test('a preset city id names the city in plan steps and narration', () => {
const step = (id, action = 'fly_to_location') =>
planStepLabel(action, { locationId: id });
assert.equal(step('sf'), 'Fly to San Francisco');
assert.equal(
narrationLabel('fly_to_location', { locationId: 'dc' }),
'Washington DC',
);
assert.equal(
planStepLabel('fly_to_location', { query: 'Ulaanbaatar' }),
'Fly to Ulaanbaatar',
'other places are shown as given',
);
inLocale('es', () => {
assert.equal(step('tokyo'), 'Volar a Tokio');
assert.equal(step('nyc'), 'Volar a Nueva York');
for (const id of ['sf', 'nyc', 'dc'])
assert.doesNotMatch(step(id), /\b(Sf|Nyc|Dc)$/, id);
});
inLocale('ru', () => {
assert.match(step('dc', 'select_nearest_aircraft'), /Вашингтон/);
});
});
test('every locationId the voice tools accept is a preset city', () => {
const ids = new Set();
const visit = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.locationId?.enum))
node.locationId.enum.forEach((id) => ids.add(id));
Object.values(node).forEach(visit);
};
visit(GEV_ACTION_SCHEMAS);
assert.ok(ids.size > 0, 'found the locationId enums');
for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id);
});