diff --git a/docs/I18N.md b/docs/I18N.md index 767172b64..dcd90dd59 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -65,7 +65,7 @@ An empty translation is reported, and so is one that is exactly the English ("Bl `npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. -- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`, `say`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast` (also `showToast?.(…)` and `this._showToast(…)`), `toast`, `setHint`, `announce`, `say`; assigned to a layer's `error`, `_error`, `loadingLabel`, `statusMessage` or `status` (`this._error = …`, `stats.loadingLabel = …`; a `const`/`let` local is not checked); or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:`, `status:`, `loadingLabel:`, `statusMessage:` properties. Lower-case codes without spaces, such as `status: 'auth-failed'`, are not reported. Fix it by marking the text. When the text must stay as it is, say so where it lives: diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index 0f48152a3..bb4259c4d 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -217,8 +217,14 @@ const JS_SINKS = [ /\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g, /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, - /\b(?:showToast|toast|setHint|announce|say)\(\s*/g, - /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm, + // Also showToast?.(…) and this._showToast(…). + /\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g, + // `status` values are often codes ('ok', 'auth-failed'); visibleText drops + // those, so only readable text is reported. + /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm, + // Layer status set by assignment: this._error = …, stats.loadingLabel = …. + // A declared local (`const error = …`) is scratch, not a status field. + /(?])\s*/g, ]; const MARKERS = /\b(?:t|tn|msg)\(/y; diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 02ddce387..736a1d38b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -469,7 +469,8 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/ui/unitLabels.js", - "src/ui/keyGuidance.js" + "src/ui/keyGuidance.js", + "src/ui/statusText.js" ], "external": [] }, @@ -737,7 +738,8 @@ "src/i18n/locales/ru.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", - "src/ui/layerNames.js" + "src/ui/layerNames.js", + "src/ui/statusText.js" ], "external": ["cesium"] }, @@ -1570,6 +1572,7 @@ "src/voice/referents.js", "src/voice/layerManifest.js", "src/voice/speech.js", + "src/cityPois.js", "src/ui/layerNames.js", "src/ui/unitLabels.js", "src/ui/placeNames.js", @@ -1580,7 +1583,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/statusText.js" ], "external": [] }, @@ -1794,6 +1798,7 @@ "src/firstRunExperience.js", "src/hud.js", "src/hudLocality.js", + "src/hudLocalityFormat.js", "src/hudSummaryResponse.js", "src/keySetup.js", "src/keySetupCore.mjs", @@ -2020,6 +2025,7 @@ "src/loadingFeedback.js", "src/locationStatus.js", "src/locations.js", + "src/cityPois.js", "src/logoGaze.js", "src/mapStackChips.js", "src/mapStackController.js", @@ -2436,7 +2442,8 @@ "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", - "src/layers/airQuality/source.js" + "src/layers/airQuality/source.js", + "src/ui/statusText.js" ], "external": [ "hls.js", @@ -2792,6 +2799,7 @@ "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", "src/locations.js", + "src/cityPois.js", "src/navigationPolicy.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", @@ -3000,7 +3008,11 @@ "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", - "src/layers/airQuality/source.js" + "src/layers/airQuality/source.js", + "src/ui/statusText.js", + "src/ui/layerNames.js", + "src/layers/wind/source.js", + "src/sources/windBody.js" ], "external": [ "hls.js", @@ -3039,6 +3051,7 @@ "src/keylessGeocoder.js", "src/layers/firms/anchors.js", "src/locations.js", + "src/cityPois.js", "src/cameraGroundGuard.js", "src/search/alpsGeocoder.js", "src/search/defaults.js", @@ -3299,13 +3312,15 @@ "src/voice/referents.js", "src/voice/layerManifest.js", "src/voice/speech.js", + "src/cityPois.js", "src/ui/layerNames.js", "src/ui/unitLabels.js", "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/statusText.js" ], "external": [] }, @@ -3647,7 +3662,9 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/statusText.js", + "src/ui/layerNames.js" ], "external": ["cesium"] }, @@ -3825,7 +3842,9 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/statusText.js", + "src/ui/layerNames.js" ], "external": ["cesium"] }, diff --git a/src/cityPois.js b/src/cityPois.js new file mode 100644 index 000000000..d0fc8c04c --- /dev/null +++ b/src/cityPois.js @@ -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, + }, + ], + }, +}; diff --git a/src/data/aisWatchdog.js b/src/data/aisWatchdog.js index 6d88f93f8..253482727 100644 --- a/src/data/aisWatchdog.js +++ b/src/data/aisWatchdog.js @@ -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 []; diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 6d050474b..06bfe01af 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -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'); diff --git a/src/director/sharing/preview.js b/src/director/sharing/preview.js index 400d5416d..631ecc48b 100644 --- a/src/director/sharing/preview.js +++ b/src/director/sharing/preview.js @@ -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 { diff --git a/src/hud.js b/src/hud.js index 980cbb6eb..7f2d5df88 100644 --- a/src/hud.js +++ b/src/hud.js @@ -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']); diff --git a/src/hudLocality.test.mjs b/src/hudLocality.test.mjs index 40dcf651b..5bd5f0c9b 100644 --- a/src/hudLocality.test.mjs +++ b/src/hudLocality.test.mjs @@ -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'); + } +}); diff --git a/src/hudLocalityCity.test.mjs b/src/hudLocalityCity.test.mjs deleted file mode 100644 index de6d08c7b..000000000 --- a/src/hudLocalityCity.test.mjs +++ /dev/null @@ -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'); - } -}); diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js new file mode 100644 index 000000000..4a82fdbb3 --- /dev/null +++ b/src/hudLocalityFormat.js @@ -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')), + }), +}); diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs index 4df60577e..6697c547f 100644 --- a/src/i18n/catalog.test.mjs +++ b/src/i18n/catalog.test.mjs @@ -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') };"), []); +}); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 96443109b..a662130a1 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -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'); + } }); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4749947a5..acbb540e3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a13902555..b4b7b6af6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Источник недоступен" } diff --git a/src/keySetup.js b/src/keySetup.js index 0b632f35c..536b5e3fd 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -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, ); diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 786977cab..fcea0fc15 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -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(), []); }); diff --git a/src/layers/airQuality/airQualityLocale.test.mjs b/src/layers/airQuality/airQualityLocale.test.mjs index efd30f6a0..8249ce148 100644 --- a/src/layers/airQuality/airQualityLocale.test.mjs +++ b/src/layers/airQuality/airQualityLocale.test.mjs @@ -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'); + }); +} diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 9fe4d8e01..87f79e4f9 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -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, })), }, diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 5fc0cdcb6..cef9e1563 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -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', }, }; diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 86891f4c7..f3a4102f8 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -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), diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index f7c114a64..1bbd64822 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -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})`, diff --git a/src/layers/gnss/sourceErrorLocale.test.mjs b/src/layers/gnss/sourceErrorLocale.test.mjs index 8618008cc..a09019164 100644 --- a/src/layers/gnss/sourceErrorLocale.test.mjs +++ b/src/layers/gnss/sourceErrorLocale.test.mjs @@ -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'; diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 641b8d157..3c445b04f 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -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() diff --git a/src/layers/localAdsb/singleFeedProblemLocale.test.mjs b/src/layers/localAdsb/singleFeedProblemLocale.test.mjs new file mode 100644 index 000000000..552150320 --- /dev/null +++ b/src/layers/localAdsb/singleFeedProblemLocale.test.mjs @@ -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'); + } + }); + } +} diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index 5fff0d45c..d264c5e15 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -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}`, }; } diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 2c965c05f..273da2b66 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -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'; diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 2d2835526..8a440a54f 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -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', }, }; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 72ccb669d..9021485f6 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -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; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 9aaaa6fc3..5cba24b4e 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -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 diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b50478398..bd3f3328e 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -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, diff --git a/src/layers/vessels/detectionLabel.test.mjs b/src/layers/vessels/detectionLabel.test.mjs deleted file mode 100644 index 85d17cc6d..000000000 --- a/src/layers/vessels/detectionLabel.test.mjs +++ /dev/null @@ -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'); -}); diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index cbb81766e..d0b8afb18 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -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} Records that have an ambient card this pass. */ selectedRecord: null, /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ trail: null, diff --git a/src/layers/wind/fieldUnavailableLocale.test.mjs b/src/layers/wind/fieldUnavailableLocale.test.mjs index ddf3fc495..f6284c0b8 100644 --- a/src/layers/wind/fieldUnavailableLocale.test.mjs +++ b/src/layers/wind/fieldUnavailableLocale.test.mjs @@ -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); +}); diff --git a/src/layers/wind/gpuRendering.js b/src/layers/wind/gpuRendering.js index 3b40aa073..27d71dcd5 100644 --- a/src/layers/wind/gpuRendering.js +++ b/src/layers/wind/gpuRendering.js @@ -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; } diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index b72cf4b09..c76e2501c 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -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 || diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index e3489b96a..d7723f3dd 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -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; diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 233a19799..dbd7b59bc 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -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 diff --git a/src/locations.js b/src/locations.js index 035f8114c..28f2ac923 100644 --- a/src/locations.js +++ b/src/locations.js @@ -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 diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 000d4a3e3..d2ed6ff39 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -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]); } /** diff --git a/src/navigationPolicyFrame.test.mjs b/src/navigationPolicyFrame.test.mjs index cd8f035f6..a861870cf 100644 --- a/src/navigationPolicyFrame.test.mjs +++ b/src/navigationPolicyFrame.test.mjs @@ -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`, - ); - } }); diff --git a/src/scenes/sharing.js b/src/scenes/sharing.js index 183040651..b3adde27a 100644 --- a/src/scenes/sharing.js +++ b/src/scenes/sharing.js @@ -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, diff --git a/src/sources/httpBody.js b/src/sources/httpBody.js index 9c8a28f04..e20af628b 100644 --- a/src/sources/httpBody.js +++ b/src/sources/httpBody.js @@ -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(() => {}); diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js index db82111be..7c2e41788 100644 --- a/src/sources/live/contract.js +++ b/src/sources/live/contract.js @@ -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( diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 762e0024b..347481238 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -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) => ({ diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 65cd910ac..d9674efea 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -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]), ); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b83eef6c1..d876e916c 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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 " HTTP ". - [ - /^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; diff --git a/src/ui/layerStatusLocale.test.mjs b/src/ui/layerStatusLocale.test.mjs index 2957e9334..e017b4b8a 100644 --- a/src/ui/layerStatusLocale.test.mjs +++ b/src/ui/layerStatusLocale.test.mjs @@ -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'; diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index bf0aa0744..b737b2c2d 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -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+)\)$/; diff --git a/src/ui/shellFeedbackStatusLocalization.test.mjs b/src/ui/shellFeedbackStatusLocalization.test.mjs index bcfba7a65..10f2992eb 100644 --- a/src/ui/shellFeedbackStatusLocalization.test.mjs +++ b/src/ui/shellFeedbackStatusLocalization.test.mjs @@ -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)); + }); } diff --git a/src/ui/shellToasts.i18n.test.mjs b/src/ui/shellToasts.i18n.test.mjs index 0d83640d9..9890e01d5 100644 --- a/src/ui/shellToasts.i18n.test.mjs +++ b/src/ui/shellToasts.i18n.test.mjs @@ -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', ]); }); diff --git a/src/ui/statusText.js b/src/ui/statusText.js new file mode 100644 index 000000000..d4308c596 --- /dev/null +++ b/src/ui/statusText.js @@ -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 " HTTP ". + [ + /^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()); +} diff --git a/src/ui/statusText.test.mjs b/src/ui/statusText.test.mjs new file mode 100644 index 000000000..a154e7da6 --- /dev/null +++ b/src/ui/statusText.test.mjs @@ -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)); + } + }); + }); +} diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 7d260d985..ff3259122 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -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; diff --git a/src/voice/analystEmptyNotes.test.mjs b/src/voice/analystEmptyNotes.test.mjs new file mode 100644 index 000000000..7ec493b59 --- /dev/null +++ b/src/voice/analystEmptyNotes.test.mjs @@ -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}`)), + ); + }); +} diff --git a/src/voice/analystTransitNote.test.mjs b/src/voice/analystTransitNote.test.mjs index aa5bf0b16..7aabe0be8 100644 --- a/src/voice/analystTransitNote.test.mjs +++ b/src/voice/analystTransitNote.test.mjs @@ -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'; diff --git a/src/voice/planStepPresetIds.i18n.test.mjs b/src/voice/planStepPresetIds.i18n.test.mjs deleted file mode 100644 index 8da47b740..000000000 --- a/src/voice/planStepPresetIds.i18n.test.mjs +++ /dev/null @@ -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); -}); diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 779578434..9b46b24c9 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -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. */ diff --git a/src/voice/speech.js b/src/voice/speech.js index 1085e11e8..3e04bf1fc 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -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) diff --git a/src/voice/voiceCardPlanSteps.i18n.test.mjs b/src/voice/voiceCardPlanSteps.i18n.test.mjs index 22db9e28e..7a99c292e 100644 --- a/src/voice/voiceCardPlanSteps.i18n.test.mjs +++ b/src/voice/voiceCardPlanSteps.i18n.test.mjs @@ -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); +});