i18n: fix the repeat-review findings

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

Catalogs: es/ru 2617 messages each, complete.

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

No files matched your search

+1 -1
View File
@@ -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: `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-*`. - **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: Fix it by marking the text. When the text must stay as it is, say so where it lives:
+8 -2
View File
@@ -217,8 +217,14 @@ const JS_SINKS = [
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g, /\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
/\b(?:showToast|toast|setHint|announce|say)\(\s*/g, // Also showToast?.(…) and this._showToast(…).
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm, /\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.
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g,
]; ];
const MARKERS = /\b(?:t|tn|msg)\(/y; const MARKERS = /\b(?:t|tn|msg)\(/y;
+27 -8
View File
@@ -469,7 +469,8 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js" "src/ui/keyGuidance.js",
"src/ui/statusText.js"
], ],
"external": [] "external": []
}, },
@@ -737,7 +738,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js" "src/ui/layerNames.js",
"src/ui/statusText.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1570,6 +1572,7 @@
"src/voice/referents.js", "src/voice/referents.js",
"src/voice/layerManifest.js", "src/voice/layerManifest.js",
"src/voice/speech.js", "src/voice/speech.js",
"src/cityPois.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/placeNames.js", "src/ui/placeNames.js",
@@ -1580,7 +1583,8 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/ui/statusText.js"
], ],
"external": [] "external": []
}, },
@@ -1794,6 +1798,7 @@
"src/firstRunExperience.js", "src/firstRunExperience.js",
"src/hud.js", "src/hud.js",
"src/hudLocality.js", "src/hudLocality.js",
"src/hudLocalityFormat.js",
"src/hudSummaryResponse.js", "src/hudSummaryResponse.js",
"src/keySetup.js", "src/keySetup.js",
"src/keySetupCore.mjs", "src/keySetupCore.mjs",
@@ -2020,6 +2025,7 @@
"src/loadingFeedback.js", "src/loadingFeedback.js",
"src/locationStatus.js", "src/locationStatus.js",
"src/locations.js", "src/locations.js",
"src/cityPois.js",
"src/logoGaze.js", "src/logoGaze.js",
"src/mapStackChips.js", "src/mapStackChips.js",
"src/mapStackController.js", "src/mapStackController.js",
@@ -2436,7 +2442,8 @@
"src/app/layers/airQuality.js", "src/app/layers/airQuality.js",
"src/layers/airQuality/index.js", "src/layers/airQuality/index.js",
"src/layers/airQuality/model.js", "src/layers/airQuality/model.js",
"src/layers/airQuality/source.js" "src/layers/airQuality/source.js",
"src/ui/statusText.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -2792,6 +2799,7 @@
"src/layers/vessels/testing.js", "src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js", "src/layers/vessels/tracking.js",
"src/locations.js", "src/locations.js",
"src/cityPois.js",
"src/navigationPolicy.js", "src/navigationPolicy.js",
"src/overlays/worldOverlay.js", "src/overlays/worldOverlay.js",
"src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayDraw.js",
@@ -3000,7 +3008,11 @@
"src/app/layers/airQuality.js", "src/app/layers/airQuality.js",
"src/layers/airQuality/index.js", "src/layers/airQuality/index.js",
"src/layers/airQuality/model.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": [ "external": [
"hls.js", "hls.js",
@@ -3039,6 +3051,7 @@
"src/keylessGeocoder.js", "src/keylessGeocoder.js",
"src/layers/firms/anchors.js", "src/layers/firms/anchors.js",
"src/locations.js", "src/locations.js",
"src/cityPois.js",
"src/cameraGroundGuard.js", "src/cameraGroundGuard.js",
"src/search/alpsGeocoder.js", "src/search/alpsGeocoder.js",
"src/search/defaults.js", "src/search/defaults.js",
@@ -3299,13 +3312,15 @@
"src/voice/referents.js", "src/voice/referents.js",
"src/voice/layerManifest.js", "src/voice/layerManifest.js",
"src/voice/speech.js", "src/voice/speech.js",
"src/cityPois.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/voice/pointerReticle.js", "src/voice/pointerReticle.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json" "src/i18n/locales/ru.json",
"src/ui/statusText.js"
], ],
"external": [] "external": []
}, },
@@ -3647,7 +3662,9 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "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"] "external": ["cesium"]
}, },
@@ -3825,7 +3842,9 @@
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "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"] "external": ["cesium"]
}, },
+515
View File
@@ -0,0 +1,515 @@
/**
* @module cityPois
* @description The preset cities and their landmarks — plain data with no
* Cesium, so modules that only need the names (voice cards) can import it.
* `locations.js` re-exports it.
*/
/**
* Points of Interest per city.
* Each city has 5 POIs; the first is the default fly-to landmark.
*
* Field reference:
* alt — RANGE (distance from target in meters), NOT absolute altitude
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
* pitch — camera tilt in degrees (negative = looking down)
* buildingHeight — estimated height of landmark center above ground (meters)
*/
export const CITY_POIS = {
austin: {
name: 'Austin',
groundElevation: 150, // meters above WGS84 ellipsoid
viewBounds: {
southwest: { lat: 30.1, lng: -97.95 },
northeast: { lat: 30.52, lng: -97.55 },
},
pois: [
{
name: 'Texas State Capitol',
lat: 30.2747,
lon: -97.7403,
alt: 550,
pitch: -28,
heading: 180,
buildingHeight: 35,
},
{
name: 'Frost Bank Tower',
lat: 30.2674,
lon: -97.7434,
alt: 550,
pitch: -22,
heading: 30,
buildingHeight: 80,
},
{
name: 'Pennybacker Bridge',
lat: 30.3451,
lon: -97.7951,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 40,
},
{
name: 'The Jenga Tower',
lat: 30.2642,
lon: -97.75,
alt: 500,
pitch: -18,
heading: 45,
buildingHeight: 60,
},
{
name: 'UT Tower',
lat: 30.2862,
lon: -97.7394,
alt: 500,
pitch: -22,
heading: 180,
buildingHeight: 50,
},
],
},
sf: {
name: 'San Francisco',
groundElevation: 15,
viewBounds: {
southwest: { lat: 37.7, lng: -122.53 },
northeast: { lat: 37.84, lng: -122.35 },
},
pois: [
{
name: 'Golden Gate Bridge',
lat: 37.8199,
lon: -122.4783,
alt: 1400,
pitch: -20,
heading: 45,
buildingHeight: 100,
},
{
name: 'Transamerica Pyramid',
lat: 37.7952,
lon: -122.4028,
alt: 500,
pitch: -25,
heading: 30,
buildingHeight: 85,
},
{
name: 'Salesforce Tower',
lat: 37.7897,
lon: -122.3972,
alt: 680,
pitch: -25,
heading: 330,
buildingHeight: 100,
},
{
name: 'Alcatraz Island',
lat: 37.8267,
lon: -122.423,
alt: 800,
pitch: -30,
heading: 0,
buildingHeight: 20,
},
{
name: 'Coit Tower',
lat: 37.8024,
lon: -122.4058,
alt: 420,
pitch: -30,
heading: 45,
buildingHeight: 30,
},
],
},
nyc: {
name: 'New York',
groundElevation: 10,
viewBounds: {
southwest: { lat: 40.477, lng: -74.259 },
northeast: { lat: 40.918, lng: -73.7 },
},
pois: [
{
name: 'Statue of Liberty',
lat: 40.6892,
lon: -74.0445,
alt: 450,
pitch: -25,
heading: 315,
buildingHeight: 45,
},
{
name: 'Empire State Building',
lat: 40.7484,
lon: -73.9857,
alt: 850,
pitch: -12,
heading: 30,
buildingHeight: 130,
buildingBounds: { height: 443, width: 130, depth: 75 },
},
{
name: 'One World Trade Center',
lat: 40.7127,
lon: -74.0134,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 170,
},
{
name: 'Brooklyn Bridge',
lat: 40.7061,
lon: -73.9969,
alt: 850,
pitch: -25,
heading: 45,
buildingHeight: 40,
},
{
name: 'Chrysler Building',
lat: 40.7516,
lon: -73.9755,
alt: 700,
pitch: -20,
heading: 225,
buildingHeight: 100,
},
],
},
tokyo: {
name: 'Tokyo',
groundElevation: 40,
viewBounds: {
southwest: { lat: 35.52, lng: 139.55 },
northeast: { lat: 35.9, lng: 139.92 },
},
pois: [
{
name: 'Tokyo Tower',
lat: 35.6586,
lon: 139.7454,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 110,
},
{
name: 'Tokyo Skytree',
lat: 35.7101,
lon: 139.8107,
alt: 900,
pitch: -25,
heading: 30,
buildingHeight: 200,
},
{
name: 'Imperial Palace',
lat: 35.6852,
lon: 139.7528,
alt: 900,
pitch: -35,
heading: 0,
buildingHeight: 20,
},
{
name: 'Senso-ji Temple',
lat: 35.7148,
lon: 139.7967,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 25,
},
{
name: 'Mode Gakuen Cocoon Tower',
lat: 35.6929,
lon: 139.6925,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 70,
},
],
},
london: {
name: 'London',
groundElevation: 15,
viewBounds: {
southwest: { lat: 51.28, lng: -0.51 },
northeast: { lat: 51.7, lng: 0.33 },
},
pois: [
{
name: 'Tower Bridge',
lat: 51.5055,
lon: -0.0754,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 65,
},
{
name: 'The Shard',
lat: 51.5045,
lon: -0.0865,
alt: 850,
pitch: -20,
heading: 0,
buildingHeight: 100,
},
{
name: 'Big Ben / Parliament',
lat: 51.5007,
lon: -0.1246,
alt: 600,
pitch: -25,
heading: 180,
buildingHeight: 50,
},
{
name: "St. Paul's Cathedral",
lat: 51.5138,
lon: -0.0984,
alt: 400,
pitch: -30,
heading: 270,
buildingHeight: 55,
},
{
name: 'The Gherkin',
lat: 51.5145,
lon: -0.0803,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 60,
},
],
},
paris: {
name: 'Paris',
groundElevation: 35,
viewBounds: {
southwest: { lat: 48.815, lng: 2.224 },
northeast: { lat: 48.902, lng: 2.47 },
},
pois: [
{
name: 'Eiffel Tower',
lat: 48.8584,
lon: 2.2945,
alt: 750,
pitch: -25,
heading: 315,
buildingHeight: 150,
},
{
name: 'Arc de Triomphe',
lat: 48.8738,
lon: 2.295,
alt: 400,
pitch: -28,
heading: 45,
buildingHeight: 25,
},
{
name: 'Notre-Dame',
lat: 48.853,
lon: 2.3499,
alt: 400,
pitch: -25,
heading: 225,
buildingHeight: 35,
},
{
name: 'Sacré-Cœur',
lat: 48.8867,
lon: 2.3431,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 40,
},
{
name: 'Louvre Pyramid',
lat: 48.8606,
lon: 2.3376,
alt: 500,
pitch: -35,
heading: 0,
buildingHeight: 10,
},
],
},
dubai: {
name: 'Dubai',
groundElevation: 5,
viewBounds: {
southwest: { lat: 24.95, lng: 54.9 },
northeast: { lat: 25.35, lng: 55.55 },
},
pois: [
{
name: 'Burj Khalifa',
lat: 25.1972,
lon: 55.2744,
alt: 600,
pitch: -20,
heading: 200,
buildingHeight: 270,
},
{
name: 'Burj Al Arab',
lat: 25.1412,
lon: 55.1853,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 100,
},
{
name: 'Palm Jumeirah',
lat: 25.1124,
lon: 55.139,
alt: 1200,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Dubai Frame',
lat: 25.235,
lon: 55.3003,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 75,
},
{
name: 'Museum of the Future',
lat: 25.2197,
lon: 55.2806,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 35,
},
],
},
dc: {
name: 'Washington DC',
groundElevation: 10,
viewBounds: {
southwest: { lat: 38.79, lng: -77.12 },
northeast: { lat: 38.995, lng: -76.91 },
},
pois: [
{
name: 'US Capitol',
lat: 38.8897,
lon: -77.0091,
alt: 550,
pitch: -25,
heading: 270,
buildingHeight: 45,
},
{
name: 'Washington Monument',
lat: 38.8895,
lon: -77.0353,
alt: 500,
pitch: -30,
heading: 0,
buildingHeight: 85,
},
{
name: 'Lincoln Memorial',
lat: 38.8893,
lon: -77.0502,
alt: 400,
pitch: -25,
heading: 90,
buildingHeight: 20,
},
{
name: 'Pentagon',
lat: 38.8711,
lon: -77.0559,
alt: 800,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Jefferson Memorial',
lat: 38.8814,
lon: -77.0365,
alt: 400,
pitch: -30,
heading: 0,
buildingHeight: 25,
},
],
},
tallinn: {
name: 'Tallinn',
groundElevation: 15,
viewBounds: {
southwest: { lat: 59.36, lng: 24.6 },
northeast: { lat: 59.52, lng: 24.92 },
},
pois: [
{
name: 'Viru Square',
lat: 59.4366,
lon: 24.7527,
alt: 450,
pitch: -28,
heading: 60,
buildingHeight: 25,
},
{
name: 'Old Town / Raekoja plats',
lat: 59.4372,
lon: 24.7452,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 20,
},
{
name: 'Teatri väljak',
lat: 59.4344,
lon: 24.7514,
alt: 400,
pitch: -25,
heading: 220,
buildingHeight: 25,
},
{
name: 'Port of Tallinn',
lat: 59.4445,
lon: 24.7675,
alt: 700,
pitch: -30,
heading: 90,
buildingHeight: 20,
},
{
name: 'Ülemiste',
lat: 59.421,
lon: 24.792,
alt: 600,
pitch: -28,
heading: 45,
buildingHeight: 30,
},
],
},
};
+4
View File
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) { if (!env.hasKey) {
const actions = terminateOwned('key-absent'); const actions = terminateOwned('key-absent');
status = 'missing-key'; status = 'missing-key';
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
error = 'AISSTREAM_API_KEY is not set'; error = 'AISSTREAM_API_KEY is not set';
reconnectAttempt = 0; reconnectAttempt = 0;
keyFingerprint = null; keyFingerprint = null;
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
if (env.hasTransport === false) { if (env.hasTransport === false) {
const actions = terminateOwned('no-transport'); const actions = terminateOwned('no-transport');
status = 'unsupported'; status = 'unsupported';
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
error = 'Node WebSocket transport is unavailable'; error = 'Node WebSocket transport is unavailable';
return actions; return actions;
} }
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
if (!silenceWatchArmed) return []; if (!silenceWatchArmed) return [];
const silentFor = monoNow - silenceSinceMono; const silentFor = monoNow - silenceSinceMono;
if (silentFor >= recycleAfterMs) { 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`; error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
const actions = terminateOwned('silent'); const actions = terminateOwned('silent');
scheduleRetry('transport'); scheduleRetry('transport');
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
function onClose(eventGeneration) { function onClose(eventGeneration) {
if (!ownsGeneration(eventGeneration)) return []; if (!ownsGeneration(eventGeneration)) return [];
release(); release();
// i18n-ignore: server detail; the chip shows a translated reason (vessels/queries.js)
if (!error) error = 'AISStream websocket closed'; if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport'); scheduleRetry('transport');
return []; return [];
+154 -431
View File
@@ -27,25 +27,17 @@ import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import { composeLabel, resolveTier } from './detectionDraw.js'; import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => { test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
assert.equal( assert.equal(countFadingRenderEntries([
countFadingRenderEntries([ { selected: true },
{ selected: true }, { selected: false },
{ selected: false }, { selected: true },
{ selected: true }, { selected: false },
{ selected: false }, ]), 2);
]), assert.equal(countFadingRenderEntries([{ selected: true }, { selected: true }]), 0);
2,
);
assert.equal(
countFadingRenderEntries([{ selected: true }, { selected: true }]),
0,
);
}); });
class MockEvent { class MockEvent {
constructor() { constructor() { this.listeners = new Set(); }
this.listeners = new Set();
}
addEventListener(listener) { addEventListener(listener) {
this.listeners.add(listener); this.listeners.add(listener);
@@ -77,127 +69,62 @@ function mockContext(target, trace) {
return { return {
calls, calls,
font: '', font: '',
get filter() { get filter() { return filter; },
return filter; set filter(value) { filter = value; record('filter', value); },
},
set filter(value) {
filter = value;
record('filter', value);
},
fillStyle: '', fillStyle: '',
get strokeStyle() { get strokeStyle() { return strokeStyle; },
return strokeStyle; set strokeStyle(value) { strokeStyle = value; record('strokeStyle', value); },
},
set strokeStyle(value) {
strokeStyle = value;
record('strokeStyle', value);
},
globalAlpha: 1, globalAlpha: 1,
get globalCompositeOperation() { get globalCompositeOperation() { return globalCompositeOperation; },
return globalCompositeOperation; set globalCompositeOperation(value) { globalCompositeOperation = value; record('globalCompositeOperation', value); },
},
set globalCompositeOperation(value) {
globalCompositeOperation = value;
record('globalCompositeOperation', value);
},
lineWidth: 1, lineWidth: 1,
measureText(text) { measureText(text) { return { width: String(text).length * 6 }; },
return { width: String(text).length * 6 }; setTransform(...args) { record('setTransform', ...args); },
}, clearRect(...args) { record('clearRect', ...args); },
setTransform(...args) { save() { record('save'); },
record('setTransform', ...args); restore() { record('restore'); },
}, translate(...args) { record('translate', ...args); },
clearRect(...args) { scale(...args) { record('scale', ...args); },
record('clearRect', ...args); beginPath() { record('beginPath'); },
}, rect(...args) { record('rect', ...args); },
save() { clip(...args) { record('clip', ...args); },
record('save'); moveTo(...args) { record('moveTo', ...args); },
}, lineTo(...args) { record('lineTo', ...args); },
restore() { arc(...args) { record('arc', ...args); },
record('restore'); arcTo(...args) { record('arcTo', ...args); },
}, closePath() { record('closePath'); },
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 // 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 // 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 // set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour. // reason: the backdrop feather is an alpha, not a colour.
fill(path) { fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
record('fill', path, this.fillStyle, this.globalAlpha); stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
}, fillRect(...args) { record('fillRect', ...args); },
stroke(path) { fillText(...args) { record('fillText', ...args); },
record('stroke', path, this.strokeStyle, this.lineWidth); drawImage(...args) { record('drawImage', ...args); },
},
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 // `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 // telemetry gated behind ?detectDebug=1, so any test that uses the banner as its
// "did this frame repaint" probe must opt in explicitly. // "did this frame repaint" probe must opt in explicitly.
function installEnvironment({ function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } = {}) {
width = 800,
height = 600,
dpr = 2,
search = '',
} = {}) {
const byId = new Map(); const byId = new Map();
const paintTrace = []; const paintTrace = [];
const ctx = mockContext('shared', paintTrace); const ctx = mockContext('shared', paintTrace);
const detectionCtx = mockContext('detection', paintTrace); const detectionCtx = mockContext('detection', paintTrace);
const originalPerformance = Object.getOwnPropertyDescriptor( const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, 'performance');
globalThis,
'performance',
);
const originalDateNow = Date.now; const originalDateNow = Date.now;
const originalPath2D = globalThis.Path2D; const originalPath2D = globalThis.Path2D;
let currentTime = 1_000; let currentTime = 1_000;
let performanceStep = 0; let performanceStep = 0;
Object.defineProperty(globalThis, 'performance', { Object.defineProperty(globalThis, 'performance', {
configurable: true, configurable: true,
value: { value: { now: () => {
now: () => { const value = currentTime;
const value = currentTime; currentTime += performanceStep;
currentTime += performanceStep; return value;
return value; } },
},
},
}); });
Date.now = () => currentTime; Date.now = () => currentTime;
globalThis.Path2D = MockPath2D; globalThis.Path2D = MockPath2D;
@@ -240,9 +167,7 @@ function installEnvironment({
register(this); register(this);
} }
getBoundingClientRect() { getBoundingClientRect() { return this._rect; }
return this._rect;
}
getContext() { getContext() {
if (this.tagName !== 'CANVAS') return null; if (this.tagName !== 'CANVAS') return null;
return this.id === 'world-overlay-detection-surface' ? detectionCtx : ctx; return this.id === 'world-overlay-detection-surface' ? detectionCtx : ctx;
@@ -255,17 +180,13 @@ function installEnvironment({
remove() { remove() {
if (this.parentElement) { if (this.parentElement) {
this.parentElement.children = this.parentElement.children.filter( this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
(child) => child !== this,
);
} }
unregister(this); unregister(this);
this.parentElement = null; this.parentElement = null;
} }
get nextSibling() { get nextSibling() { return null; }
return null;
}
} }
function register(element) { function register(element) {
@@ -279,26 +200,14 @@ function installEnvironment({
} }
const body = new MockElement('body'); const body = new MockElement('body');
body.classList = { body.classList = { contains() { return false; } };
contains() {
return false;
},
};
const document = { const document = {
body, body,
createElement(tagName) { createElement(tagName) { return new MockElement(tagName); },
return new MockElement(tagName); getElementById(id) { return byId.get(id) || null; },
}, querySelector(selector) { return byId.get(selector.slice(1)) || null; },
getElementById(id) {
return byId.get(id) || null;
},
querySelector(selector) {
return byId.get(selector.slice(1)) || null;
},
querySelectorAll(selector) { querySelectorAll(selector) {
const element = selector.startsWith('#') const element = selector.startsWith('#') ? byId.get(selector.slice(1)) : null;
? byId.get(selector.slice(1))
: null;
return element ? [element] : []; return element ? [element] : [];
}, },
}; };
@@ -307,18 +216,10 @@ function installEnvironment({
location: { search }, location: { search },
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
getComputedStyle() { getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
return { display: 'block', visibility: 'visible', opacity: '1' };
},
};
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
};
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
}; };
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.MutationObserver = class { observe() {} disconnect() {} };
globalThis.document = document; globalThis.document = document;
globalThis.window = window; globalThis.window = window;
@@ -340,9 +241,7 @@ function installEnvironment({
positionWC: new Cesium.Cartesian3(0, 0, 10_000_000), positionWC: new Cesium.Cartesian3(0, 0, 10_000_000),
positionCartographic: { height: 1_000_000 }, positionCartographic: { height: 1_000_000 },
viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
frustum: { frustum: { projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) },
projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
},
moveEnd: new MockEvent(), moveEnd: new MockEvent(),
}, },
scene: { postRender, requestRender() {} }, scene: { postRender, requestRender() {} },
@@ -355,12 +254,8 @@ function installEnvironment({
ctx, ctx,
detectionCtx, detectionCtx,
paintTrace, paintTrace,
advance(ms) { advance(ms) { currentTime += ms; },
currentTime += ms; setPerformanceStep(ms) { performanceStep = ms; },
},
setPerformanceStep(ms) {
performanceStep = ms;
},
cleanup() { cleanup() {
destroyDetection(); destroyDetection();
destroyWorldOverlay(); destroyWorldOverlay();
@@ -371,8 +266,7 @@ function installEnvironment({
delete globalThis.window; delete globalThis.window;
delete globalThis.ResizeObserver; delete globalThis.ResizeObserver;
delete globalThis.MutationObserver; delete globalThis.MutationObserver;
if (originalPerformance) if (originalPerformance) Object.defineProperty(globalThis, 'performance', originalPerformance);
Object.defineProperty(globalThis, 'performance', originalPerformance);
else delete globalThis.performance; else delete globalThis.performance;
}, },
}; };
@@ -401,16 +295,14 @@ function militaryLayer() {
return { return {
id: 'military', id: 'military',
getDetectableObjects() { getDetectableObjects() {
return [ return [{
{ position: new Cesium.Cartesian3(0, 0, 6_356_752),
position: new Cesium.Cartesian3(0, 0, 6_356_752), sourceId: 'military-pin',
sourceId: 'military-pin', id: 'MIL-PIN',
id: 'MIL-PIN', metric: 'FL450',
metric: 'FL450', type: 'AIR',
type: 'AIR', tier: 'military',
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.postRender.listeners.size, 1);
assert.equal(env.document.getElementById('detection-overlay'), null); assert.equal(env.document.getElementById('detection-overlay'), null);
assert.ok(env.document.getElementById('world-overlay-canvas')); assert.ok(env.document.getElementById('world-overlay-canvas'));
const surface = env.document.getElementById( const surface = env.document.getElementById('world-overlay-detection-surface');
'world-overlay-detection-surface',
);
const root = env.document.getElementById('world-overlay-root'); const root = env.document.getElementById('world-overlay-root');
const mainCanvas = env.document.getElementById('world-overlay-canvas'); const mainCanvas = env.document.getElementById('world-overlay-canvas');
assert.ok(surface); 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.equal(first.profile, 'DENSE');
assert.ok(Array.isArray(first.calloutRects)); assert.ok(Array.isArray(first.calloutRects));
if (first.calloutRects.length) { if (first.calloutRects.length) {
const x = first.calloutRects[0].x; const x=first.calloutRects[0].x;
first.calloutRects[0].x = NaN; first.calloutRects[0].x=NaN;
assert.equal( assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
getDetectionDiagnostics().calloutRects[0].x,
x,
'QA owns a copy, never a live plate',
);
} }
assert.equal(first.observationCount, 2); assert.equal(first.observationCount, 2);
assert.equal(first.didSolve, true); assert.equal(first.didSolve, true);
assert.ok(first.solveRevision > 0); assert.ok(first.solveRevision > 0);
assert.ok(first.collectiveLabelBudget > 0); assert.ok(first.collectiveLabelBudget > 0);
assert.ok( assert.ok(first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0);
first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0, assert.ok(env.detectionCtx.calls.some(([name, text]) => name === 'fillText'
); && String(text).startsWith('DENSE VIS:')));
assert.ok( assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D));
env.detectionCtx.calls.some( assert.ok(env.detectionCtx.calls.some(([name]) => name === 'fillRect'), 'scanlines and banner still paint');
([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.globalCompositeOperation, 'source-over');
assert.equal(env.detectionCtx.filter, 'none'); assert.equal(env.detectionCtx.filter, 'none');
assert.equal(surface.style.display, 'block'); 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 // 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 // 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. // into it. Brackets, banner and scanlines stay on the sensor surface.
assert.ok( assert.ok(env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
env.ctx.calls.some( assert.ok(!env.detectionCtx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
([name, text]) => name === 'fillText' && text === 'TEST0',
),
);
assert.ok(
!env.detectionCtx.calls.some(
([name, text]) => name === 'fillText' && text === 'TEST0',
),
);
env.advance(1); env.advance(1);
env.postRender.raise(); env.postRender.raise();
assert.ok(getDetectionDiagnostics().solveRevision > first.solveRevision); 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(getMode(), 'SPARSE');
assert.equal(getDetectionDiagnostics().profile, 'SPARSE'); assert.equal(getDetectionDiagnostics().profile, 'SPARSE');
assert.equal(getDetectionDiagnostics().allocationStrategy, 'WEIGHTED'); assert.equal(getDetectionDiagnostics().allocationStrategy, 'WEIGHTED');
assert.ok( assert.ok(env.detectionCtx.calls.some(([name]) => name === 'arc'), 'sparse focus ring remains available');
env.detectionCtx.calls.some(([name]) => name === 'arc'),
'sparse focus ring remains available',
);
setMode('BALANCED'); setMode('BALANCED');
env.postRender.raise(); env.postRender.raise();
@@ -567,12 +429,9 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
env.postRender.raise(); env.postRender.raise();
assert.equal(getDetectionDiagnostics().profile, 'DENSE'); assert.equal(getDetectionDiagnostics().profile, 'DENSE');
const bannerCount = () => const bannerCount = () => env.detectionCtx.calls.filter(([name, text]) => (
env.detectionCtx.calls.filter( name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
([name, text]) => )).length;
name === 'fillText' &&
/^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)),
).length;
const beforeSuspend = bannerCount(); const beforeSuspend = bannerCount();
suspendDetection('intercity'); suspendDetection('intercity');
assert.equal(isDetectionSuspended(), true); 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 // These are the exact engine calls made by military style presets after
// their production UI gate chooses CRT/NVG/FLIR defaults. // their production UI gate chooses CRT/NVG/FLIR defaults.
const expectedThemes = { const expectedThemes = {
retro: retro: 'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))',
'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))',
surveillance: thermal: 'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))',
'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']) { for (const style of ['retro', 'surveillance', 'thermal']) {
setMode('OFF'); setMode('OFF');
@@ -620,28 +476,17 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
assert.equal(getMode(), 'OFF'); assert.equal(getMode(), 'OFF');
assert.equal(surface.style.display, 'none'); assert.equal(surface.style.display, 'none');
assert.equal(env.postRender.listeners.size, 1); assert.equal(env.postRender.listeners.size, 1);
assert.ok( assert.ok(modes.includes('SPARSE') && modes.includes('BALANCED') && modes.includes('DENSE'));
modes.includes('SPARSE') &&
modes.includes('BALANCED') &&
modes.includes('DENSE'),
);
destroyDetection(); destroyDetection();
assert.equal( assert.equal(env.postRender.listeners.size, 1, 'detection never owns the listener');
env.postRender.listeners.size,
1,
'detection never owns the listener',
);
setMode('DENSE'); setMode('DENSE');
env.postRender.raise(); env.postRender.raise();
assert.equal(env.document.getElementById('detection-overlay'), null); assert.equal(env.document.getElementById('detection-overlay'), null);
destroyWorldOverlay(); destroyWorldOverlay();
assert.equal(env.postRender.listeners.size, 0); assert.equal(env.postRender.listeners.size, 0);
assert.equal(env.document.getElementById('world-overlay-canvas'), null); assert.equal(env.document.getElementById('world-overlay-canvas'), null);
assert.equal( assert.equal(env.document.getElementById('world-overlay-detection-surface'), null);
env.document.getElementById('world-overlay-detection-surface'),
null,
);
} finally { } finally {
env.cleanup(); 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 // clear the replay buffer, or the final callsigns stay stranded on the
// shared canvas after the last data layer is switched off. // shared canvas after the last data layer is switched off.
const env = installEnvironment(); const env = installEnvironment();
let objects = [ let objects = [{
{ position: new Cesium.Cartesian3(0, 0, 6_356_752),
position: new Cesium.Cartesian3(0, 0, 6_356_752), sourceId: 'lonely-1',
sourceId: 'lonely-1', id: 'LASTONE',
id: 'LASTONE', metric: 'FL120',
metric: 'FL120', type: 'AIR',
type: 'AIR', }];
},
];
try { try {
initWorldOverlay(env.viewer); initWorldOverlay(env.viewer);
initDetection( initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {});
env.viewer,
[{ id: 'flights', getDetectableObjects: () => objects }],
() => {},
);
setMode('DENSE'); setMode('DENSE');
env.advance(250); env.advance(250);
env.postRender.raise(); env.postRender.raise();
@@ -678,9 +517,7 @@ test('callouts stop painting the moment the last detectable object goes away', (
env.advance(250); env.advance(250);
env.postRender.raise(); env.postRender.raise();
assert.ok( assert.ok(
env.ctx.calls.some( env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
([name, text]) => name === 'fillText' && text === 'LASTONE',
),
'the callout paints on the shared canvas while the contact exists', '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.advance(250);
env.postRender.raise(); env.postRender.raise();
assert.ok( assert.ok(
!env.ctx.calls.some( !env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
([name, text]) => name === 'fillText' && text === 'LASTONE',
),
'an empty field must drop the replay buffer, not strand the last callsign', 'an empty field must drop the replay buffer, not strand the last callsign',
); );
} finally { } finally {
@@ -760,24 +595,12 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig
theme.calloutPlateSpace, theme.calloutPlateSpace,
'the fixture is only meaningful while the two tiers differ', 'the fixture is only meaningful while the two tiers differ',
); );
const painted = (text) => const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
env.ctx.calls.some( assert.ok(painted('AIRONE') && painted('SATONE'), 'both tiers reached the frame under test');
([name, value]) => name === 'fillText' && value === text,
);
assert.ok(
painted('AIRONE') && painted('SATONE'),
'both tiers reached the frame under test',
);
const fills = sharedFillStyles(env); const fills = sharedFillStyles(env);
assert.ok( assert.ok(fills.includes(theme.calloutPlate), 'the air contact keeps the light plate');
fills.includes(theme.calloutPlate), assert.ok(fills.includes(theme.calloutPlateSpace), 'the space contact takes the heavier plate');
'the air contact keeps the light plate',
);
assert.ok(
fills.includes(theme.calloutPlateSpace),
'the space contact takes the heavier plate',
);
} finally { } finally {
env.cleanup(); env.cleanup();
} }
@@ -794,16 +617,11 @@ test('surveillance military tier reaches the dedicated render target as shipped
env.postRender.raise(); env.postRender.raise();
assert.ok( assert.ok(
env.detectionCtx.calls.some( env.detectionCtx.calls.some(([name, value]) => name === 'strokeStyle' && value === '#ff5a47'),
([name, value]) => name === 'strokeStyle' && value === '#ff5a47',
),
'the production projection/tier/batched-bracket paint path emits surveillance military red', 'the production projection/tier/batched-bracket paint path emits surveillance military red',
); );
assert.ok( assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke'
env.detectionCtx.calls.some( && path instanceof MockPath2D));
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
),
);
} finally { } finally {
env.cleanup(); env.cleanup();
} }
@@ -815,71 +633,44 @@ test('pathological detection paint holds alternate frames without freezing share
try { try {
initWorldOverlay(env.viewer); initWorldOverlay(env.viewer);
initDetection(env.viewer, [detectableLayer()], () => {}); initDetection(env.viewer, [detectableLayer()], () => {});
setOverlayEntries('valve-card', [ setOverlayEntries('valve-card', [{
{ id: 'ambient-card',
id: 'ambient-card', position: new Cesium.Cartesian3(0, 0, 0),
position: new Cesium.Cartesian3(0, 0, 0), variant: 'label',
variant: 'label', title: 'AMBIENT-CONTINUES',
title: 'AMBIENT-CONTINUES', selected: true,
selected: true, protected: true,
protected: true, horizonCull: false,
horizonCull: false, edgeFade: 'none',
edgeFade: 'none', }]);
},
]);
setMode('DENSE'); setMode('DENSE');
env.advance(250); env.advance(250);
env.setPerformanceStep(30); env.setPerformanceStep(30);
env.postRender.raise(); env.postRender.raise();
env.postRender.raise(); env.postRender.raise();
const detectionClears = env.detectionCtx.calls.filter( const detectionClears = env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length;
([name]) => name === 'clearRect', const detectionBanners = env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
).length; && String(text).startsWith('DENSE VIS:')).length;
const detectionBanners = env.detectionCtx.calls.filter( const sharedClears = env.ctx.calls.filter(([name]) => name === 'clearRect').length;
([name, text]) => const sharedCards = env.ctx.calls.filter(([name, text]) => name === 'fillText'
name === 'fillText' && String(text).startsWith('DENSE VIS:'), && text === 'AMBIENT-CONTINUES').length;
).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(); env.postRender.raise();
assert.equal( assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, detectionClears,
env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, 'the host preserves detection pixels on the held odd frame');
detectionClears, assert.equal(env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
'the host preserves detection pixels on the held odd frame', && String(text).startsWith('DENSE VIS:')).length, detectionBanners);
); assert.equal(env.ctx.calls.filter(([name]) => name === 'clearRect').length, sharedClears + 1,
assert.equal( 'the shared surface still clears');
env.detectionCtx.calls.filter( assert.equal(env.ctx.calls.filter(([name, text]) => name === 'fillText'
([name, text]) => && text === 'AMBIENT-CONTINUES').length, sharedCards + 1,
name === 'fillText' && String(text).startsWith('DENSE VIS:'), 'the shared lane still repaints');
).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); assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
env.postRender.raise(); env.postRender.raise();
assert.equal( assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, detectionClears + 1, 'the next even frame repaints detection');
detectionClears + 1,
'the next even frame repaints detection',
);
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1); assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
} finally { } finally {
env.cleanup(); 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', () => { test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
const source = readFileSync( const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
new URL('./detection.js', import.meta.url), const uiSource = readShellSource()
'utf8', + '\n' + readFileSync(new URL('../ui/visualPresets.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, /createElement\(\s*['"]canvas['"]\s*\)/);
assert.doesNotMatch(source, /postRender\.addEventListener/); assert.doesNotMatch(source, /postRender\.addEventListener/);
assert.doesNotMatch(source, /['"]detection-overlay['"]/); 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, /CALLOUT_OCCLUSION_SELECTORS/);
assert.doesNotMatch(source, /_ctx\.(?:filter|globalCompositeOperation)\s*=/); assert.doesNotMatch(source, /_ctx\.(?:filter|globalCompositeOperation)\s*=/);
assert.match(source, /_hostSurface\.style\.mixBlendMode = _theme\.blend;/); assert.match(source, /_hostSurface\.style\.mixBlendMode = _theme\.blend;/);
assert.match( assert.match(source, /_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/);
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 LABEL_SOLVE_INTERVAL_MS = 125;/);
assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/); assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/);
assert.match( assert.match(source, /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/);
source, assert.match(source, /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/);
/const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/,
);
assert.match(
source,
/registerWorldOverlayPaintLane\('detection', _paintDetectionLane/,
);
assert.match(source, /target: 'detection'/); assert.match(source, /target: 'detection'/);
assert.match(source, /shouldPaint: _shouldPaintDetectionLane/); assert.match(source, /shouldPaint: _shouldPaintDetectionLane/);
// The three military styles used to carry three copies of these numbers. // 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, 3,
'CRT, NVG, and FLIR retain their Dense auto-enable defaults', 'CRT, NVG, and FLIR retain their Dense auto-enable defaults',
); );
assert.match( assert.match(uiSource, /preset\.detection && !this\._detectionUserOverridden/);
uiSource,
/preset\.detection && !this\._detectionUserOverridden/,
);
}); });
// ── Developer telemetry gate (2026-08-20 QA hunt) ─────────────────────────── // ── 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(''), false);
assert.equal(detectionDebugRequested('?detectDebug=0'), false); assert.equal(detectionDebugRequested('?detectDebug=0'), false);
assert.equal(detectionDebugRequested('?detectDebug'), false); assert.equal(detectionDebugRequested('?detectDebug'), false);
assert.equal( assert.equal(detectionDebugRequested('?detectdebug=1'), false, 'the flag is case-sensitive');
detectionDebugRequested('?detectdebug=1'),
false,
'the flag is case-sensitive',
);
assert.equal(detectionDebugRequested(undefined), false); assert.equal(detectionDebugRequested(undefined), false);
assert.equal(detectionDebugRequested(null), false); assert.equal(detectionDebugRequested(null), false);
}); });
test('the mode banner is absent by default and present behind the flag', () => { test('the mode banner is absent by default and present behind the flag', () => {
const bannerPaints = (env) => const bannerPaints = (env) => env.detectionCtx.calls.filter(([name, text]) => (
env.detectionCtx.calls.filter( name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
([name, text]) => )).length;
name === 'fillText' &&
/^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text)),
).length;
const painted = (search) => { const painted = (search) => {
const env = installEnvironment({ 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(); env.postRender.raise();
// The overlay is alive either way — brackets still stroke. // The overlay is alive either way — brackets still stroke.
assert.ok( assert.ok(
env.detectionCtx.calls.some( env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D),
([name, path]) => name === 'stroke' && path instanceof MockPath2D,
),
'detection still paints its contacts regardless of the debug gate', 'detection still paints its contacts regardless of the debug gate',
); );
return bannerPaints(env); return bannerPaints(env);
@@ -991,15 +756,8 @@ test('the mode banner is absent by default and present behind the flag', () => {
} }
}; };
assert.equal( assert.equal(painted(''), 0, 'users must never see the engine telemetry banner');
painted(''), assert.ok(painted('?detectDebug=1') > 0, 'the flag brings the telemetry back');
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', () => { 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', type: 'AIR',
})); }));
initWorldOverlay(env.viewer); initWorldOverlay(env.viewer);
initDetection( initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {});
env.viewer,
[{ id: layerId, getDetectableObjects: () => objects }],
() => {},
);
setDetectionTuning({ densityPct: 25 }); setDetectionTuning({ densityPct: 25 });
setMode('SPARSE'); setMode('SPARSE');
settleFrame(env); 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.visibleCount, 3);
assert.equal(diagnostics.densityPct, 25); assert.equal(diagnostics.densityPct, 25);
assert.equal(diagnostics.profile, 'SPARSE'); assert.equal(diagnostics.profile, 'SPARSE');
assert.ok( assert.ok(diagnostics.selectedCount <= diagnostics.collectiveLabelBudget,
diagnostics.selectedCount <= diagnostics.collectiveLabelBudget, 'Sparse keeps its documented budget instead of silently becoming Dense');
'Sparse keeps its documented budget instead of silently becoming Dense',
);
} finally { } finally {
destroyDetection(); destroyDetection();
destroyWorldOverlay(); destroyWorldOverlay();
@@ -1081,17 +833,11 @@ function backdropLayer() {
return [ return [
{ {
position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752), position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752),
sourceId: 'ground-1', sourceId: 'ground-1', id: 'GROUNDED', metric: 'FL100', type: 'AIR',
id: 'GROUNDED',
metric: 'FL100',
type: 'AIR',
}, },
{ {
position: new Cesium.Cartesian3(0.4, -0.2, 20_000_000), position: new Cesium.Cartesian3(0.4, -0.2, 20_000_000),
sourceId: 'sky-1', sourceId: 'sky-1', id: 'SKYBACK', metric: 'FL400', type: 'AIR',
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; env.ctx.calls.length = 0;
settleFrame(env); settleFrame(env);
const painted = (text) => const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
env.ctx.calls.some( assert.ok(painted('GROUNDED') && painted('SKYBACK'), 'both backdrops reached the frame under test');
([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 plate = DETECTION_THEME_MAP._default.calloutPlate;
const plateAlphas = env.ctx.calls 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 heaviest = Math.max(...plateAlphas);
const lightest = Math.min(...plateAlphas); const lightest = Math.min(...plateAlphas);
assert.ok(heaviest > 0, 'the grounded contact still gets a plate'); assert.ok(heaviest > 0, 'the grounded contact still gets a plate');
assert.ok( assert.ok(lightest > 0, 'the sky contact keeps a whisper rather than vanishing');
lightest > 0,
'the sky contact keeps a whisper rather than vanishing',
);
// Reverting the feature makes both plates equal, which fails here first. // Reverting the feature makes both plates equal, which fails here first.
assert.ok( assert.ok(
lightest < heaviest * 0.5, 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')), painted.some((text) => String(text).includes('34 KM/H')),
'and so does what the vehicle is doing', 'and so does what the vehicle is doing',
); );
assert.ok( assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
env.ctx.calls.some( assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
([name, , color, width]) => assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
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 // 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. // where `klass` is read; the host's track label does not paint it.
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H'); assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
+8 -4
View File
@@ -1,5 +1,9 @@
import { BUNDLE_SOURCE } from './bundle.js'; 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. */ /** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare( export function describeSceneShare(
{ project, assets }, { project, assets },
@@ -16,11 +20,11 @@ export function describeSceneShare(
status: status:
pack.source.adapter === BUNDLE_SOURCE pack.source.adapter === BUNDLE_SOURCE
? assets.has(pack.source.path) ? assets.has(pack.source.path)
? 'Included in bundle' ? msg('Included in bundle')
: 'Missing bundle file — reimport its bundle' : msg('Missing bundle file — reimport its bundle')
: sources.has(pack.source.adapter) : sources.has(pack.source.adapter)
? 'Source configured; file checked when loaded' ? msg('Source configured; file checked when loaded')
: 'Source unavailable', : msg('Source unavailable'),
})), })),
); );
return { return {
+2 -35
View File
@@ -18,6 +18,7 @@ import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js'; import { CITY_POIS } from './locations.js';
import { composeLocalityTag } from './hudLocality.js'; import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import { import {
ellipsoidalToMslDisplayM, ellipsoidalToMslDisplayM,
ensureGeoidReady, ensureGeoidReady,
@@ -35,10 +36,8 @@ import {
DEFAULT_HUD_LAYOUT, DEFAULT_HUD_LAYOUT,
normalizeHudLayout, normalizeHudLayout,
} from './hudLayouts.js'; } from './hudLayouts.js';
import { formatNumber, getLocale, msg, t } from './i18n/index.js'; import { formatNumber, msg, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizeLayerName } from './ui/layerPanel.js'; import { localizeLayerName } from './ui/layerPanel.js';
import { localizePlaceName } from './ui/placeNames.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */ /** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = { 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. */ /** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']); const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
+20 -1
View File
@@ -6,6 +6,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { composeLocalityTag, NEAR_POI_MAX_KM } from './hudLocality.js'; 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 ALCATRAZ = { poi: 'Alcatraz Island', city: 'San Francisco' };
const LINCOLN = { poi: 'Lincoln Memorial', city: 'Washington DC' }; 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 // 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', () => { test('a formatter builds the line from the same parts', () => {
const format = { const format = {
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`, 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', 'sector:21.3|-157.8',
); );
}); });
test('the HUD formatter translates the catalogued city, then upper-cases it', () => {
const tokyo = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
assert.equal(
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
'NEAR TOKYO TOWER (TOKYO) 2KM',
);
setLocale('ru');
try {
assert.match(
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
/\(ТОКИО\)/,
);
} finally {
setLocale('en');
}
});
-26
View File
@@ -1,26 +0,0 @@
// N-04: the HUD locality line passes the city upper-cased ("TOKYO") to the
// place-name lookup, which knows only "Tokyo", so the city never translates.
// The formatter below is the HUD's own (src/hud.js LOCALIZED_LOCALITY.near);
// hud.js itself does not load under node (mgrs CommonJS import).
// Place: src/hudLocalityCity.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from './i18n/index.js';
import { composeLocalityTag } from './hudLocality.js';
import { localizePlaceName } from './ui/placeNames.js';
const nearest = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
const hudFormat = {
near: ({ city }) => localizePlaceName(city),
sector: () => '',
};
test('ru: the HUD locality names Tokyo in Russian', () => {
setLocale('ru');
try {
const shown = composeLocalityTag(nearest, 35.66, 139.75, hudFormat);
assert.match(shown, /токио/i);
} finally {
setLocale('en');
}
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @module hudLocalityFormat
* @description The HUD summary's locality line in the active language — the
* formatter `hud.js` hands to `composeLocalityTag`. Kept apart from `hud.js`
* (which imports `mgrs`, not loadable under plain Node) so it is unit-testable.
*/
import { formatNumber, getLocale, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
export function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
useGrouping: false,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${number}${unitLabel(unit).toUpperCase()}`;
}
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
function hudCoordinate(value, positive, negative) {
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
}
/** The summary's locality line in the active language (see hudLocality.js). */
export const LOCALIZED_LOCALITY = Object.freeze({
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
// language: the place-name table knows `Tokyo`, not `TOKYO`.
near: ({ poi, city, distKm }) =>
t('NEAR {poi} ({city}) {distance}', {
poi: poi.toLocaleUpperCase(getLocale()),
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
distance: hudQuantity(Math.round(distKm), 'km'),
}),
sector: ({ latDeg, lonDeg }) =>
t('SECTOR {lat} {lon}', {
lat: hudCoordinate(latDeg, t('N'), t('S')),
lon: hudCoordinate(lonDeg, t('E'), t('W')),
}),
});
+75
View File
@@ -136,3 +136,78 @@ test('unmarkedScript finds literals that reach the page without t/tn/msg', () =>
[], [],
); );
}); });
test('unmarkedScript sees toasts behind optional calls and private helpers', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("showToast?.('North up');"), ['North up']);
assert.deepEqual(texts("this._showToast('Search failed');"), [
'Search failed',
]);
assert.deepEqual(texts("this.showToast('Link copied');"), ['Link copied']);
assert.deepEqual(texts("this.actions.showToast?.('Tilted view');"), [
'Tilted view',
]);
assert.deepEqual(texts("showToast?.(t('North up'));"), []);
assert.deepEqual(texts("this._showToast(t('Search failed'));"), []);
});
test('unmarkedScript sees status text assigned to layer errors and labels', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("layerState._error = 'live feed unavailable';"), [
'live feed unavailable',
]);
assert.deepEqual(
texts("this._error = err?.message || 'TeleGeography load failed';"),
['TeleGeography load failed'],
);
assert.deepEqual(texts("_error = 'Route request failed';"), [
'Route request failed',
]);
assert.deepEqual(texts("error ??= 'Feed closed';"), ['Feed closed']);
assert.deepEqual(texts('stats.error = `${name} fetch error`;'), [
'fetch error',
]);
assert.deepEqual(texts("loadingLabel = 'loading coverage...';"), [
'loading coverage...',
]);
assert.deepEqual(texts("state._loadingLabel = 'Loading cables';"), [
'Loading cables',
]);
assert.deepEqual(texts("stats.statusMessage = 'Add a key in Settings';"), [
'Add a key in Settings',
]);
assert.deepEqual(texts("this.status = 'Scene not saved';"), [
'Scene not saved',
]);
assert.deepEqual(
texts("layerState._error = msg('live feed unavailable');"),
[],
);
assert.deepEqual(texts("stats.error = t('GBFS fetch error');"), []);
// Machine values, comparisons and local scratch variables are not UI text.
assert.deepEqual(texts("status = 'missing-key';"), []);
assert.deepEqual(texts("if (error === 'Feed closed') stop();"), []);
assert.deepEqual(texts("const error = log() || 'Unknown shader error';"), []);
assert.deepEqual(texts('stats.error = new Error(`HTTP ${code}`);'), []);
});
test('unmarkedScript sees human-readable status, loadingLabel and statusMessage fields', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(
texts("return { status: 'Scene not saved — storage unavailable' };"),
['Scene not saved — storage unavailable'],
);
assert.deepEqual(
texts("return { status: ok ? 'Ready' : 'Source unavailable' };"),
['Ready', 'Source unavailable'],
);
assert.deepEqual(texts("return { loadingLabel: 'Loading feeds' };"), [
'Loading feeds',
]);
assert.deepEqual(texts("return { statusMessage: 'connect a receiver' };"), [
'connect a receiver',
]);
assert.deepEqual(texts("return { status: 'streaming', code: 'ok' };"), []);
assert.deepEqual(texts("return { status: 'auth-failed' };"), []);
assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []);
});
+6 -2
View File
@@ -243,6 +243,10 @@ test('tn() shows a caller-formatted count and still picks the form by number', (
assert.equal(label(12345), '12,345 map objects'); assert.equal(label(12345), '12,345 map objects');
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files'); assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
setLocale('ru'); setLocale('ru');
assert.equal(label(12345), '12 345 объектов на карте'); try {
assert.equal(label(1), '1 объект на карте'); assert.equal(label(12345), '12 345 объектов на карте');
assert.equal(label(1), '1 объект на карте');
} finally {
setLocale('en');
}
}); });
+8 -2
View File
@@ -2440,7 +2440,6 @@
"Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta", "Exit cockpit to fly to a 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 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 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", "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", "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", "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", "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 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 fly to a satellite": "Sal de la cabina para volar a un satélite",
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos" "Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos",
"{feed} {count} ({seconds}s old)": "{feed} {count} (hace {seconds} s)",
"entry {n}": "n.º {n}",
"Upstream response too large": "Respuesta del origen demasiado grande",
"Included in bundle": "Incluido en el paquete",
"Missing bundle file — reimport its bundle": "Falta el archivo en el paquete — vuelve a importar su paquete",
"Source configured; file checked when loaded": "Fuente configurada; el archivo se comprobará al cargarlo",
"Source unavailable": "Fuente no disponible"
} }
+8 -2
View File
@@ -2481,7 +2481,6 @@
"Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту", "Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту",
"Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу", "Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу",
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене", "Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
"Exit cockpit to fly to a {noun}": "Выйдите из кабины, чтобы перелететь: {noun}",
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио", "Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»", "Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место", "Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
@@ -2944,5 +2943,12 @@
"unsaved provider keys": "несохранённые ключи провайдеров", "unsaved provider keys": "несохранённые ключи провайдеров",
"Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту", "Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту",
"Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику", "Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику",
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре" "Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре",
"{feed} {count} ({seconds}s old)": "{feed} {count} ({seconds} с назад)",
"entry {n}": "№ {n}",
"Upstream response too large": "Слишком большой ответ источника",
"Included in bundle": "Входит в архив",
"Missing bundle file — reimport its bundle": "Файла нет в архиве — импортируйте этот архив снова",
"Source configured; file checked when loaded": "Источник настроен; файл будет проверен при загрузке",
"Source unavailable": "Источник недоступен"
} }
+3 -8
View File
@@ -389,14 +389,9 @@ export async function initKeySetup({
// A pasted key lives only in its field until Save: switching language (a // A pasted key lives only in its field until Save: switching language (a
// reload) asks first while any field holds one. // reload) asks first while any field holds one.
const unregisterUnsavedWork = registerUnsavedWork(() => const unregisterUnsavedWork = registerUnsavedWork(() =>
!disposed && [...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
Object.keys( String(input.value ?? '').trim(),
collectKeyUpdates( )
[...(root.querySelectorAll?.('input[data-env-var]') || [])].map(
(input) => ({ envVar: input.dataset?.envVar, value: input.value }),
),
),
).length
? msg('unsaved provider keys') ? msg('unsaved provider keys')
: null, : null,
); );
+19 -80
View File
@@ -6,7 +6,9 @@ import {
stripKeylessBasemapFromHash, stripKeylessBasemapFromHash,
bindKeySetupPlacement, bindKeySetupPlacement,
waitForChatGptOAuth, waitForChatGptOAuth,
initKeySetup,
} from './keySetup.js'; } from './keySetup.js';
import { describeUnsavedWork } from './i18n/index.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => { test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
const home = { 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', () => { test('the chip counts what is missing, and retires the count at zero', () => {
assert.equal( assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
keySetupChipLabel({ setCount: 0, total: 8 }), assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
'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({ setCount: 8, total: 8 }), 'POWERED UP');
assert.equal( assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label');
keySetupChipLabel(null),
'POWERED UP',
'no status is not a broken label',
);
}); });
test('collectKeyUpdates keeps only non-empty trimmed values', () => { 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 () => { test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
for (const result of [ 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.' } }, { ok: false, payload: { error: 'Sign-in is local only.' } },
]) { ]) {
let checks = 0; let checks = 0;
await assert.rejects( await assert.rejects(waitForChatGptOAuth({
waitForChatGptOAuth({ fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
fetchImpl: async () => { sleep: () => assert.fail('failed login must stop polling'),
checks += 1; }), { message: result.payload.error });
return { ok: result.ok, json: async () => result.payload };
},
sleep: () => assert.fail('failed login must stop polling'),
}),
{ message: result.payload.error },
);
assert.equal(checks, 1); 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 () => { test('OAuth login polling respects timeout and an aborted setup surface', async () => {
let clock = 0; let clock = 0;
let checks = 0; let checks = 0;
const poll = { const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
fetchImpl: async () => { assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
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); assert.equal(checks, 2);
const controller = new AbortController(); const controller = new AbortController();
controller.abort(); controller.abort();
assert.equal( assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
await waitForChatGptOAuth({ ...poll, signal: controller.signal }),
false,
);
assert.equal(checks, 2, 'aborted polling does not fetch'); 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 controller = new AbortController();
const available = await waitForChatGptOAuth({ const available = await waitForChatGptOAuth({
signal: controller.signal, signal: controller.signal,
fetchImpl: async () => ({ fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
ok: true,
json: async () => {
controller.abort();
return { available: true };
},
}),
sleep: () => assert.fail('aborted polling must not continue'), sleep: () => assert.fail('aborted polling must not continue'),
}); });
assert.equal(available, false); assert.equal(available, false);
}); });
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => { test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
const stripped = stripKeylessBasemapFromHash( const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
'lat=30.2&lon=-97.7&map=osm&style=normal',
);
assert.ok(stripped !== null); assert.ok(stripped !== null);
const params = new URLSearchParams(stripped); const params = new URLSearchParams(stripped);
assert.equal(params.get('map'), null, 'osm basemap removed'); 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'); assert.equal(params.get('style'), 'normal', 'style survives');
// A stack under any other name was chosen or shared on purpose. // A stack under any other name was chosen or shared on purpose.
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null); assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
assert.equal( assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do');
stripKeylessBasemapFromHash('lat=1&lon=2'),
null,
'no stack, nothing to do',
);
assert.equal(stripKeylessBasemapFromHash(''), null); assert.equal(stripKeylessBasemapFromHash(''), null);
assert.equal(stripKeylessBasemapFromHash(undefined), 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; let requestSignal;
const controller = new AbortController(); const controller = new AbortController();
const pending = initKeySetup({ const pending = initKeySetup({
documentRef: { documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
},
signal: controller.signal, signal: controller.signal,
fetchImpl: (_url, { signal }) => { fetchImpl: (_url, { signal }) => {
requestSignal = signal; requestSignal = signal;
return new Promise((resolve) => { return new Promise((resolve) => { resolveResponse = resolve; });
resolveResponse = resolve;
});
}, },
}); });
controller.abort(); 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: [] }) }); resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
assert.equal(await pending, null); assert.equal(await pending, null);
}); });
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => { 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 = () => ({ const node = () => ({
dataset: {}, dataset: {},
addEventListener() {}, addEventListener() {},
@@ -271,6 +209,7 @@ test('a pasted, unsaved key makes the language switch ask first until teardown',
assert.deepEqual(describeUnsavedWork(), []); assert.deepEqual(describeUnsavedWork(), []);
input.value = 'AIza-pasted'; input.value = 'AIza-pasted';
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']); assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
// The field still holds the key, so only teardown dropping the check clears it.
controller.abort(); controller.abort();
assert.deepEqual(describeUnsavedWork(), []); assert.deepEqual(describeUnsavedWork(), []);
}); });
@@ -1,11 +1,11 @@
// N-02: the Air quality layer (merged from main, AQHI) is fully English in // The Air quality layer (AQHI) reads in the UI language: layer name, Weather
// es/ru: layer name, Weather card (title, coverage, status, legend, risk) and // card (title, coverage, status, legend, risk) and its own failure text.
// its own failure text. Place: src/layers/airQuality/airQualityLocale.test.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { setLocale, t } from '../../i18n/index.js'; import { setLocale, t } from '../../i18n/index.js';
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js'; import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
import { LayerPanel } from '../../ui/layerPanel.js'; import { LayerPanel } from '../../ui/layerPanel.js';
import { translateFeedError } from '../../ui/statusText.js';
import { createAirQualityLayer } from './index.js'; import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) { function inLocale(locale, run) {
@@ -89,3 +89,20 @@ for (const locale of ['es', 'ru']) {
assert.ok(!row.includes('Air quality request failed'), row); assert.ok(!row.includes('Air quality request failed'), row);
}); });
} }
for (const locale of ['en', 'es', 'ru']) {
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
const layer = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
const card = inLocale(locale, () => layer.getRowControls().summary.status);
assert.equal(
card,
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
);
assert.equal(card, locale === 'en' ? 'ECCC HTTP 503' : 'ECCC: HTTP 503');
});
}
+7 -5
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * 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 { import {
AIR_QUALITY_BANDS, AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID, AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -216,8 +217,9 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
0, 0,
); );
// Readings and errors stay English data (analyst records, stats); the // Readings and errors stay English data (analyst records, stats); the
// card shows them in the UI language. A provider's own error text has // card shows them in the UI language, errors as the Layers panel row
// no catalog entry and shows as it came. // does. A provider's own error text has no catalog entry and shows as
// it came.
return { return {
readout: true, readout: true,
summary: { summary: {
@@ -226,7 +228,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
validTime: newest ? new Date(newest).toISOString() : undefined, validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`, detail: `${provider.network} · ${provider.scale}`,
status: _lastError status: _lastError
? translateKnown(_lastError) ? translateFeedError(_lastError)
: _count === 0 : _count === 0
? _lastUpdate === null ? _lastUpdate === null
? t('No readings available') ? t('No readings available')
@@ -249,7 +251,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
id: reading.id, id: reading.id,
ordinal: index + 1, ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`, lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`, text: `${reading.name} · ${t(reading.risk)}`,
accent: reading.color, accent: reading.color,
})), })),
}, },
+1
View File
@@ -132,6 +132,7 @@ export function createTracking({
icao24, icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the // Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live. // 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', status: described.stale ? 'stale (missed polls)' : 'live',
}, },
}; };
+3 -10
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; 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 { import {
GNSS_LEVEL_COLORS, GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT, GNSS_MIN_AIRCRAFT,
@@ -31,14 +32,6 @@ const LEVEL_LABELS = Object.freeze({
high: msg('Low-accuracy score > 10%'), 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. */ /** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) { export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas; const canvas = viewer?.scene?.canvas;
@@ -385,7 +378,7 @@ export function createGnssIntegrityLayer({
'Regional sample · 250 nm radius · cells from visited views over 30 min', 'Regional sample · 250 nm radius · cells from visited views over 30 min',
) + ) +
(_lastError && _retryAt (_lastError && _retryAt
? ` · ${shownSourceError(_lastError)} · ${ ? ` · ${translateFeedError(_lastError)} · ${
_retryAt <= 8640000000000000 _retryAt <= 8640000000000000
? t('Retrying automatically at {time} UTC', { ? t('Retrying automatically at {time} UTC', {
time: new Date(_retryAt).toISOString().slice(11, 19), time: new Date(_retryAt).toISOString().slice(11, 19),
+1 -1
View File
@@ -42,7 +42,7 @@ export function createAdsbGnssSource({
throw Object.assign( throw Object.assign(
new Error( new Error(
// The status code form is matched by FEED_ERROR_PATTERNS // 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 limited
? msg('GNSS feed rate limited (HTTP 429)') ? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`, : `GNSS feed unavailable (HTTP ${response.status})`,
+1 -1
View File
@@ -1,4 +1,4 @@
// N-03: the GNSS source's own failures are English data (source.js); the // The GNSS source's own failures are English data (source.js); the
// card's info line shows them in the UI language, the HTTP form included. // card's info line shows them in the UI language, the HTTP form included.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
+1
View File
@@ -192,6 +192,7 @@ export function createModel({ state: layerState, services, parts, source }) {
launch.id || launch.slug || launch.name || `launch-${date}`, launch.id || launch.slug || launch.name || `launch-${date}`,
), ),
name: launch.name || 'Unnamed launch', name: launch.name || 'Unnamed launch',
// i18n-ignore: Launch Library status names are provider data, shown as sent
status: launch.status?.name || 'Unknown', status: launch.status?.name || 'Unknown',
launchTime: Number.isFinite(date) launchTime: Number.isFinite(date)
? new Date(date).toISOString() ? new Date(date).toISOString()
@@ -0,0 +1,41 @@
// A Local ADS-B feed problem can be the whole error (nothing heard, WebUSB
// not producing); the row shows it in the UI language like any other piece.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
const receiver = {
webUsbSupported: true,
connected: false,
mode: 'adsb',
status: 'idle',
};
function rowFor(status) {
return LayerPanel.prototype._buildMetaText({
id: 'local-adsb',
enabled: true,
stats: { count: 0, lastUpdate: Date.now(), ...status },
});
}
for (const locale of ['es', 'ru']) {
for (const status of ['unreachable', 'invalid']) {
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
setLocale(locale);
try {
const st = localAdsbStatus({
receiver,
feedState: {
configured: true,
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
},
heard: 0,
});
const row = rowFor(st);
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
} finally {
setLocale('en');
}
});
}
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { LAYER_SOURCE } from './policy.js';
// Marks English that the Layers panel translates when it shows the status. // 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 // 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; const msg = (text) => text;
/** /**
@@ -75,6 +75,7 @@ function webUsbStatus(receiver, heard, feedState) {
const rate = Number.isFinite(receiver.messagesPerSecond) const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s` ? `${receiver.messagesPerSecond} msg/s`
: msg('listening'); : msg('listening');
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` }; return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
} }
@@ -117,6 +118,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
return { return {
source, source,
status: 'streaming', status: 'streaming',
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`, loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
}; };
} }
+3 -3
View File
@@ -1,6 +1,6 @@
// N-10: the Local ADS-B row's working texts ("3 heard · 5.8 msg/s", // 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 // "2 feeds live · 14 heard", "listening", "USB error", feed problems) read in
// catalog entries and stay English in es/ru. Place: src/layers/localAdsb/statusLocale.test.mjs // the UI language.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { setLocale } from '../../i18n/index.js'; import { setLocale } from '../../i18n/index.js';
+1
View File
@@ -125,6 +125,7 @@ export function createTracking({
icao24, icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the // Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live. // 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', status: described.stale ? 'stale (missed polls)' : 'live',
}, },
}; };
+1 -1
View File
@@ -72,7 +72,7 @@ export function createIngestion({
// At least one group loaded — proceed with a fresh rebuild, but keep the // At least one group loaded — proceed with a fresh rebuild, but keep the
// partial outage visible at the layer control instead of presenting the // partial outage visible at the layer control instead of presenting the
// reduced catalog as a fully healthy refresh. English data; the panel // 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 layerState._lastError = failed.length
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null; : null;
+1 -1
View File
@@ -477,7 +477,7 @@ export function createIngestion({ state, services, parts, source }) {
error?.message || error, error?.message || error,
); );
// English data; the panel shows it through FEED_ERROR_PATTERNS // 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.error = `${feed.name} feed unavailable`;
status.retryInSec = Number.isFinite(error?.retryInSec) status.retryInSec = Number.isFinite(error?.retryInSec)
? error.retryInSec ? error.retryInSec
+3 -2
View File
@@ -278,9 +278,10 @@ export function createQueries({ state, parts }) {
: {}), : {}),
degraded: count > 0 && health.degraded, degraded: count > 0 && health.degraded,
loading: health.loading && count === 0, loading: health.loading && count === 0,
// English data; the panel shows it through LOADING_LABEL_PATTERNS // English data; the panel shows it through STATUS_PIECE_PATTERNS
// (ui/layerPanel.js). // (ui/statusText.js).
loadingLabel: loadingLabel:
// i18n-ignore: translated on display
health.loading && count === 0 health.loading && count === 0
? `Loading ${active.map((feed) => feed.name).join(', ')}` ? `Loading ${active.map((feed) => feed.name).join(', ')}`
: undefined, : undefined,
@@ -1,71 +0,0 @@
// The speed unit of a vessel's detection callout follows the UI language.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import aisLiveVesselsLayer, {
_setVesselOverlayHostForTest,
_setVesselStateForTest,
_updateVesselCardsForTest,
} from '../../data/aisLiveVessels.js';
import { setLocale } from '../../i18n/index.js';
const record = (mmsi, name, screen, z = 0) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: { x: 0, y: 0, z, screen },
billboard: null,
});
function detectable(cameraZ) {
const carded = record('200', 'GOLDEN BEAR', { x: 40, y: 30 });
const plain = record('300', 'BAY PILOT', { x: 41, y: 31 });
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) =>
position.screen;
_setVesselOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
_setVesselStateForTest({
viewer: {
scene: {
canvas: { clientWidth: 1600, clientHeight: 900 },
camera: { positionWC: new Cesium.Cartesian3(0, 0, cameraZ) },
},
},
records: [carded, plain],
billboardCollection: { show: true, remove() {} },
});
try {
// Both share one 118 px grid cell, so only one of them gets a card.
_updateVesselCardsForTest([carded, plain]);
return Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
} finally {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
_setVesselStateForTest({ enabled: false });
_setVesselOverlayHostForTest();
}
}
test('ru: the callout speed unit is translated', () => {
setLocale('ru');
try {
const objects = detectable(4_000_000);
assert.equal(objects['GOLDEN BEAR'].metric, '10 уз');
} finally {
setLocale('en');
}
assert.equal(detectable(4_000_000)['GOLDEN BEAR'].metric, '10 kn');
});
-1
View File
@@ -69,7 +69,6 @@ export function createVesselState({ source, services }) {
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */ /** Sprites whose animated emphasis remains outside the 1.0 deadband. */
activeFocusCount: 0, activeFocusCount: 0,
activeLabelCount: 0, activeLabelCount: 0,
/** @type {Set<Object>} Records that have an ambient card this pass. */
selectedRecord: null, selectedRecord: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null, trail: null,
@@ -1,10 +1,11 @@
// N-09: a scalar overlay that cannot be drawn reports a marked English status // A scalar overlay that cannot be drawn reports a marked English status
// (rendering.js), which the Wind row shows through t() in the UI language. // (rendering.js), which the Wind row shows through t() in the UI language.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js'; import { setLocale, t } from '../../i18n/index.js';
import { WIND_FIELDS } from './fields.js'; import { WIND_FIELDS } from './fields.js';
import { fieldUnavailableText } from './rendering.js'; import { fieldUnavailableText } from './rendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
for (const locale of ['es', 'ru']) { for (const locale of ['es', 'ru']) {
test(`${locale}: every field's unavailable status has a translation`, () => { test(`${locale}: every field's unavailable status has a translation`, () => {
@@ -19,3 +20,10 @@ for (const locale of ['es', 'ru']) {
} }
}); });
} }
test('only a known overlay has an unavailable status', () => {
assert.equal(fieldUnavailableText('constructor'), null);
assert.equal(fieldUnavailableText('none'), null);
for (const [overlay, text] of Object.entries(SCALAR_UNAVAILABLE))
assert.equal(fieldUnavailableText(overlay), text);
});
+1
View File
@@ -205,6 +205,7 @@ export function createWindGpuRendering({ cesium: C, getViewer }) {
} catch (error) { } catch (error) {
clear(); clear();
diagnostics.error = diagnostics.error =
// i18n-ignore: GPU diagnostics only; the panel never shows this field
error instanceof Error ? error.message : 'Wind geometry unavailable'; error instanceof Error ? error.message : 'Wind geometry unavailable';
return false; return false;
} }
+1
View File
@@ -91,6 +91,7 @@ export function createWindLayer({
model: model === 'ifs' ? 'ECMWF' : 'GFS', model: model === 'ifs' ? 'ECMWF' : 'GFS',
validTime: validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable', formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
// i18n-ignore: English reading for AI tools; the card status uses t()
status: loading status: loading
? 'Loading forecast' ? 'Loading forecast'
: error || : error ||
+6 -4
View File
@@ -7,6 +7,7 @@ import { orderWeatherImagery } from '../weather/imageryOrder.js';
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js'; import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
import { msg } from '../../i18n/index.js'; import { msg } from '../../i18n/index.js';
import { createWindGpuRendering } from './gpuRendering.js'; import { createWindGpuRendering } from './gpuRendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
import { advectParticle, sampleWind } from './model.js'; import { advectParticle, sampleWind } from './model.js';
import { import {
createFieldRaster, createFieldRaster,
@@ -16,16 +17,17 @@ import {
// A scalar overlay that cannot be drawn, in English for diagnostics; the // 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 // 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({ const FIELD_UNAVAILABLE = Object.freeze({
speed: msg('Wind speed field unavailable'), speed: msg('Wind speed field unavailable'),
temperature: msg('Temperature field unavailable'), ...SCALAR_UNAVAILABLE,
pressure: msg('Mean sea level pressure field unavailable'),
}); });
/** English status for a scalar overlay with no drawable field. */ /** English status for a scalar overlay with no drawable field. */
export function fieldUnavailableText(overlay) { 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_HIGH_METERS = 1_200_000;
const WIND_FIELD_FADE_LOW_METERS = 200_000; const WIND_FIELD_FADE_LOW_METERS = 200_000;
+7 -4
View File
@@ -4,6 +4,12 @@ import { readWindBody } from '../../sources/windBody.js';
// wind layer and layer panel show them translated. // wind layer and layer panel show them translated.
const msg = (text) => text; 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. */ /** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({ export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
@@ -44,10 +50,7 @@ export function createWindSource({
const optionalUnavailable = const optionalUnavailable =
overlay !== 'none' && overlay !== 'none' &&
scalar === undefined && scalar === undefined &&
scalarError === scalarError === SCALAR_UNAVAILABLE[overlay];
(overlay === 'temperature'
? msg('Temperature field unavailable')
: msg('Mean sea level pressure field unavailable'));
const scalarValid = const scalarValid =
overlay === 'none' overlay === 'none'
? scalar === undefined && scalarError === undefined ? scalar === undefined && scalarError === undefined
+3 -509
View File
@@ -10,519 +10,13 @@ import {
import { unavailablePlaceSearch } from './search/placeSearch.js'; import { unavailablePlaceSearch } from './search/placeSearch.js';
import { cancelCameraArrival } from './data/cameraArrival.js'; import { cancelCameraArrival } from './data/cameraArrival.js';
import { guardCameraAboveGround } from './cameraGroundGuard.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. */ /** Newest landmark arrival; an older post-arrival ground guard yields to it. */
let arrivalGeneration = 0; 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 * 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 * must stay inside the app's 'global' view-scale band (>12,000 km — classifyViewScale
+2 -4
View File
@@ -12,8 +12,7 @@ import { t, msg } from './i18n/index.js';
export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken'; export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken';
// The Cockpit refusal per destination noun. Nouns are English identifiers // The Cockpit refusal per destination noun. Nouns are English identifiers
// from the callers ('camera', 'location', 'photo'…); a noun without its own // from the callers ('camera', 'location', 'photo'…);
// sentence falls back to the template with the noun as given.
// navigationPolicyFrame.test.mjs collects every noun the callers pass and // navigationPolicyFrame.test.mjs collects every noun the callers pass and
// fails when one has no sentence here. // fails when one has no sentence here.
const COCKPIT_REFUSALS = Object.freeze({ const COCKPIT_REFUSALS = Object.freeze({
@@ -44,8 +43,7 @@ export const COCKPIT_REFUSAL_NOUNS = Object.freeze(
); );
function cockpitRefusal(noun) { function cockpitRefusal(noun) {
const ready = COCKPIT_REFUSALS[noun]; return t(COCKPIT_REFUSALS[noun]);
return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun });
} }
/** /**
+47 -68
View File
@@ -1,7 +1,5 @@
// N-06: voice frame_overhead under Cockpit passed the noun 'frame', which had // The Cockpit refusal names its destination in the UI language. The scan
// no entry in COCKPIT_REFUSALS, so the toast fell back to the template with // below collects every noun the callers hand to the navigation policy, so a
// 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
// noun without its own sentence fails here instead of reaching the page. // noun without its own sentence fails here instead of reaching the page.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
@@ -59,17 +57,15 @@ function sourceFiles(dir) {
}); });
} }
const lineOf = (text, index) => text.slice(0, index).split('\n').length; // The destination noun: the first argument of a navigation call, or the
// second of runManagedVoiceNavigation(styleManager, noun, …) in gevActions.js.
// Calls whose first argument is the destination noun. // `runNavigation(fly)` layer seams take only the flight (group 1).
const NOUN_FIRST = const NOUN_ARGUMENT =
/\b(runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|runNavigation|navigation\??\.(?:run|begin))\s*\(\s*([^,)]*)/g; /\b(?:(runNavigation)\s*\(|(?:runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|navigation\??\.(?:run|begin))\s*\(|runManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,)\s*([^,)]*)/g;
// runManagedVoiceNavigation(styleManager, noun, action, …) in gevActions.js. // Literal nouns elsewhere: a parameter default (`noun = 'location'`) or both
const VOICE_NOUN = /\brunManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,\s*([^,)]*)/g; // branches of `const noun = … ? 'cyclone' : 'weather'`.
// Parameter defaults: `noun = 'location'`. const NOUN_LITERAL =
const NOUN_DEFAULT = /\bnoun\s*=\s*'([^']+)'/g; /\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\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;
function trackableKinds() { function trackableKinds() {
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8'); 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]); return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
} }
// Non-literal first arguments the test knows how to expand. Anything else is // Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
// a new dynamic noun: list its values here. // 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([ const DYNAMIC_NOUNS = new Map([
['family.kind', trackableKinds], ['family.kind', trackableKinds],
[ [
"detail?.kind || 'target'", "detail?.kind || 'target'",
() => [...Object.keys(WORLD_FOCUS_FRAMING), '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() { function collectNouns() {
const nouns = new Map(); const nouns = new Set();
const unknown = []; const unknown = [];
const add = (noun, where) => {
if (!nouns.has(noun)) nouns.set(noun, where);
};
for (const file of sourceFiles(SRC)) { for (const file of sourceFiles(SRC)) {
const text = readFileSync(file, 'utf8'); const text = readFileSync(file, 'utf8');
const name = relative(SRC, file); for (const [, seam, raw] of text.matchAll(NOUN_ARGUMENT)) {
const take = (raw, where, { closures = false } = {}) => {
const arg = raw.trim(); const arg = raw.trim();
const literal = arg.match(/^'([^']*)'$/); const literal = arg.match(/^'([^']*)'$/)?.[1];
if (literal) return add(literal[1], where); if (literal) nouns.add(literal);
// A wrapper's own parameter; its default is collected by NOUN_DEFAULT. else if (DYNAMIC_NOUNS.has(arg))
if (PASS_THROUGH.has(arg) || /^noun\s*=\s*'[^']+'$/.test(arg)) return; DYNAMIC_NOUNS.get(arg)().forEach((noun) => nouns.add(noun));
if (DYNAMIC_NOUNS.has(arg)) { else if (!seam && !/^noun\s*=/.test(arg))
for (const noun of DYNAMIC_NOUNS.get(arg)()) add(noun, where); unknown.push(`${relative(SRC, file)}: ${arg}`);
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)}`);
} }
for (const m of text.matchAll(NOUN_LITERAL))
m.slice(1).forEach((noun) => noun && nouns.add(noun));
} }
return { nouns, unknown }; return { nouns, unknown };
} }
@@ -134,25 +112,26 @@ test('every noun the callers pass has its own Cockpit refusal', () => {
const { nouns, unknown } = collectNouns(); const { nouns, unknown } = collectNouns();
assert.deepEqual(unknown, [], 'a new dynamic noun: add it to DYNAMIC_NOUNS'); 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). // 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}`); assert.ok(nouns.has(noun), `found ${noun}`);
for (const noun of ['scene', 'route', 'weather', 'cyclone', 'vessel', 'fire']) const missing = [...nouns].filter(
assert.ok(nouns.has(noun), `found ${noun}`); (noun) => !COCKPIT_REFUSAL_NOUNS.includes(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})`);
assert.deepEqual(missing, [], 'add these nouns to COCKPIT_REFUSALS'); assert.deepEqual(missing, [], 'add these nouns to COCKPIT_REFUSALS');
setLocale('es');
for (const noun of nouns.keys()) {
const shown = cockpitToast(noun);
assert.notEqual(
shown,
`Sal de la cabina para volar a ${noun}`,
`${noun}: the template with the English id`,
);
}
}); });
+1 -1
View File
@@ -119,7 +119,7 @@ export function createSceneSharing(director) {
{ {
scene: pack.scene, scene: pack.scene,
pack: pack.id, pack: pack.id,
status: pack.status, status: t(pack.status),
path: pack.path, path: pack.path,
attribution: pack.attribution.text, attribution: pack.attribution.text,
license: pack.attribution.license, license: pack.attribution.license,
+9 -8
View File
@@ -1,13 +1,18 @@
// Imported by the server: no i18n here. The text stays English data; layers
// show it translated (translateKnown).
const msg = (text) => text;
const tooLarge = () =>
Object.assign(new Error(msg('Upstream response too large')), {
code: 'RESPONSE_TOO_LARGE',
});
/** /**
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an * 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 * 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' }. * length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
*/ */
export async function readResponseTextCapped(response, maxBytes, signal) { 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')); const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) { if (Number.isFinite(declared) && declared > maxBytes) {
void response.body?.cancel().catch(() => {}); void response.body?.cancel().catch(() => {});
@@ -61,10 +66,6 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
* Throws { code:'RESPONSE_TOO_LARGE' }. * Throws { code:'RESPONSE_TOO_LARGE' }.
*/ */
export async function readResponseBytesCapped(response, maxBytes, signal) { 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')); const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) { if (Number.isFinite(declared) && declared > maxBytes) {
await response.body?.cancel().catch(() => {}); await response.body?.cancel().catch(() => {});
+1 -1
View File
@@ -10,7 +10,7 @@
* Records contain observation data only, with no scene objects or transport data. * 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 // 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. // so a new or reworded message is changed there as well.
export class LiveSourceError extends Error { export class LiveSourceError extends Error {
constructor( constructor(
-4
View File
@@ -714,10 +714,6 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
assert.ok(gpx.children[0].style.background); 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 () => { test('the map-object count in a row title keeps digit grouping', async () => {
const t = setup({ const t = setup({
load: async (f) => ({ load: async (f) => ({
-2
View File
@@ -117,8 +117,6 @@ const SOURCE_LABEL_KEYS = new Map(
msg('OSM routing'), msg('OSM routing'),
msg('Mapped camera locations'), msg('Mapped camera locations'),
msg('OpenStreetMap · community mapped'), msg('OpenStreetMap · community mapped'),
msg('Decoder feeds'),
msg('WebUSB + decoder feeds'),
].map((key) => [key, key]), ].map((key) => [key, key]),
); );
+1 -190
View File
@@ -14,12 +14,12 @@ import {
} from './layerNames.js'; } from './layerNames.js';
export { localizeLayerName } from './layerNames.js'; export { localizeLayerName } from './layerNames.js';
import { createWeatherPanel } from './weatherPanel.js'; import { createWeatherPanel } from './weatherPanel.js';
import { translateFeedError, translateLoadingLabel } from './statusText.js';
import { import {
formatNumber, formatNumber,
getLocale, getLocale,
msg, msg,
t, t,
tn,
translateKnown, translateKnown,
} from '../i18n/index.js'; } from '../i18n/index.js';
@@ -146,197 +146,8 @@ export function layerKeyRequirementTooltip(layer = {}) {
); );
} }
// Live-source failures arrive as English data (src/sources/live/contract.js,
// standalone.js, flights and military ingestion), some with a source name or a
// number in them; the row shows them in the UI language. Keep these in step
// with the producers. Anything else shows its catalog entry, or as it came.
const FEED_ERRORS = new Set([
msg('OpenSky OAuth client missing/invalid'),
msg('OpenSky OAuth rejected credentials'),
msg('OpenSky username/password rejected'),
msg('OpenSky auth missing'),
msg('OpenSky auth required'),
msg('OpenSky auth invalid'),
msg('OpenSky auth failed'),
]);
const FEED_ERROR_PATTERNS = [
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
// ahead of the generic "<source> HTTP <status>".
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^Panoramax answered HTTP (\d+)$/,
([, status]) => t('Panoramax answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
// (sources/vectorTiles.js) failures carry their own wording; the generic
// pattern below would take "Weather image" for a source name.
[
/^Weather (image )?HTTP (\d+)$/,
([, image, status]) =>
image
? t('Weather image HTTP {status}', { status })
: t('Weather HTTP {status}', { status }),
],
[
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
// Layer statuses with a number or a feed name in them: GNSS
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
// feeds (transit/ingestion.js).
[
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
],
[
/^(\d+) CelesTrak groups? unavailable$/,
([, groups]) =>
tn(
Number(groups),
'{count} CelesTrak group unavailable',
'{count} CelesTrak groups unavailable',
{ count: formatNumber(Number(groups)) },
),
],
[
/^(.+) feed unavailable$/,
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
],
[
/^(.+) rate limited \(anonymous\)$/,
([, source]) =>
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
],
[
/^(.+) rate limited$/,
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
],
[
/^(.+) HTTP (\d{3})$/,
([, source, status]) =>
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
],
[
/^(.+) network error$/,
([, source]) =>
t('{source} network error', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) aircraft rows$/,
([, source]) =>
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) response$/,
([, source]) =>
t('Malformed {source} response', { source: sourceLabel(source) }),
],
];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return null;
}
/**
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
* several statuses joined by " · " (Local ADS-B) translates piece by piece.
*/
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
const whole = feedErrorText(text);
if (whole !== null) return whole;
if (!text.includes(ROW_TEXT_SEPARATOR)) return text;
return text
.split(ROW_TEXT_SEPARATOR)
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
.join(ROW_TEXT_SEPARATOR);
}
const ROW_TEXT_SEPARATOR = ' · '; 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 // 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. // error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g; const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
+2 -3
View File
@@ -1,6 +1,5 @@
// N-03: layer status texts produced without msg()/catalog entries (GNSS, // The DATA LAYERS row shows the English status texts that GNSS, Transit,
// Transit, CelesTrak, AQHI) stay English in the es/ru DATA LAYERS row. // CelesTrak and AQHI produce in the UI language.
// Place: src/ui/layerStatusLocale.test.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
+3 -1
View File
@@ -10,7 +10,8 @@ import {
} from '../loadingFeedback.js'; } from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js'; import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { getLocale, t, msg } from '../i18n/index.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 // loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so // 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('Vector tile request timed out'),
msg('Camera coverage unavailable'), msg('Camera coverage unavailable'),
msg('Camera source temporarily unavailable'), msg('Camera source temporarily unavailable'),
msg('Overpass returned an incomplete camera response'),
].map((key) => [key.toUpperCase(), key]), ].map((key) => [key.toUpperCase(), key]),
); );
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/; const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
@@ -5,7 +5,13 @@
// catalog entry, not only the default one. // catalog entry, not only the default one.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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 = { const elements = {
'global-loading-status': { hidden: true, dataset: {} }, '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.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
assert.notEqual(localized.detail, 'retry pending'); assert.notEqual(localized.detail, 'retry pending');
}); });
// An incomplete Overpass answer (alpr/source.js) on the ALPR retry chip,
// which shows the layer's error upper-cased.
test(`${locale}: the ALPR retry chip translates an incomplete Overpass answer`, () => {
setLocale(locale);
const error = 'Overpass returned an incomplete camera response';
assert.notEqual(t(error), error, 'the catalog has the entry');
const now = Date.now();
const layer = {
id: 'alpr-cameras',
name: 'ALPR cameras',
enabled: true,
stats: { status: 'unavailable', error, retryAt: now + 12000 },
};
const summary = aggregateLayerLoading([layer]);
const state = reduceLoadingFeedback(
createLoadingFeedbackState(),
summary,
now,
);
const shown = localizeLoadingPresentation(
presentLoadingFeedback(state, summary, now),
);
assert.equal(shown.state, 'retry', 'the ALPR retry chip is up');
assert.equal(shown.label, t(error).toLocaleUpperCase(locale));
});
} }
+4 -8
View File
@@ -1,8 +1,7 @@
// Shell toasts, prompts and launcher labels that reach the page through // Shell toasts, prompts and launcher labels: pin that they are marked in the
// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant // code, present in every shipped catalog, and that no bare literal is left at
// are invisible to the unmarked-text checker, so pin them here: marked in the // the call sites (`window.prompt/confirm` and constants are invisible to the
// code, present in every shipped catalog, and no bare literal left at the // unmarked-text checker).
// call sites.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -27,7 +26,6 @@ const KEYS = [
'Exit cockpit to fly to a location', 'Exit cockpit to fly to a location',
'Exit cockpit to fly to a photo', 'Exit cockpit to fly to a photo',
'Exit cockpit to fly to a shared view', 'Exit cockpit to fly to a shared view',
'Exit cockpit to fly to a {noun}',
'Go to expanded Radio section', 'Go to expanded Radio section',
'Expand Radio section in Context', 'Expand Radio section in Context',
'Double-click the header to snap the panel back', '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, cockpitActive: true,
showToast, showToast,
}); });
runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast });
assert.deepEqual(log, [ assert.deepEqual(log, [
'Exit cockpit to fly to a camera', '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 location', 'Exit cockpit to fly to a location',
'Exit cockpit to fly to a contact',
]); ]);
}); });
+231
View File
@@ -0,0 +1,231 @@
/**
* Layer status texts in the UI language.
*
* Layers report errors, loading labels and status notes as English data
* (stats, analyst records, what the model receives); the Layers panel, the
* loading chip, layer cards and the voice card show them translated through
* here, by their shape.
*
* Pure: imports only i18n and layer names, no DOM.
* @module ui/statusText
*/
import { sourceLabel } from './layerNames.js';
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
const STATUS_SEPARATOR = ' · ';
// Live-source failures arrive as English data (src/sources/live/contract.js,
// standalone.js, flights and military ingestion), some with a source name or a
// number in them; the row shows them in the UI language. Keep these in step
// with the producers. Anything else shows its catalog entry, or as it came.
const FEED_ERRORS = new Set([
msg('OpenSky OAuth client missing/invalid'),
msg('OpenSky OAuth rejected credentials'),
msg('OpenSky username/password rejected'),
msg('OpenSky auth missing'),
msg('OpenSky auth required'),
msg('OpenSky auth invalid'),
msg('OpenSky auth failed'),
]);
const FEED_ERROR_PATTERNS = [
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
// ahead of the generic "<source> HTTP <status>".
[
/^KartaView answered HTTP (\d+)$/,
([, status]) => t('KartaView answered HTTP {status}', { status }),
],
[
/^Panoramax answered HTTP (\d+)$/,
([, status]) => t('Panoramax answered HTTP {status}', { status }),
],
[
/^KartaView refused the request \(code (-?\d+)\)$/,
([, code]) => t('KartaView refused the request (code {code})', { code }),
],
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
// (sources/vectorTiles.js) failures carry their own wording; the generic
// pattern below would take "Weather image" for a source name.
[
/^Weather (image )?HTTP (\d+)$/,
([, image, status]) =>
image
? t('Weather image HTTP {status}', { status })
: t('Weather HTTP {status}', { status }),
],
[
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
// Layer statuses with a number or a feed name in them: GNSS
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
// feeds (transit/ingestion.js).
[
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
],
[
/^(\d+) CelesTrak groups? unavailable$/,
([, groups]) =>
tn(
Number(groups),
'{count} CelesTrak group unavailable',
'{count} CelesTrak groups unavailable',
{ count: formatNumber(Number(groups)) },
),
],
[
/^(.+) feed unavailable$/,
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
],
[
/^(.+) rate limited \(anonymous\)$/,
([, source]) =>
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
],
[
/^(.+) rate limited$/,
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
],
[
/^(.+) HTTP (\d{3})$/,
([, source, status]) =>
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
],
[
/^(.+) network error$/,
([, source]) =>
t('{source} network error', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) aircraft rows$/,
([, source]) =>
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
],
[
/^Malformed (.+) response$/,
([, source]) =>
t('Malformed {source} response', { source: sourceLabel(source) }),
],
];
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
const match = pattern.exec(text);
if (match) return format(match);
}
return null;
}
/**
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
* several statuses joined by " · " (Local ADS-B) translates piece by piece,
* and so does a lone status piece ("feed 1090 unreachable").
*/
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
const whole = feedErrorText(text);
if (whole !== null) return whole;
return text
.split(STATUS_SEPARATOR)
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
.join(STATUS_SEPARATOR);
}
// A decoder feed with no band is named by the server's "entry N" (its place
// in LOCAL_RECEIVER_FEEDS, server/providers/local-receivers.js).
const FEED_ENTRY = /^entry (\d+)$/;
function shownFeedName(name) {
const entry = FEED_ENTRY.exec(name);
return entry ? t('entry {n}', { n: entry[1] }) : name;
}
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
const FEED_PROBLEM_FORMS = Object.freeze({
stale: (count, names) =>
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
unreachable: (count, names) =>
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
names,
}),
invalid: (count, names) =>
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
});
// Working texts with a number or a name in them arrive as English data, one
// piece of a " · " joined line each: transit (transit/queries.js) and Local
// ADS-B (localAdsb/status.js). The Layers panel and the voice card's analyst
// notes show them through here. Keep these in step with the producers.
const STATUS_PIECE_PATTERNS = [
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
[
/^(\d+) heard$/,
([, heard]) =>
tn(Number(heard), '{count} heard', '{count} heard', {
count: formatNumber(Number(heard)),
}),
],
[
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
([, usb, rate]) =>
usb
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
],
[
/^(\d+) feeds? live$/,
([, live]) =>
tn(Number(live), '{count} feed live', '{count} feeds live', {
count: formatNumber(Number(live)),
}),
],
[
/^feeds? (.+) (stale|unreachable|invalid)$/,
([, names, status]) => {
const list = names.split(', ');
return FEED_PROBLEM_FORMS[status](
list.length,
list.map(shownFeedName).join(', '),
);
},
],
// A quiet transit feed in the coverage line, "TTC 0 (95s old)"
// (transit/queries.js); the feed name and the numbers stay as they came.
[
/^(.+) (\d+) \((\d+)s old\)$/,
([, feed, count, seconds]) =>
t('{feed} {count} ({seconds}s old)', { feed, count, seconds }),
],
];
/** One piece of a status line in the UI language, or as it came. */
export function translateStatusPiece(piece) {
const known = translateKnown(piece);
if (known !== piece) return known;
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
const match = pattern.exec(piece);
if (match) return format(match);
}
return piece;
}
/** A status line of " · " joined pieces in the UI language, piece by piece. */
export function translateStatusLine(text) {
return text
.split(STATUS_SEPARATOR)
.map(translateStatusPiece)
.join(STATUS_SEPARATOR);
}
/** A loading label is English data, at times several joined by " · ". */
export function translateLoadingLabel(label) {
return translateStatusLine(label.trim());
}
+98
View File
@@ -0,0 +1,98 @@
// Layer status texts arrive as English data and show in the UI language by
// their shape; English shows exactly as it came.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t, translateKnown } from '../i18n/index.js';
import { sourceLabel } from './layerNames.js';
import {
translateFeedError,
translateLoadingLabel,
translateStatusLine,
} from './statusText.js';
import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js';
import { readResponseTextCapped } from '../sources/httpBody.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
const STATUS_LINES = [
'feed 1090 unreachable',
'feeds 1090, entry 2 invalid',
'3 heard · USB 5.8 msg/s · feed 1090 stale',
'2 feeds live · 14 heard',
'TTC 0 (95s old)',
'TTC 0 · GO Transit 4 (95s old)',
'Loading TTC, MiWay',
];
test('en: status lines and errors show as they came', () => {
setLocale('en');
for (const text of [
...STATUS_LINES,
'ECCC HTTP 503',
'GNSS feed unavailable (HTTP 503)',
'Upstream response too large',
]) {
assert.equal(translateFeedError(text), text);
assert.equal(translateStatusLine(text), text);
assert.equal(translateLoadingLabel(` ${text} `), text);
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: a lone feed problem translates like a joined one`, () => {
inLocale(locale, () => {
const lone = translateFeedError('feed 1090 unreachable');
assert.doesNotMatch(lone, /unreachable/, lone);
assert.equal(
translateFeedError('feed 1090 unreachable · 2 heard').split(' · ')[0],
lone,
);
});
});
test(`${locale}: a feed with no band is named in the UI language`, () => {
inLocale(locale, () => {
const shown = translateFeedError('feeds 1090, entry 2 invalid');
assert.doesNotMatch(shown, /\b(entry|invalid)\b/, shown);
assert.ok(shown.includes(t('entry {n}', { n: 2 })), shown);
assert.ok(shown.includes('1090'), shown);
});
});
test(`${locale}: a quiet transit feed keeps its name and numbers`, () => {
inLocale(locale, () => {
const shown = translateStatusLine('TTC 0 · GO Transit 4 (95s old)');
assert.doesNotMatch(shown, /\bold\b/, shown);
assert.ok(shown.startsWith('TTC 0 · GO Transit 4 '), shown);
assert.ok(shown.includes('95'), shown);
});
});
test(`${locale}: an oversized upstream response is translated`, async () => {
const response = new Response('x'.repeat(10), {
headers: { 'content-length': '10' },
});
const error = await readResponseTextCapped(response, 4).catch((e) => e);
assert.equal(error.message, 'Upstream response too large');
inLocale(locale, () => {
assert.notEqual(translateKnown(error.message), error.message);
assert.equal(translateFeedError(error.message), t(error.message));
});
});
test(`${locale}: Local ADS-B source names translate without their own entry`, () => {
inLocale(locale, () => {
for (const source of [FEED_SOURCE, COMBINED_SOURCE]) {
assert.notEqual(t(source), source, 'the catalog has the entry');
assert.equal(sourceLabel(source), t(source));
}
});
});
}
+2 -2
View File
@@ -79,8 +79,8 @@ html[lang='es'] .cctv-controls {
} }
/* A translated label longer than its button wraps inside the button instead /* 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, of running past the panel edge. English is excluded so its labels (AUTO HOP
this rule broke AUTO HOP OFF onto two lines). */ OFF) stay on one line. */
html:not([lang='en']) .cctv-controls .scene-btn { html:not([lang='en']) .cctv-controls .scene-btn {
white-space: normal; white-space: normal;
overflow-wrap: break-word; overflow-wrap: break-word;
+80
View File
@@ -0,0 +1,80 @@
// The analyst card's empty notes can be a layer's status line: Local ADS-B
// with every decoder feed stale, Transit with a quiet feed ("(95s old)"). The
// card shows them in the UI language; the model gets the English.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { attachVoiceResult } from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
function notesFor(layerKey, caveat) {
const shaped = attachVoiceResult(
'analyst_query',
{
ok: true,
count: 0,
complete: true,
scopeLabel: 'in view',
feedState: 'nominal',
unanswered: [],
coverage: { layersQueried: [{ layerKey, status: 'empty' }] },
display: { scope: 'in view', caveat },
items: [],
},
{},
);
return voiceCardView(
[
{
type: 'action-call',
callId: 'c1',
name: 'analyst_query',
arguments: {},
},
{
type: 'action-result',
callId: 'c1',
name: 'analyst_query',
result: shaped,
},
].reduce(reduceVoiceCard, initialVoiceCardState()),
).result.notes;
}
// Caveat = `${layerKey}: ${getEmptyNote(layerKey)}` (analystEngine.js:1026,
// gevActions.js getEmptyNote → stats.coverage || stats.statusMessage).
const cases = [
['local-adsb', 'local-adsb: feeds 1090, 978 stale · 0 heard'],
['local-adsb', 'local-adsb: feed 1090 stale · 0 heard'],
['transit', 'transit: TTC 0 (95s old)'],
['transit', 'transit: TTC 0 · GO Transit 0 (95s old)'],
];
for (const locale of ['es', 'ru']) {
for (const [layerKey, caveat] of cases) {
test(`${locale}: analyst note "${caveat}" is translated`, () => {
setLocale(locale);
try {
const notes = notesFor(layerKey, caveat);
assert.ok(notes.length > 0, 'the note is shown');
assert.doesNotMatch(notes.join(' | '), /\b(stale|heard|old)\b/);
} finally {
setLocale('en');
}
});
}
}
for (const [layerKey, caveat] of cases) {
test(`en: analyst note "${caveat}" keeps its status line`, () => {
setLocale('en');
const line = caveat.slice(caveat.indexOf(': ') + 2);
assert.ok(
notesFor(layerKey, caveat).some((note) => note.endsWith(`: ${line}`)),
);
});
}
+2 -3
View File
@@ -1,6 +1,5 @@
// N-07: the analyst card shows a layer's "empty" note from getStats().coverage // The analyst card shows a layer's "empty" note (Transit's English
// as is; Transit's notes are English template strings with no catalog entry, // getStats().coverage text) in the UI language.
// so the es/ru card note stays English. Place: src/voice/analystTransitNote.test.mjs
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
-67
View File
@@ -1,67 +0,0 @@
// N-05: plan steps for preset ids from the tool schema (sf, nyc, dc) read
// "Sf" / "Nyc" / "Dc" in every language; 'tokyo' translates.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { CITY_POIS } from '../locations.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { narrationLabel, planStepLabel } from './speech.js';
test.afterEach(() => setLocale('en'));
test('es: preset location ids show the city name', () => {
setLocale('es');
assert.equal(
planStepLabel('fly_to_location', { locationId: 'tokyo' }),
'Volar a Tokio',
);
for (const id of ['sf', 'nyc', 'dc']) {
const label = planStepLabel('fly_to_location', { locationId: id });
assert.doesNotMatch(label, /\b(Sf|Nyc|Dc)$/, label);
}
assert.equal(
planStepLabel('fly_to_location', { locationId: 'nyc' }),
'Volar a Nueva York',
);
});
test('ru: a preset id in the nearest-aircraft step names the city', () => {
setLocale('ru');
assert.match(
planStepLabel('select_nearest_aircraft', { locationId: 'dc' }),
/Вашингтон/,
);
});
test('en: every preset id reads as its CITY_POIS name', () => {
setLocale('en');
for (const [id, city] of Object.entries(CITY_POIS)) {
assert.equal(
planStepLabel('fly_to_location', { locationId: id }),
`Fly to ${city.name}`,
id,
);
assert.equal(
narrationLabel('fly_to_location', { locationId: id }),
city.name,
);
}
// Other places are shown as given.
assert.equal(
planStepLabel('fly_to_location', { query: 'Ulaanbaatar' }),
'Fly to Ulaanbaatar',
);
});
test('every locationId the voice tools accept is a preset city', () => {
const ids = new Set();
const visit = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node.locationId?.enum))
node.locationId.enum.forEach((id) => ids.add(id));
Object.values(node).forEach(visit);
};
visit(GEV_ACTION_SCHEMAS);
assert.ok(ids.size > 0, 'found the locationId enums');
for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id);
});
+5 -1
View File
@@ -28,6 +28,7 @@ import {
} from '../i18n/index.js'; } from '../i18n/index.js';
import { layerNameForId, sourceLabel } from '../ui/layerNames.js'; import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js'; import { localizePlaceName } from '../ui/placeNames.js';
import { translateStatusLine } from '../ui/statusText.js';
import { formatQuantity } from '../ui/unitLabels.js'; import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({ const LAYER_NOUNS = Object.freeze({
@@ -372,7 +373,10 @@ function shownNote(text) {
const shown = match ? show(match) : null; const shown = match ? show(match) : null;
if (shown) return shown; 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. */ /** Feed states as the card's chips show them; the values stay English. */
+3 -21
View File
@@ -39,6 +39,7 @@ import {
sourceLabel, sourceLabel,
} from '../ui/layerNames.js'; } from '../ui/layerNames.js';
import { localizePlaceName } from '../ui/placeNames.js'; import { localizePlaceName } from '../ui/placeNames.js';
import { CITY_POIS } from '../cityPois.js';
import { formatQuantity } from '../ui/unitLabels.js'; import { formatQuantity } from '../ui/unitLabels.js';
const LAYER_NOUNS = Object.freeze({ const LAYER_NOUNS = Object.freeze({
@@ -344,28 +345,10 @@ function shownPair(items) {
: items[0] || ''; : items[0] || '';
} }
/** /** A preset city id (`sf`, `nyc`) as its English name; any other value unchanged. */
* 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. */
function presetPlaceName(value) { function presetPlaceName(value) {
const id = typeof value === 'string' ? value.trim() : ''; 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, // 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. */ /** Ids such as `austin` or `local-firms` read as names on screen. */
function displayName(value, max) { function displayName(value, max) {
const label = spokenLabel(value, max); const label = spokenLabel(value, max);
return /^[a-z0-9-]+$/.test(label) return /^[a-z0-9-]+$/.test(label)
+40 -1
View File
@@ -7,7 +7,8 @@ import test from 'node:test';
import { setLocale } from '../i18n/index.js'; import { setLocale } from '../i18n/index.js';
import { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; import { GEV_ACTION_SCHEMAS } from './actionSchemas.js';
import { presentResult, shownSnapshotTime } from './resultDisplay.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 { import {
initialVoiceCardState, initialVoiceCardState,
reduceVoiceCard, 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.match(title, /Los Ángeles$/, 'the headline is not cut at 64');
assert.equal(result.scopeLabel, 'within 40 km of Los Angeles'); 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);
});