From 3adce33cc6d881570e234bfb329d6780fa3ef69f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 16:39:25 +0400 Subject: [PATCH] i18n: fix the final pre-merge review findings Fixes the P1 and the confirmed P2 findings of the final review of the i18n branch. - CI: the air-quality model no longer imports i18n (portable module), so check:boundaries passes again. - Air quality layer: named in the layer panel, HUD and voice; its Weather card, bands and errors translate. A test keeps the layer-name table in step with the layer registry. - Layer statuses that still reached ru/es in English: GNSS, CelesTrak, Transit loading and feed errors, launches, perimeters, ALPR, ECCC, wind "field unavailable", Local ADS-B streaming lines. - Voice and HUD: preset ids show city names ("Fly to San Francisco"), the HUD city translates, cockpit refusals cover frame, aircraft and satellite; empty Transit notes translate on the analyst card. - tn() keeps a caller's formatted count ("12,345 map objects"). - Cyber HUD: the language button no longer covers the bottom-right card or the cockpit position readout. - CCTV buttons wrap only in non-English locales; English layout matches main again. - Switching language asks first when Provider Settings holds unsaved keys. Catalogs: es/ru 2611 messages each, complete. Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 6 +- src/hud.js | 8 +- src/hudLocality.js | 10 +- src/hudLocality.test.mjs | 4 +- src/hudLocalityCity.test.mjs | 26 +++ src/i18n/i18n.test.mjs | 12 ++ src/i18n/index.js | 8 +- src/i18n/locales/es.json | 83 ++++++++- src/i18n/locales/ru.json | 92 +++++++++- src/i18n/same-as-english.json | 2 + src/keySetup.js | 17 +- src/keySetup.test.mjs | 171 +++++++++++++++--- .../airQuality/airQualityLocale.test.mjs | 91 ++++++++++ src/layers/airQuality/index.js | 27 ++- src/layers/airQuality/model.js | 37 +++- src/layers/airQuality/source.js | 14 +- src/layers/alpr/source.js | 2 +- src/layers/gnss/index.js | 10 +- src/layers/gnss/source.js | 14 +- src/layers/gnss/sourceErrorLocale.test.mjs | 54 ++++++ src/layers/launches/source.js | 6 +- src/layers/localAdsb/status.js | 16 +- src/layers/localAdsb/statusLocale.test.mjs | 38 ++++ src/layers/perimeters/source.js | 6 +- src/layers/satellites/ingestion.js | 3 +- src/layers/transit/ingestion.js | 2 + src/layers/transit/queries.js | 6 +- .../wind/fieldUnavailableLocale.test.mjs | 21 +++ src/layers/wind/rendering.js | 15 +- src/navigationPolicy.js | 12 ++ src/navigationPolicyFrame.test.mjs | 158 ++++++++++++++++ src/ui/geoFileImport.test.mjs | 21 +++ src/ui/layerNames.js | 3 + src/ui/layerNames.test.mjs | 33 ++++ src/ui/layerPanel.js | 102 ++++++++++- src/ui/layerStatusLocale.test.mjs | 90 +++++++++ src/ui/styles/cctv.css | 6 +- src/ui/styles/cyber.css | 7 +- src/ui/styles/english-css-parity.test.mjs | 7 + src/voice/analystTransitNote.test.mjs | 72 ++++++++ src/voice/planStepPresetIds.i18n.test.mjs | 67 +++++++ src/voice/resultDisplay.js | 23 +++ src/voice/speech.js | 45 ++++- 43 files changed, 1350 insertions(+), 97 deletions(-) create mode 100644 src/hudLocalityCity.test.mjs create mode 100644 src/layers/airQuality/airQualityLocale.test.mjs create mode 100644 src/layers/gnss/sourceErrorLocale.test.mjs create mode 100644 src/layers/localAdsb/statusLocale.test.mjs create mode 100644 src/layers/wind/fieldUnavailableLocale.test.mjs create mode 100644 src/navigationPolicyFrame.test.mjs create mode 100644 src/ui/layerNames.test.mjs create mode 100644 src/ui/layerStatusLocale.test.mjs create mode 100644 src/voice/analystTransitNote.test.mjs create mode 100644 src/voice/planStepPresetIds.i18n.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 33f2560cc..02ddce387 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3821,7 +3821,11 @@ "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, diff --git a/src/hud.js b/src/hud.js index 7c659d1c4..980cbb6eb 100644 --- a/src/hud.js +++ b/src/hud.js @@ -35,7 +35,7 @@ import { DEFAULT_HUD_LAYOUT, normalizeHudLayout, } from './hudLayouts.js'; -import { formatNumber, msg, t } from './i18n/index.js'; +import { formatNumber, getLocale, msg, t } from './i18n/index.js'; import { unitLabel } from './ui/unitLabels.js'; import { localizeLayerName } from './ui/layerPanel.js'; import { localizePlaceName } from './ui/placeNames.js'; @@ -155,10 +155,12 @@ function hudCoordinate(value, 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, - city: localizePlaceName(city), + poi: poi.toLocaleUpperCase(getLocale()), + city: localizePlaceName(city).toLocaleUpperCase(getLocale()), distance: hudQuantity(Math.round(distKm), 'km'), }), sector: ({ latDeg, lonDeg }) => diff --git a/src/hudLocality.js b/src/hudLocality.js index fc4f0714b..5c66a18f9 100644 --- a/src/hudLocality.js +++ b/src/hudLocality.js @@ -29,7 +29,7 @@ export const NEAR_POI_MAX_KM = 150; */ const ENGLISH_LOCALITY = Object.freeze({ near: ({ poi, city, distKm }) => - `NEAR ${poi} (${city}) ${Math.round(distKm)}KM`, + `NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`, sector: ({ latDeg, lonDeg }) => `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`, }); @@ -54,7 +54,9 @@ function coordinateTag(value, positive, negative) { * @param {number} latDeg Camera latitude in decimal degrees. * @param {number} lonDeg Camera longitude in decimal degrees. * @param {{near: Function, sector: Function}} [format] Builds the line from - * `{ poi, city, distKm }` (names upper-cased) or `{ latDeg, lonDeg }`. + * `{ poi, city, distKm }` or `{ latDeg, lonDeg }`. The names arrive as + * catalogued (`Tokyo`), so a formatter can translate them first and + * upper-case the result in the page's language. * @returns {string} `NEAR () KM` within the bound (inclusive), * otherwise `SECTOR `. */ @@ -67,8 +69,8 @@ export function composeLocalityTag( const distKm = Number(nearest?.distKm); if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) { return format.near({ - poi: String(nearest.poi).toUpperCase(), - city: String(nearest.city).toUpperCase(), + poi: String(nearest.poi), + city: String(nearest.city), distKm, }); } diff --git a/src/hudLocality.test.mjs b/src/hudLocality.test.mjs index 237e1e527..40dcf651b 100644 --- a/src/hudLocality.test.mjs +++ b/src/hudLocality.test.mjs @@ -122,6 +122,8 @@ test('a missing or malformed nearest POI never crashes the summary', () => { assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /); }); +// 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). test('a formatter builds the line from the same parts', () => { const format = { near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`, @@ -129,7 +131,7 @@ test('a formatter builds the line from the same parts', () => { }; assert.equal( composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format), - 'near:ALCATRAZ ISLAND|SAN FRANCISCO|2.4', + 'near:Alcatraz Island|San Francisco|2.4', ); assert.equal( composeLocalityTag(null, 21.3, -157.8, format), diff --git a/src/hudLocalityCity.test.mjs b/src/hudLocalityCity.test.mjs new file mode 100644 index 000000000..de6d08c7b --- /dev/null +++ b/src/hudLocalityCity.test.mjs @@ -0,0 +1,26 @@ +// N-04: the HUD locality line passes the city upper-cased ("TOKYO") to the +// place-name lookup, which knows only "Tokyo", so the city never translates. +// The formatter below is the HUD's own (src/hud.js LOCALIZED_LOCALITY.near); +// hud.js itself does not load under node (mgrs CommonJS import). +// Place: src/hudLocalityCity.test.mjs +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from './i18n/index.js'; +import { composeLocalityTag } from './hudLocality.js'; +import { localizePlaceName } from './ui/placeNames.js'; + +const nearest = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 }; +const hudFormat = { + near: ({ city }) => localizePlaceName(city), + sector: () => '', +}; + +test('ru: the HUD locality names Tokyo in Russian', () => { + setLocale('ru'); + try { + const shown = composeLocalityTag(nearest, 35.66, 139.75, hudFormat); + assert.match(shown, /токио/i); + } finally { + setLocale('en'); + } +}); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1c0700aad..96443109b 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -234,3 +234,15 @@ test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', ( ['reload'], ]); }); + +test('tn() shows a caller-formatted count and still picks the form by number', () => { + const label = (n) => + tn(n, '{count} map object', '{count} map objects', { + count: formatNumber(n), + }); + assert.equal(label(12345), '12,345 map objects'); + assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files'); + setLocale('ru'); + assert.equal(label(12345), '12 345 объектов на карте'); + assert.equal(label(1), '1 объект на карте'); +}); diff --git a/src/i18n/index.js b/src/i18n/index.js index 21314125a..c5b6bd238 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -94,11 +94,13 @@ export function t(text, values) { /** * Translate a count-dependent message. The catalog entry for `other` holds * one form per Intl.PluralRules category of the locale ({ one, few, many, other } - * for Russian). `{count}` is always available to the pattern. + * for Russian). `{count}` is always available to the pattern: the raw number, + * unless `values.count` gives its display text, e.g. `formatNumber(n)`. * @param {number} count * @param {string} one English singular, e.g. '{count} camera' * @param {string} other English plural, e.g. '{count} cameras' - * @param {object} [values] Extra interpolation values + * @param {object} [values] Extra interpolation values; a `count` here is + * shown in place of the number (the plural form still follows `count`) * @returns {string} */ export function tn(count, one, other, values) { @@ -111,7 +113,7 @@ export function tn(count, one, other, values) { } else { pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other; } - return interpolate(pattern, { ...values, count }); + return interpolate(pattern, { count, ...values }); } /** diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 06ca3af11..4749947a5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2796,5 +2796,86 @@ }, "FOV {value}° (DATASHEET)": "FOV {value}° (FICHA TÉCNICA)", "SPEC FOV {span}° (CAPABILITY)": "FOV DE FICHA {span}° (CAPACIDAD)", - "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ESPECIFICACIONES: CCTV-DATABASE.COM" + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ESPECIFICACIONES: CCTV-DATABASE.COM", + "Canada only · ECCC AQHI network": "Solo Canadá · red AQHI de ECCC", + "{count} stations reporting": { + "one": "{count} estación con datos", + "many": "{count} de estaciones con datos", + "other": "{count} estaciones con datos" + }, + "No readings available": "No hay lecturas disponibles", + "No current readings": "Sin lecturas actuales", + "Low health risk": "Riesgo bajo para la salud", + "Moderate health risk": "Riesgo moderado para la salud", + "High health risk": "Riesgo alto para la salud", + "Very High health risk": "Riesgo muy alto para la salud", + "Air quality request failed": "Falló la petición de calidad del aire", + "ECCC request timed out": "La petición a ECCC agotó el tiempo de espera", + "Malformed AQHI station response": "Respuesta de estaciones AQHI mal formada", + "Malformed AQHI observation response": "Respuesta de observaciones AQHI mal formada", + "No AQHI stations available": "No hay estaciones AQHI disponibles", + "{n} air quality": { + "one": "{n} estación de calidad del aire", + "many": "{n} de estaciones de calidad del aire", + "other": "{n} estaciones de calidad del aire" + }, + "GNSS feed rate limited (HTTP 429)": "Fuente GNSS: límite de peticiones superado (HTTP 429)", + "GNSS feed unavailable (HTTP {status})": "Fuente GNSS no disponible (HTTP {status})", + "GNSS snapshot needs a view anchor": "La instantánea GNSS necesita un punto de referencia de la vista", + "Malformed GNSS integrity snapshot": "Instantánea de integridad GNSS mal formada", + "GNSS integrity snapshot has no observation time": "La instantánea de integridad GNSS no tiene hora de observación", + "{feed} feed unavailable": "Fuente {feed} no disponible", + "Loading {feeds}": "Cargando {feeds}", + "{count} CelesTrak groups unavailable": { + "one": "{count} grupo de CelesTrak no disponible", + "many": "{count} de grupos de CelesTrak no disponibles", + "other": "{count} grupos de CelesTrak no disponibles" + }, + "Malformed launch snapshot": "Instantánea de lanzamientos mal formada", + "Malformed perimeter snapshot": "Instantánea de perímetros mal formada", + "Overpass returned an incomplete camera response": "Overpass devolvió una respuesta de cámaras incompleta", + "Fly below {km} km to a covered region": "Desciende por debajo de {km} km sobre una región con cobertura", + "No feed here yet · {n} regions available": { + "one": "Aún no hay fuente aquí · {n} región disponible", + "many": "Aún no hay fuente aquí · {n} de regiones disponibles", + "other": "Aún no hay fuente aquí · {n} regiones disponibles" + }, + "Wind speed field unavailable": "Campo de velocidad del viento no disponible", + "Decoder feeds": "Fuentes del decodificador", + "WebUSB + decoder feeds": "WebUSB + fuentes del decodificador", + "opening receiver": "abriendo el receptor", + "checking decoder feeds": "comprobando las fuentes del decodificador", + "listening": "escuchando", + "USB error": "error de USB", + "{count} heard": { + "one": "{count} aeronave captada", + "many": "{count} de aeronaves captadas", + "other": "{count} aeronaves captadas" + }, + "{rate} msg/s": "{rate} msg/s", + "USB {rate} msg/s": "USB {rate} msg/s", + "{count} feeds live": { + "one": "{count} fuente en vivo", + "many": "{count} de fuentes en vivo", + "other": "{count} fuentes en vivo" + }, + "feeds {names} stale": { + "one": "fuente {names} desactualizada", + "many": "fuentes {names} desactualizadas", + "other": "fuentes {names} desactualizadas" + }, + "feeds {names} unreachable": { + "one": "fuente {names} inaccesible", + "many": "fuentes {names} inaccesibles", + "other": "fuentes {names} inaccesibles" + }, + "feeds {names} invalid": { + "one": "fuente {names} no válida", + "many": "fuentes {names} no válidas", + "other": "fuentes {names} no válidas" + }, + "unsaved provider keys": "las claves de proveedores sin guardar", + "Exit cockpit to fly to an aircraft": "Sal de la cabina para volar a una aeronave", + "Exit cockpit to fly to a satellite": "Sal de la cabina para volar a un satélite", + "Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3ef391d19..a13902555 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2854,5 +2854,95 @@ }, "FOV {value}° (DATASHEET)": "ОБЗОР {value}° (ПО ПАСПОРТУ)", "SPEC FOV {span}° (CAPABILITY)": "ОБЗОР ПО ПАСПОРТУ {span}° (ВОЗМОЖНОСТИ)", - "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ХАРАКТЕРИСТИКИ: CCTV-DATABASE.COM" + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ХАРАКТЕРИСТИКИ: CCTV-DATABASE.COM", + "Canada only · ECCC AQHI network": "Только Канада · сеть AQHI ECCC", + "{count} stations reporting": { + "one": "{count} станция передаёт данные", + "few": "{count} станции передают данные", + "many": "{count} станций передают данные", + "other": "{count} станции передают данные" + }, + "No readings available": "Нет доступных показаний", + "No current readings": "Нет актуальных показаний", + "Low health risk": "Низкий риск для здоровья", + "Moderate health risk": "Умеренный риск для здоровья", + "High health risk": "Высокий риск для здоровья", + "Very High health risk": "Очень высокий риск для здоровья", + "Air quality request failed": "Не удалось запросить данные о качестве воздуха", + "ECCC request timed out": "Истекло время ожидания запроса к ECCC", + "Malformed AQHI station response": "Некорректный ответ со станциями AQHI", + "Malformed AQHI observation response": "Некорректный ответ с наблюдениями AQHI", + "No AQHI stations available": "Нет доступных станций AQHI", + "{n} air quality": { + "one": "{n} станция качества воздуха", + "few": "{n} станции качества воздуха", + "many": "{n} станций качества воздуха", + "other": "{n} станции качества воздуха" + }, + "GNSS feed rate limited (HTTP 429)": "Поток GNSS: превышен лимит запросов (HTTP 429)", + "GNSS feed unavailable (HTTP {status})": "Поток GNSS недоступен (HTTP {status})", + "GNSS snapshot needs a view anchor": "Для снимка GNSS нужна опорная точка вида", + "Malformed GNSS integrity snapshot": "Некорректный снимок целостности GNSS", + "GNSS integrity snapshot has no observation time": "В снимке целостности GNSS нет времени наблюдения", + "{feed} feed unavailable": "Поток {feed} недоступен", + "Loading {feeds}": "Загрузка: {feeds}", + "{count} CelesTrak groups unavailable": { + "one": "{count} группа CelesTrak недоступна", + "few": "{count} группы CelesTrak недоступны", + "many": "{count} групп CelesTrak недоступны", + "other": "{count} группы CelesTrak недоступны" + }, + "Malformed launch snapshot": "Некорректный снимок запусков", + "Malformed perimeter snapshot": "Некорректный снимок периметров", + "Overpass returned an incomplete camera response": "Overpass вернул неполный ответ о камерах", + "Fly below {km} km to a covered region": "Опуститесь ниже {km} км над регионом с покрытием", + "No feed here yet · {n} regions available": { + "one": "Здесь пока нет потока данных · доступен {n} регион", + "few": "Здесь пока нет потока данных · доступно {n} региона", + "many": "Здесь пока нет потока данных · доступно {n} регионов", + "other": "Здесь пока нет потока данных · доступно {n} региона" + }, + "Wind speed field unavailable": "Поле скорости ветра недоступно", + "Decoder feeds": "Потоки декодера", + "WebUSB + decoder feeds": "WebUSB + потоки декодера", + "opening receiver": "открытие приёмника", + "checking decoder feeds": "проверка потоков декодера", + "listening": "приём", + "USB error": "ошибка USB", + "{count} heard": { + "one": "слышно бортов: {count}", + "few": "слышно бортов: {count}", + "many": "слышно бортов: {count}", + "other": "слышно бортов: {count}" + }, + "{rate} msg/s": "{rate} сообщ./с", + "USB {rate} msg/s": "USB {rate} сообщ./с", + "{count} feeds live": { + "one": "{count} поток онлайн", + "few": "{count} потока онлайн", + "many": "{count} потоков онлайн", + "other": "{count} потока онлайн" + }, + "feeds {names} stale": { + "one": "поток {names} устарел", + "few": "потоки {names} устарели", + "many": "потоки {names} устарели", + "other": "потоки {names} устарели" + }, + "feeds {names} unreachable": { + "one": "поток {names} недоступен", + "few": "потоки {names} недоступны", + "many": "потоки {names} недоступны", + "other": "потоки {names} недоступны" + }, + "feeds {names} invalid": { + "one": "поток {names} некорректен", + "few": "потоки {names} некорректны", + "many": "потоки {names} некорректны", + "other": "потоки {names} некорректны" + }, + "unsaved provider keys": "несохранённые ключи провайдеров", + "Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту", + "Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику", + "Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c92cfc1fb..c159beaab 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -88,6 +88,7 @@ "Soul", "Techno", "Trance", + "USB {rate} msg/s", "VISUAL", "VS", "WHOT/BHOT", @@ -107,6 +108,7 @@ "panel", "view band|GLOBAL", "voice|ERROR", + "{rate} msg/s", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", "{value} KM", diff --git a/src/keySetup.js b/src/keySetup.js index 177937126..0b632f35c 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -20,7 +20,7 @@ import { * the chip and the dialog are removed outright. */ -import { t, msg, tn } from './i18n/index.js'; +import { t, msg, tn, registerUnsavedWork } from './i18n/index.js'; /** * Chip label — pure, exported for tests. The exact English values are pinned @@ -386,9 +386,24 @@ export async function initKeySetup({ let disposed = false; let disposeControls = () => {}; let disposePlacement = () => {}; + // A pasted key lives only in its field until Save: switching language (a + // reload) asks first while any field holds one. + const unregisterUnsavedWork = registerUnsavedWork(() => + !disposed && + Object.keys( + collectKeyUpdates( + [...(root.querySelectorAll?.('input[data-env-var]') || [])].map( + (input) => ({ envVar: input.dataset?.envVar, value: input.value }), + ), + ), + ).length + ? msg('unsaved provider keys') + : null, + ); const destroy = () => { if (disposed) return; disposed = true; + unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); disposeControls(); diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index b3662bedf..786977cab 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -9,25 +9,42 @@ import { } from './keySetup.js'; test('setup placement moves the same button only in Cyber and disconnects on teardown', () => { - const home = { prepend: (node) => { node.parentNode = home; } }; + const home = { + prepend: (node) => { + node.parentNode = home; + }, + }; const chip = { parentNode: home, hidden: true }; let moves = 0; - const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } }; + const toolbar = { + append: (node) => { + moves++; + node.parentNode = toolbar; + }, + }; const theme = { dataset: { uiTheme: 'tactical' } }; - let notify, disconnected = false; + let notify, + disconnected = false; class Observer { - constructor(callback) { notify = callback; } + constructor(callback) { + notify = callback; + } observe(target, options) { assert.equal(target, theme); assert.deepEqual(options.attributeFilter, ['data-ui-theme']); } - disconnect() { disconnected = true; } + disconnect() { + disconnected = true; + } } - const dispose = bindKeySetupPlacement({ - documentElement: theme, - getElementById: () => toolbar, - defaultView: { MutationObserver: Observer }, - }, chip); + const dispose = bindKeySetupPlacement( + { + documentElement: theme, + getElementById: () => toolbar, + defaultView: { MutationObserver: Observer }, + }, + chip, + ); assert.equal(chip.parentNode, home); for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) { theme.dataset.uiTheme = variant; @@ -42,10 +59,20 @@ test('setup placement moves the same button only in Cyber and disconnects on tea }); test('the chip counts what is missing, and retires the count at zero', () => { - assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING'); - assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING'); + assert.equal( + keySetupChipLabel({ setCount: 0, total: 8 }), + 'POWER UP · 8 KEYS WAITING', + ); + assert.equal( + keySetupChipLabel({ setCount: 7, total: 8 }), + 'POWER UP · 1 KEY WAITING', + ); assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP'); - assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label'); + assert.equal( + keySetupChipLabel(null), + 'POWERED UP', + 'no status is not a broken label', + ); }); test('collectKeyUpdates keeps only non-empty trimmed values', () => { @@ -85,14 +112,27 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available' test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => { for (const result of [ - { ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } }, + { + ok: true, + payload: { + available: false, + loginFailed: true, + error: 'Sign-in failed.', + }, + }, { ok: false, payload: { error: 'Sign-in is local only.' } }, ]) { let checks = 0; - await assert.rejects(waitForChatGptOAuth({ - fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; }, - sleep: () => assert.fail('failed login must stop polling'), - }), { message: result.payload.error }); + await assert.rejects( + waitForChatGptOAuth({ + fetchImpl: async () => { + checks += 1; + return { ok: result.ok, json: async () => result.payload }; + }, + sleep: () => assert.fail('failed login must stop polling'), + }), + { message: result.payload.error }, + ); assert.equal(checks, 1); } }); @@ -100,12 +140,34 @@ test('OAuth login polling stops on login or HTTP failure instead of waiting for test('OAuth login polling respects timeout and an aborted setup surface', async () => { let clock = 0; let checks = 0; - const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } }; - assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false); + const poll = { + fetchImpl: async () => { + checks += 1; + return { + ok: true, + json: async () => ({ available: false, pending: true }), + }; + }, + }; + assert.equal( + await waitForChatGptOAuth({ + ...poll, + timeoutMs: 100, + pollMs: 100, + now: () => clock, + sleep: async (ms) => { + clock += ms; + }, + }), + false, + ); assert.equal(checks, 2); const controller = new AbortController(); controller.abort(); - assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false); + assert.equal( + await waitForChatGptOAuth({ ...poll, signal: controller.signal }), + false, + ); assert.equal(checks, 2, 'aborted polling does not fetch'); }); @@ -113,14 +175,22 @@ test('OAuth polling ignores a ready response received after the surface was abor const controller = new AbortController(); const available = await waitForChatGptOAuth({ signal: controller.signal, - fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }), + fetchImpl: async () => ({ + ok: true, + json: async () => { + controller.abort(); + return { available: true }; + }, + }), sleep: () => assert.fail('aborted polling must not continue'), }); assert.equal(available, false); }); test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => { - const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal'); + const stripped = stripKeylessBasemapFromHash( + 'lat=30.2&lon=-97.7&map=osm&style=normal', + ); assert.ok(stripped !== null); const params = new URLSearchParams(stripped); assert.equal(params.get('map'), null, 'osm basemap removed'); @@ -128,7 +198,11 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha assert.equal(params.get('style'), 'normal', 'style survives'); // A stack under any other name was chosen or shared on purpose. assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null); - assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do'); + assert.equal( + stripKeylessBasemapFromHash('lat=1&lon=2'), + null, + 'no stack, nothing to do', + ); assert.equal(stripKeylessBasemapFromHash(''), null); assert.equal(stripKeylessBasemapFromHash(undefined), null); }); @@ -142,11 +216,15 @@ test('aborting pending setup removes its surface and ignores a late response', a let requestSignal; const controller = new AbortController(); const pending = initKeySetup({ - documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root }, + documentRef: { + getElementById: (id) => (id === 'key-setup-chip' ? chip : root), + }, signal: controller.signal, fetchImpl: (_url, { signal }) => { requestSignal = signal; - return new Promise((resolve) => { resolveResponse = resolve; }); + return new Promise((resolve) => { + resolveResponse = resolve; + }); }, }); controller.abort(); @@ -155,3 +233,44 @@ test('aborting pending setup removes its surface and ignores a late response', a resolveResponse({ ok: true, json: async () => ({ keys: [] }) }); assert.equal(await pending, null); }); + +test('a pasted, unsaved key makes the language switch ask first until teardown', async () => { + const { initKeySetup } = await import('./keySetup.js'); + const { describeUnsavedWork } = await import('./i18n/index.js'); + const node = () => ({ + dataset: {}, + addEventListener() {}, + removeEventListener() {}, + setAttribute() {}, + querySelector: () => null, + remove() {}, + }); + const chip = node(); + const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' }; + const root = { + ...node(), + querySelectorAll: (selector) => + selector === 'input[data-env-var]' ? [input] : [], + }; + const controller = new AbortController(); + const setup = await initKeySetup({ + documentRef: { + getElementById: (id) => (id === 'key-setup-chip' ? chip : root), + addEventListener() {}, + removeEventListener() {}, + }, + signal: controller.signal, + fetchImpl: async () => ({ + ok: true, + json: async () => ({ keys: [], setCount: 0, total: 1 }), + }), + }); + assert.ok(setup); + assert.deepEqual(describeUnsavedWork(), []); + input.value = ' '; + assert.deepEqual(describeUnsavedWork(), []); + input.value = 'AIza-pasted'; + assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']); + controller.abort(); + assert.deepEqual(describeUnsavedWork(), []); +}); diff --git a/src/layers/airQuality/airQualityLocale.test.mjs b/src/layers/airQuality/airQualityLocale.test.mjs new file mode 100644 index 000000000..efd30f6a0 --- /dev/null +++ b/src/layers/airQuality/airQualityLocale.test.mjs @@ -0,0 +1,91 @@ +// N-02: the Air quality layer (merged from main, AQHI) is fully English in +// es/ru: layer name, Weather card (title, coverage, status, legend, risk) and +// its own failure text. Place: src/layers/airQuality/airQualityLocale.test.mjs +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale, t } from '../../i18n/index.js'; +import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js'; +import { LayerPanel } from '../../ui/layerPanel.js'; +import { createAirQualityLayer } from './index.js'; + +function inLocale(locale, run) { + setLocale(locale); + try { + return run(); + } finally { + setLocale('en'); + } +} + +function harness(source) { + const layer = createAirQualityLayer({ + source, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + }); + const viewer = { dataSources: { add() {}, remove() {} } }; + layer.init(viewer); + layer.enable(viewer); + return layer; +} + +const reading = { + id: 'eccc:A', + provider: 'eccc', + scale: 'AQHI', + value: 2, + band: 'low', + stationId: 'A', + name: 'Toronto', + zone: 'ont', + lat: 43.65, + lon: -79.38, + observedMs: 1_700_000_000_000, + color: '#4cc9f0', + label: '2', + risk: 'Low health risk', + note: null, +}; + +for (const locale of ['es', 'ru']) { + test(`${locale}: the layer name is translated`, () => { + inLocale(locale, () => { + assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry'); + assert.equal(layerNameForId('air-quality'), t('Air quality')); + assert.equal(localizeLayerName('Air quality'), t('Air quality')); + }); + }); + + test(`${locale}: the Weather card of Air quality is translated`, async () => { + const layer = harness({ getSnapshot: async () => [reading] }); + await layer.update(); + const controls = inLocale(locale, () => layer.getRowControls()); + const english = [ + controls.summary.label, + controls.summary.coverage, + controls.summary.status, + ...controls.legend.map((entry) => entry.label), + ...controls.list.items.map((item) => item.text), + ].filter((text) => + /\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test( + text, + ), + ); + assert.deepEqual(english, [], `English left in the ${locale} card`); + }); + + test(`${locale}: the Air quality failure text is translated in the row`, () => { + const row = inLocale(locale, () => + LayerPanel.prototype._buildMetaText({ + id: 'air-quality', + enabled: true, + stats: { + count: 0, + error: 'Air quality request failed', + source: 'ECCC', + lastUpdate: null, + }, + }), + ); + assert.ok(!row.includes('Air quality request failed'), row); + }); +} diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 9b7cd3876..9fe4d8e01 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { t } from '../../i18n/index.js'; +import { msg, t, tn, translateKnown } from '../../i18n/index.js'; import { AIR_QUALITY_BANDS, AIR_QUALITY_OVERLAY_SOURCE_ID, @@ -110,7 +110,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { return true; } catch (error) { if (request.signal.aborted || _request !== request) return false; - _lastError = error?.message || 'Air quality request failed'; + _lastError = error?.message || msg('Air quality request failed'); console.warn('[Data:AirQuality] Refresh failed:', _lastError); notify(); return false; @@ -215,25 +215,32 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { (max, reading) => Math.max(max, reading.observedMs || 0), 0, ); + // Readings and errors stay English data (analyst records, stats); the + // card shows them in the UI language. A provider's own error text has + // no catalog entry and shows as it came. return { readout: true, summary: { - label: provider.label, - coverage: provider.coverage, + label: t(provider.label), + coverage: t(provider.coverage), validTime: newest ? new Date(newest).toISOString() : undefined, detail: `${provider.network} · ${provider.scale}`, status: _lastError - ? _lastError + ? translateKnown(_lastError) : _count === 0 ? _lastUpdate === null - ? 'No readings available' - : 'No current readings' - : `${_count} stations reporting`, + ? t('No readings available') + : t('No current readings') + : tn( + _count, + '{count} station reporting', + '{count} stations reporting', + ), units: provider.scale, }, legend: AIR_QUALITY_BANDS.map((band) => ({ color: band.color, - label: band.label, + label: t(band.label), })), legendCategorical: true, list: { @@ -242,7 +249,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { id: reading.id, ordinal: index + 1, lead: `${reading.scale} ${reading.label}`, - text: `${reading.name} · ${reading.risk}`, + text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`, accent: reading.color, })), }, diff --git a/src/layers/airQuality/model.js b/src/layers/airQuality/model.js index 4854a3761..d5fded2fb 100644 --- a/src/layers/airQuality/model.js +++ b/src/layers/airQuality/model.js @@ -15,14 +15,36 @@ * Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a * later provider registers against. */ -import { msg } from '../../i18n/index.js'; +// A portable export (package.json): no i18n here. The labels stay English data +// and the Layers panel shows them translated. +const msg = (text) => text; /** Band identifiers shared across providers, in ascending severity. */ export const AIR_QUALITY_BANDS = Object.freeze([ - Object.freeze({ id: 'low', label: msg('Low'), color: '#4cc9f0' }), - Object.freeze({ id: 'moderate', label: msg('Moderate'), color: '#ffd43b' }), - Object.freeze({ id: 'high', label: msg('High'), color: '#ff6b35' }), - Object.freeze({ id: 'very-high', label: msg('Very High'), color: '#c1121f' }), + Object.freeze({ + id: 'low', + label: msg('Low'), + risk: msg('Low health risk'), + color: '#4cc9f0', + }), + Object.freeze({ + id: 'moderate', + label: msg('Moderate'), + risk: msg('Moderate health risk'), + color: '#ffd43b', + }), + Object.freeze({ + id: 'high', + label: msg('High'), + risk: msg('High health risk'), + color: '#ff6b35', + }), + Object.freeze({ + id: 'very-high', + label: msg('Very High'), + risk: msg('Very High health risk'), + color: '#c1121f', + }), ]); const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band])); @@ -92,7 +114,7 @@ export const AIR_QUALITY_PROVIDERS = Object.freeze({ scale: 'AQHI', label: msg('Air quality'), network: 'Environment and Climate Change Canada', - coverage: 'Canada only · ECCC AQHI network', + coverage: msg('Canada only · ECCC AQHI network'), /** ECCC's published band thresholds, evaluated in order. */ bands: Object.freeze([ Object.freeze({ id: 'low', max: 3 }), @@ -162,8 +184,7 @@ export function valueLabel(providerId, value) { /** Plain-language risk phrase for a band id. */ export function bandRiskText(bandId) { - const band = airQualityBandById(bandId); - return band ? `${band.label} health risk` : null; + return airQualityBandById(bandId)?.risk ?? null; } /** diff --git a/src/layers/airQuality/source.js b/src/layers/airQuality/source.js index 8fb4808ec..02063bec9 100644 --- a/src/layers/airQuality/source.js +++ b/src/layers/airQuality/source.js @@ -1,6 +1,10 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js'; +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel and the Weather card show them translated. +const msg = (text) => text; + const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections'; const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`; const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`; @@ -47,7 +51,7 @@ export function createEcccAirQualitySource({ const abort = () => controller.abort(signal.reason); signal?.addEventListener('abort', abort, { once: true }); const timer = setTimeout( - () => controller.abort(new Error('ECCC request timed out')), + () => controller.abort(new Error(msg('ECCC request timed out'))), timeoutMs, ); try { @@ -82,14 +86,15 @@ export function createEcccAirQualitySource({ signal, ); const next = normalizeAqhiStations(payload); - if (!next) throw new Error('Malformed AQHI station response'); + if (!next) throw new Error(msg('Malformed AQHI station response')); // An empty refresh must not wipe a good catalog. if (next.size > 0) { stations = next; stationsFetchedAt = clock; } } - if (stations.size === 0) throw new Error('No AQHI stations available'); + if (stations.size === 0) + throw new Error(msg('No AQHI stations available')); const payload = await getJson( `${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`, @@ -97,7 +102,8 @@ export function createEcccAirQualitySource({ signal, ); const readings = normalizeAqhiObservations(payload, stations, clock); - if (!readings) throw new Error('Malformed AQHI observation response'); + if (!readings) + throw new Error(msg('Malformed AQHI observation response')); return readings; }, }; diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js index 1b886fe9e..76166ad50 100644 --- a/src/layers/alpr/source.js +++ b/src/layers/alpr/source.js @@ -245,7 +245,7 @@ export function createOverpassAlprSource({ // The shared proxy already rejects query errors. Validate here too so a // malformed or partial response never becomes an authoritative empty map. if (!Array.isArray(payload?.elements) || payload.remark) { - throw new Error('Overpass returned an incomplete camera response'); + throw new Error(msg('Overpass returned an incomplete camera response')); } return { records: [ diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 3dfd0808e..86891f4c7 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -31,6 +31,14 @@ const LEVEL_LABELS = Object.freeze({ high: msg('Low-accuracy score > 10%'), }); +/** A source failure (English data, source.js) in the UI language. */ +function shownSourceError(text) { + const http = /^GNSS feed unavailable \(HTTP (\d{3})\)$/.exec(text); + return http + ? t('GNSS feed unavailable (HTTP {status})', { status: http[1] }) + : translateKnown(text); +} + /** Degrees at the centre of the current view, or null before the camera settles. */ export function defaultGnssViewAnchor(viewer) { const canvas = viewer?.scene?.canvas; @@ -377,7 +385,7 @@ export function createGnssIntegrityLayer({ 'Regional sample · 250 nm radius · cells from visited views over 30 min', ) + (_lastError && _retryAt - ? ` · ${translateKnown(_lastError)} · ${ + ? ` · ${shownSourceError(_lastError)} · ${ _retryAt <= 8640000000000000 ? t('Retrying automatically at {time} UTC', { time: new Date(_retryAt).toISOString().slice(11, 19), diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js index af67aa18b..f7c114a64 100644 --- a/src/layers/gnss/source.js +++ b/src/layers/gnss/source.js @@ -1,5 +1,9 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + function retryDelay(raw, nowMs) { if (!raw) return null; const seconds = Number(raw); @@ -21,7 +25,7 @@ export function createAdsbGnssSource({ async getSnapshot({ latitude, longitude } = {}, { signal } = {}) { signal?.throwIfAborted(); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) - throw new Error('GNSS snapshot needs a view anchor'); + throw new Error(msg('GNSS snapshot needs a view anchor')); const params = new URLSearchParams({ lat: String(Math.round(latitude)), lon: String(Math.round(longitude)), @@ -37,8 +41,10 @@ export function createAdsbGnssSource({ ); throw Object.assign( new Error( + // The status code form is matched by FEED_ERROR_PATTERNS + // (ui/layerPanel.js) and gnss/index.js. limited - ? 'GNSS feed rate limited (HTTP 429)' + ? msg('GNSS feed rate limited (HTTP 429)') : `GNSS feed unavailable (HTTP ${response.status})`, ), { @@ -55,12 +61,12 @@ export function createAdsbGnssSource({ ); signal?.throwIfAborted(); if (!Array.isArray(payload?.rows)) - throw new Error('Malformed GNSS integrity snapshot'); + throw new Error(msg('Malformed GNSS integrity snapshot')); // The snapshot's age (measured on the proxy's clock) dates every row in // the rolling window; an undated snapshot could only be treated as new, // so it is refused. if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0) - throw new Error('GNSS integrity snapshot has no observation time'); + throw new Error(msg('GNSS integrity snapshot has no observation time')); return { rows: payload.rows, ageMs: payload.ageMs, diff --git a/src/layers/gnss/sourceErrorLocale.test.mjs b/src/layers/gnss/sourceErrorLocale.test.mjs new file mode 100644 index 000000000..8618008cc --- /dev/null +++ b/src/layers/gnss/sourceErrorLocale.test.mjs @@ -0,0 +1,54 @@ +// N-03: the GNSS source's own failures are English data (source.js); the +// card's info line shows them in the UI language, the HTTP form included. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { setLocale } from '../../i18n/index.js'; +import { createGnssIntegrityLayer } from './index.js'; + +async function failedLayer(message) { + const layer = createGnssIntegrityLayer({ + source: { + async getSnapshot() { + throw Object.assign(new Error(message), { + retryable: true, + retryAfterMs: 60_000, + }); + }, + }, + viewAnchor: () => ({ latitude: 50, longitude: 30 }), + now: () => 1_000, + monotonicNow: () => 1_000, + }); + layer.init({ + camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, + scene: { + globe: { show: true }, + groundPrimitives: { add() {}, remove() {} }, + }, + }); + layer.enable(); + await layer.update(); + return layer; +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: the GNSS source failure is translated on the card`, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + for (const message of [ + 'GNSS feed unavailable (HTTP 503)', + 'GNSS feed rate limited (HTTP 429)', + ]) { + const layer = await failedLayer(message); + setLocale(locale); + try { + const { info } = layer.getRowControls(); + assert.ok(info.includes('HTTP'), info); + assert.doesNotMatch(info, /feed (unavailable|rate limited)/, info); + } finally { + setLocale('en'); + layer.destroy(); + } + } + }); +} diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js index 2779e7d81..2e2833cca 100644 --- a/src/layers/launches/source.js +++ b/src/layers/launches/source.js @@ -1,3 +1,7 @@ +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + /** Read launch records and their optional active-orbit catalog with explicit cancellation. */ export function createLaunchSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -10,7 +14,7 @@ export function createLaunchSource({ const payload = await response.json(); signal?.throwIfAborted(); if (!Array.isArray(payload) && !Array.isArray(payload?.results)) - throw new Error('Malformed launch snapshot'); + throw new Error(msg('Malformed launch snapshot')); return { payload, stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR', diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index db9afbbf2..5fff0d45c 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -1,7 +1,9 @@ import { localReceiverFeedName } from './feedNames.js'; import { LAYER_SOURCE } from './policy.js'; -// Marks English that the Layers panel translates when it shows the hint. +// Marks English that the Layers panel translates when it shows the status. +// Texts with a number or a feed name stay English data too; the panel shows +// them through STATUS_PIECE_PATTERNS (ui/layerPanel.js), piece by piece. const msg = (text) => text; /** @@ -9,8 +11,8 @@ const msg = (text) => text; * WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium. */ -export const FEED_SOURCE = 'Decoder feeds'; -export const COMBINED_SOURCE = 'WebUSB + decoder feeds'; +export const FEED_SOURCE = msg('Decoder feeds'); +export const COMBINED_SOURCE = msg('WebUSB + decoder feeds'); function plural(count, one, many) { return count === 1 ? one : many; @@ -51,13 +53,13 @@ function webUsbStatus(receiver, heard, feedState) { if (receiver.status === 'error') return { status: 'error', error: receiver.message }; if (receiver.status === 'connecting' || receiver.status === 'tuning') - return { loading: true, loadingLabel: 'opening receiver' }; + return { loading: true, loadingLabel: msg('opening receiver') }; if ( feedState?.polling && feedState.configured === null && !receiver.connected ) - return { loading: true, loadingLabel: 'checking decoder feeds' }; + return { loading: true, loadingLabel: msg('checking decoder feeds') }; if (!receiver.webUsbSupported) return { status: 'idle', @@ -72,7 +74,7 @@ function webUsbStatus(receiver, heard, feedState) { return { status: 'idle', statusMessage: msg('receiver is in FM mode') }; const rate = Number.isFinite(receiver.messagesPerSecond) ? `${receiver.messagesPerSecond} msg/s` - : 'listening'; + : msg('listening'); return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` }; } @@ -103,7 +105,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) { const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE; const live = feedState.feeds.filter((feed) => feed.status === 'live'); const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean); - if (usbActive && receiver.status === 'error') problems.push('USB error'); + if (usbActive && receiver.status === 'error') problems.push(msg('USB error')); const rate = usbActive && receiver.status !== 'error' && diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs new file mode 100644 index 000000000..2c965c05f --- /dev/null +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -0,0 +1,38 @@ +// N-10: the Local ADS-B row's working texts ("3 heard · 5.8 msg/s", +// "2 feeds live · 14 heard", "listening", "USB error", feed problems) have no +// catalog entries and stay English in es/ru. Place: src/layers/localAdsb/statusLocale.test.mjs +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: true, + mode: 'adsb', + status: 'streaming', + messagesPerSecond: 5.8, +}; + +function rowFor(status) { + return LayerPanel.prototype._buildMetaText({ + id: 'local-adsb', + enabled: true, + stats: { count: 3, lastUpdate: Date.now(), ...status }, + }); +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: the streaming Local ADS-B row is translated`, () => { + setLocale(locale); + try { + const row = rowFor( + localAdsbStatus({ receiver, feedState: null, heard: 3 }), + ); + assert.doesNotMatch(row, /\bheard\b/, row); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index 9147fe0f9..133beadb5 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -1,5 +1,9 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + /** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */ export function createWfigsPerimeterSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -16,7 +20,7 @@ export function createWfigsPerimeterSource({ ); signal?.throwIfAborted(); if (!Array.isArray(payload?.rows)) - throw new Error('Malformed perimeter snapshot'); + throw new Error(msg('Malformed perimeter snapshot')); return payload.rows; }, }; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7e1a7ba6d..72ccb669d 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -71,7 +71,8 @@ export function createIngestion({ // At least one group loaded — proceed with a fresh rebuild, but keep the // partial outage visible at the layer control instead of presenting the - // reduced catalog as a fully healthy refresh. + // reduced catalog as a fully healthy refresh. English data; the panel + // shows it through FEED_ERROR_PATTERNS (ui/layerPanel.js). layerState._lastError = failed.length ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` : null; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index be8833270..9aaaa6fc3 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -476,6 +476,8 @@ export function createIngestion({ state, services, parts, source }) { `[Data:Transit] ${feed.id} poll failed:`, error?.message || error, ); + // English data; the panel shows it through FEED_ERROR_PATTERNS + // (ui/layerPanel.js). status.error = `${feed.name} feed unavailable`; status.retryInSec = Number.isFinite(error?.retryInSec) ? error.retryInSec diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 09b94c49f..b50478398 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -246,9 +246,11 @@ export function createQueries({ state, parts }) { error: null, source: 'GTFS-RT', status: 'zoom-in', + // English data (the analyst's empty note to the model); the voice + // card shows it through NOTE_PATTERNS (voice/resultDisplay.js). coverage: state._altitudeGateOpen ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` - : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString()} km to a covered region`, + : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`, }; } // Age is shown by the same rule the row state uses, so the text and the @@ -276,6 +278,8 @@ export function createQueries({ state, parts }) { : {}), degraded: count > 0 && health.degraded, loading: health.loading && count === 0, + // English data; the panel shows it through LOADING_LABEL_PATTERNS + // (ui/layerPanel.js). loadingLabel: health.loading && count === 0 ? `Loading ${active.map((feed) => feed.name).join(', ')}` diff --git a/src/layers/wind/fieldUnavailableLocale.test.mjs b/src/layers/wind/fieldUnavailableLocale.test.mjs new file mode 100644 index 000000000..ddf3fc495 --- /dev/null +++ b/src/layers/wind/fieldUnavailableLocale.test.mjs @@ -0,0 +1,21 @@ +// N-09: a scalar overlay that cannot be drawn reports a marked English status +// (rendering.js), which the Wind row shows through t() in the UI language. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t } from '../../i18n/index.js'; +import { WIND_FIELDS } from './fields.js'; +import { fieldUnavailableText } from './rendering.js'; + +for (const locale of ['es', 'ru']) { + test(`${locale}: every field's unavailable status has a translation`, () => { + setLocale(locale); + try { + const english = Object.keys(WIND_FIELDS) + .map(fieldUnavailableText) + .filter((text) => t(text) === text); + assert.deepEqual(english, []); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index bb891ae44..e3489b96a 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -14,6 +14,19 @@ import { windTrailColor, } from './fields.js'; +// A scalar overlay that cannot be drawn, in English for diagnostics; the +// panel status shows it translated (index.js). The scalar texts are the ones +// the source reports (source.js). +const FIELD_UNAVAILABLE = Object.freeze({ + speed: msg('Wind speed field unavailable'), + temperature: msg('Temperature field unavailable'), + pressure: msg('Mean sea level pressure field unavailable'), +}); + +/** English status for a scalar overlay with no drawable field. */ +export function fieldUnavailableText(overlay) { + return FIELD_UNAVAILABLE[overlay] || `${overlay} field unavailable`; +} const WIND_FIELD_FADE_HIGH_METERS = 1_200_000; const WIND_FIELD_FADE_LOW_METERS = 200_000; const WIND_FIELD_LOG_RANGE = Math.log( @@ -337,7 +350,7 @@ export function createWindRendering({ } const raster = createFieldRaster(snapshot, overlay); if (!raster) { - imageryError = `${overlay} field unavailable`; + imageryError = fieldUnavailableText(overlay); return; } if ( diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 4513787b8..000d4a3e3 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -14,6 +14,8 @@ export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken'; // The Cockpit refusal per destination noun. Nouns are English identifiers // from the callers ('camera', 'location', 'photo'…); a noun without its own // sentence falls back to the template with the noun as given. +// navigationPolicyFrame.test.mjs collects every noun the callers pass and +// fails when one has no sentence here. const COCKPIT_REFUSALS = Object.freeze({ camera: msg('Exit cockpit to fly to a camera'), location: msg('Exit cockpit to fly to a location'), @@ -29,8 +31,18 @@ const COCKPIT_REFUSALS = Object.freeze({ // «View coverage» button); the cyclone layer flies to the selected storm. weather: msg('Exit cockpit to view weather coverage'), cyclone: msg('Exit cockpit to fly to a cyclone'), + // Voice track_entity (TRACKABLE_FAMILIES kinds in gevActions.js) and + // frame_overhead, which frames the contacts around the current view. + aircraft: msg('Exit cockpit to fly to an aircraft'), + satellite: msg('Exit cockpit to fly to a satellite'), + frame: msg('Exit cockpit to frame contacts'), }); +/** The destination nouns with their own Cockpit refusal (for tests). */ +export const COCKPIT_REFUSAL_NOUNS = Object.freeze( + Object.keys(COCKPIT_REFUSALS), +); + function cockpitRefusal(noun) { const ready = COCKPIT_REFUSALS[noun]; return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun }); diff --git a/src/navigationPolicyFrame.test.mjs b/src/navigationPolicyFrame.test.mjs new file mode 100644 index 000000000..cd8f035f6 --- /dev/null +++ b/src/navigationPolicyFrame.test.mjs @@ -0,0 +1,158 @@ +// N-06: voice frame_overhead under Cockpit passed the noun 'frame', which had +// no entry in COCKPIT_REFUSALS, so the toast fell back to the template with +// the English id («Sal de la cabina para volar a frame»). The second test +// collects every noun the callers hand to the navigation policy, so the next +// noun without its own sentence fails here instead of reaching the page. +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { setLocale } from './i18n/index.js'; +import { + COCKPIT_REFUSAL_NOUNS, + runExplicitNavigation, +} from './navigationPolicy.js'; +import { WORLD_FOCUS_FRAMING } from './worldFocus.js'; + +const SRC = fileURLToPath(new URL('.', import.meta.url)); + +test.afterEach(() => setLocale('en')); + +function cockpitToast(noun) { + const log = []; + runExplicitNavigation({ + cockpitActive: true, + noun, + showToast: (text) => log.push(text), + stamp: () => 1, + release: () => {}, + navigate: () => {}, + }); + assert.equal(log.length, 1); + return log[0]; +} + +test('es: the Cockpit refusal for frame_overhead has no English id', () => { + setLocale('es'); + const shown = cockpitToast('frame'); + assert.doesNotMatch(shown, /\bframe\b/, shown); + assert.equal(shown, 'Sal de la cabina para encuadrar contactos'); +}); + +test('en: the voice nouns read as English sentences', () => { + setLocale('en'); + assert.equal(cockpitToast('frame'), 'Exit cockpit to frame contacts'); + assert.equal(cockpitToast('aircraft'), 'Exit cockpit to fly to an aircraft'); + assert.equal(cockpitToast('satellite'), 'Exit cockpit to fly to a satellite'); +}); + +// ── Every noun a caller passes ───────────────────────────────────────────── + +function sourceFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.js$/.test(entry.name) && !/\.test\./.test(entry.name) + ? [path] + : []; + }); +} + +const lineOf = (text, index) => text.slice(0, index).split('\n').length; + +// Calls whose first argument is the destination noun. +const NOUN_FIRST = + /\b(runImmediateNavigation|_runExplicitNavigation|runOrientation|_beginDeferredNavigation|cockpitRefusal|runNavigation|navigation\??\.(?:run|begin))\s*\(\s*([^,)]*)/g; +// runManagedVoiceNavigation(styleManager, noun, action, …) in gevActions.js. +const VOICE_NOUN = /\brunManagedVoiceNavigation\s*\(\s*[\w.?]+\s*,\s*([^,)]*)/g; +// Parameter defaults: `noun = 'location'`. +const NOUN_DEFAULT = /\bnoun\s*=\s*'([^']+)'/g; +// A local noun picked by a condition: `const noun = … ? 'cyclone' : 'weather'`. +const NOUN_CHOICE = /\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g; + +function trackableKinds() { + const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8'); + const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/); + assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js'); + return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]); +} + +// Non-literal first arguments the test knows how to expand. Anything else is +// a new dynamic noun: list its values here. +const DYNAMIC_NOUNS = new Map([ + ['family.kind', trackableKinds], + [ + "detail?.kind || 'target'", + () => [...Object.keys(WORLD_FOCUS_FRAMING), 'target'], + ], +]); +// Wrappers that pass their own `noun` parameter (or all arguments) on. +const PASS_THROUGH = new Set(['noun', '...args', '...arguments']); + +function collectNouns() { + const nouns = new Map(); + const unknown = []; + const add = (noun, where) => { + if (!nouns.has(noun)) nouns.set(noun, where); + }; + for (const file of sourceFiles(SRC)) { + const text = readFileSync(file, 'utf8'); + const name = relative(SRC, file); + const take = (raw, where, { closures = false } = {}) => { + const arg = raw.trim(); + const literal = arg.match(/^'([^']*)'$/); + if (literal) return add(literal[1], where); + // A wrapper's own parameter; its default is collected by NOUN_DEFAULT. + if (PASS_THROUGH.has(arg) || /^noun\s*=\s*'[^']+'$/.test(arg)) return; + if (DYNAMIC_NOUNS.has(arg)) { + for (const noun of DYNAMIC_NOUNS.get(arg)()) add(noun, where); + return; + } + // The layer-side `runNavigation(fly)` seams take only the flight; their + // noun is bound where the seam is built. + if (closures) return; + unknown.push(`${where}: ${arg}`); + }; + for (const m of text.matchAll(NOUN_FIRST)) + take(m[2], `${name}:${lineOf(text, m.index)}`, { + closures: m[1] === 'runNavigation', + }); + for (const m of text.matchAll(VOICE_NOUN)) + take(m[1], `${name}:${lineOf(text, m.index)}`); + for (const m of text.matchAll(NOUN_DEFAULT)) + add(m[1], `${name}:${lineOf(text, m.index)}`); + for (const m of text.matchAll(NOUN_CHOICE)) { + add(m[1], `${name}:${lineOf(text, m.index)}`); + add(m[2], `${name}:${lineOf(text, m.index)}`); + } + } + return { nouns, unknown }; +} + +test('every noun the callers pass has its own Cockpit refusal', () => { + const { nouns, unknown } = collectNouns(); + assert.deepEqual(unknown, [], 'a new dynamic noun: add it to DYNAMIC_NOUNS'); + // The scan still sees the known callers (a guard against a broken regex). + for (const noun of ['location', 'camera', 'photo', 'shared view', 'file']) + assert.ok(nouns.has(noun), `found ${noun}`); + for (const noun of ['scene', 'route', 'weather', 'cyclone', 'vessel', 'fire']) + assert.ok(nouns.has(noun), `found ${noun}`); + for (const noun of ['frame', 'aircraft', 'satellite', 'target']) + assert.ok(nouns.has(noun), `found ${noun}`); + + const missing = [...nouns] + .filter(([noun]) => !COCKPIT_REFUSAL_NOUNS.includes(noun)) + .map(([noun, where]) => `${noun} (${where})`); + assert.deepEqual(missing, [], 'add these nouns to COCKPIT_REFUSALS'); + + setLocale('es'); + for (const noun of nouns.keys()) { + const shown = cockpitToast(noun); + assert.notEqual( + shown, + `Sal de la cabina para volar a ${noun}`, + `${noun}: the template with the English id`, + ); + } +}); diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 5b31b6246..762e0024b 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -713,3 +713,24 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color assert.ok(!gpx.children[0].classList.contains('own-styles')); assert.ok(gpx.children[0].style.background); }); + +// N-08: tn() replaces values.count with the raw number, so the +// formatNumber(...) passed by the import list is lost: "12345 map objects" +// (main: "12,345"). Append to src/ui/geoFileImport.test.mjs (uses its setup(), +// file(), rows() helpers). +test('the map-object count in a row title keeps digit grouping', async () => { + const t = setup({ + load: async (f) => ({ + dataSource: {}, + name: f.name, + format: 'geojson', + entityCount: 12345, + removed: 0, + revoke() {}, + }), + }); + await t.tool.importFiles([file('big.geojson')]); + const [row] = rows(t.els); + assert.match(row.children[1].title, /12,345 map objects/); + t.tool.destroy(); +}); diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 3f3f953bf..65cd910ac 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -39,6 +39,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'weather-satellite': msg('Satellite clouds'), 'weather-lightning': msg('Lightning density'), 'weather-cyclones': msg('Cyclone advisories'), + 'air-quality': msg('Air quality'), directions: msg('Directions'), 'alpr-cameras': msg('ALPR Cameras'), }); @@ -116,6 +117,8 @@ const SOURCE_LABEL_KEYS = new Map( msg('OSM routing'), msg('Mapped camera locations'), msg('OpenStreetMap · community mapped'), + msg('Decoder feeds'), + msg('WebUSB + decoder feeds'), ].map((key) => [key, key]), ); diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs new file mode 100644 index 000000000..bfb3c7268 --- /dev/null +++ b/src/ui/layerNames.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { APPLICATION_LAYER_METADATA } from '../app/constructCatalog.js'; +import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS } from './layerNames.js'; + +// Layers whose module name is a proper name and stays as it is in every +// language: the Bhote Koshi pair belong to a scripted scene. +const UNTRANSLATED_NAMES = new Set(['bhote-koshi-2026', 'bhote-koshi-locator']); + +const catalogIds = APPLICATION_LAYER_METADATA.map((layer) => layer.id); + +test('every catalog layer has a translatable name', () => { + const missing = catalogIds.filter( + (id) => + !UNTRANSLATED_NAMES.has(id) && + !Object.hasOwn(LAYER_NAME_KEYS, id) && + !Object.hasOwn(PANEL_LABEL_KEYS, id), + ); + assert.deepEqual( + missing, + [], + 'add the layer to LAYER_NAME_KEYS in src/ui/layerNames.js', + ); +}); + +test('the name tables list only catalog layers', () => { + const unknown = [ + ...Object.keys(LAYER_NAME_KEYS), + ...Object.keys(PANEL_LABEL_KEYS), + ...UNTRANSLATED_NAMES, + ].filter((id) => !catalogIds.includes(id)); + assert.deepEqual(unknown, []); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 5f1cff5fe..b83eef6c1 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -19,6 +19,7 @@ import { getLocale, msg, t, + tn, translateKnown, } from '../i18n/index.js'; @@ -187,6 +188,27 @@ const FEED_ERROR_PATTERNS = [ /^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 }), @@ -222,9 +244,8 @@ const FEED_ERROR_PATTERNS = [ ], ]; -/** A layer's error text in the UI language (see FEED_ERRORS). */ -export function translateFeedError(text) { - if (typeof text !== 'string') return text; +/** 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; @@ -232,17 +253,88 @@ export function translateFeedError(text) { const match = pattern.exec(text); if (match) return format(match); } - return text; + return null; +} + +/** + * A layer's error text in the UI language (see FEED_ERRORS). An error made of + * several statuses joined by " · " (Local ADS-B) translates piece by piece. + */ +export function translateFeedError(text) { + if (typeof text !== 'string') return text; + const whole = feedErrorText(text); + if (whole !== null) return whole; + if (!text.includes(ROW_TEXT_SEPARATOR)) return text; + return text + .split(ROW_TEXT_SEPARATOR) + .map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece)) + .join(ROW_TEXT_SEPARATOR); } const ROW_TEXT_SEPARATOR = ' · '; +// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale"). +const FEED_PROBLEM_FORMS = Object.freeze({ + stale: (count, names) => + tn(count, 'feed {names} stale', 'feeds {names} stale', { names }), + unreachable: (count, names) => + tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', { + names, + }), + invalid: (count, names) => + tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }), +}); + +// Working texts with a number or a name in them arrive as English data, one +// piece of a " · " joined line each: transit (transit/queries.js) and Local +// ADS-B (localAdsb/status.js). Keep these in step with the producers. +const STATUS_PIECE_PATTERNS = [ + [/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })], + [ + /^(\d+) heard$/, + ([, heard]) => + tn(Number(heard), '{count} heard', '{count} heard', { + count: formatNumber(Number(heard)), + }), + ], + [ + /^(USB )?(\d+(?:\.\d+)?) msg\/s$/, + ([, usb, rate]) => + usb + ? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) }) + : t('{rate} msg/s', { rate: formatNumber(Number(rate)) }), + ], + [ + /^(\d+) feeds? live$/, + ([, live]) => + tn(Number(live), '{count} feed live', '{count} feeds live', { + count: formatNumber(Number(live)), + }), + ], + [ + /^feeds? (.+) (stale|unreachable|invalid)$/, + ([, names, status]) => + FEED_PROBLEM_FORMS[status](names.split(', ').length, names), + ], +]; + +/** One piece of a status line in the UI language, or as it came. */ +function translateStatusPiece(piece) { + const known = translateKnown(piece); + if (known !== piece) return known; + for (const [pattern, format] of STATUS_PIECE_PATTERNS) { + const match = pattern.exec(piece); + if (match) return format(match); + } + return piece; +} + /** A loading label is English data, at times several joined by " · ". */ function translateLoadingLabel(label) { return label .trim() .split(ROW_TEXT_SEPARATOR) - .map(translateKnown) + .map(translateStatusPiece) .join(ROW_TEXT_SEPARATOR); } // A short Latin code joined by hyphens. Longer runs (a URL, a token in an diff --git a/src/ui/layerStatusLocale.test.mjs b/src/ui/layerStatusLocale.test.mjs new file mode 100644 index 000000000..2957e9334 --- /dev/null +++ b/src/ui/layerStatusLocale.test.mjs @@ -0,0 +1,90 @@ +// N-03: layer status texts produced without msg()/catalog entries (GNSS, +// Transit, CelesTrak, AQHI) stay English in the es/ru DATA LAYERS row. +// Place: src/ui/layerStatusLocale.test.mjs +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from '../i18n/index.js'; +import { LayerPanel } from './layerPanel.js'; + +const row = (id, stats) => + LayerPanel.prototype._buildMetaText({ id, enabled: true, stats }); +const now = Date.now(); + +// [layer id, stats, English fragment that must not reach the row] +const cases = [ + [ + 'gnss-integrity', + { + count: 0, + error: 'GNSS feed rate limited (HTTP 429)', + source: 'GNSS', + lastUpdate: now, + }, + 'rate limited', + ], + [ + 'gnss-integrity', + { + count: 0, + error: 'GNSS feed unavailable (HTTP 503)', + source: 'GNSS', + lastUpdate: now, + }, + 'feed unavailable', + ], + [ + 'transit', + { + count: 0, + error: 'GO Transit feed unavailable', + source: 'GTFS-RT', + lastUpdate: now, + }, + 'feed unavailable', + ], + [ + 'transit', + { + count: 0, + loading: true, + loadingLabel: 'Loading GO Transit, Metro Transit', + source: 'GTFS-RT', + }, + 'Loading', + ], + [ + 'satellites', + { + count: 10, + error: '1 CelesTrak group unavailable', + source: 'CelesTrak', + lastUpdate: now, + }, + 'group unavailable', + ], + [ + 'air-quality', + { + count: 0, + error: 'ECCC request timed out', + source: 'ECCC', + lastUpdate: now, + }, + 'request timed out', + ], +]; + +for (const locale of ['es', 'ru']) { + test(`${locale}: layer status texts are translated in the row`, () => { + setLocale(locale); + try { + const english = cases + .map(([id, stats, marker]) => [id, row(id, stats), marker]) + .filter(([, text, marker]) => text.includes(marker)) + .map(([id, text]) => `${id}: ${text}`); + assert.deepEqual(english, []); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 5ec14d4f2..7d260d985 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -79,9 +79,9 @@ html[lang='es'] .cctv-controls { } /* A translated label longer than its button wraps inside the button instead - of running past the panel edge. English labels fit on one line, so the - English layout is unchanged. */ -.cctv-controls .scene-btn { + of running past the panel edge. English keeps its one-line buttons (unguarded, + this rule broke AUTO HOP OFF onto two lines). */ +html:not([lang='en']) .cctv-controls .scene-btn { white-space: normal; overflow-wrap: break-word; } diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 7d52adf0d..04a1efa77 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1836,9 +1836,11 @@ display: none; } + /* Sits above the language picker row in the bottom-right corner + (locale-picker.css: 0.9rem inset + one ~2rem button + a gap). */ #intel-hud[data-variant='cyber'] .hud-bottom-right { right: 28px; - bottom: 28px; + bottom: 3.5rem; } } @@ -2735,6 +2737,9 @@ :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout { right: 30px; + /* Clears the language picker row it shares the corner with, as in the + other themes (locale-picker.css). */ + bottom: 3.5rem; } :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help { diff --git a/src/ui/styles/english-css-parity.test.mjs b/src/ui/styles/english-css-parity.test.mjs index 4f24e1177..3ed1d2642 100644 --- a/src/ui/styles/english-css-parity.test.mjs +++ b/src/ui/styles/english-css-parity.test.mjs @@ -210,3 +210,10 @@ test('K-44: Recent Imagery readout keeps its English layout', () => { assert.equal(declaration(swap, 'width'), '40px'); assert.equal(declaration(swap, 'min-width'), undefined); }); + +test('N-13: CCTV control buttons stay on one line in English', () => { + assert.equal( + unguardedRules(css('cctv.css'), '.cctv-controls .scene-btn').length, + 0, + ); +}); diff --git a/src/voice/analystTransitNote.test.mjs b/src/voice/analystTransitNote.test.mjs new file mode 100644 index 000000000..aa5bf0b16 --- /dev/null +++ b/src/voice/analystTransitNote.test.mjs @@ -0,0 +1,72 @@ +// N-07: the analyst card shows a layer's "empty" note from getStats().coverage +// as is; Transit's notes are English template strings with no catalog entry, +// so the es/ru card note stays English. Place: src/voice/analystTransitNote.test.mjs +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 viewFor(result) { + const shaped = attachVoiceResult('analyst_query', result, {}); + return voiceCardView( + [ + { + type: 'action-call', + callId: 'c1', + name: 'analyst_query', + arguments: {}, + }, + { + type: 'action-result', + callId: 'c1', + name: 'analyst_query', + result: shaped, + }, + ].reduce(reduceVoiceCard, initialVoiceCardState()), + ); +} + +// The caveat analystEngine.js:1026 builds from getEmptyNote (gevActions.js:4740) +// for Transit (transit/queries.js:249–251). +const notes = [ + 'transit: Fly below 3,000 km to a covered region', + 'transit: No feed here yet · 12 regions available', +]; + +for (const locale of ['es', 'ru']) { + test(`${locale}: Transit's empty note is translated on the analyst card`, () => { + setLocale(locale); + try { + for (const caveat of notes) { + const view = viewFor({ + ok: true, + count: 0, + complete: true, + scopeLabel: 'in view', + feedState: 'nominal', + unanswered: [], + coverage: { + layersQueried: [ + { layerKey: 'transit', source: 'GTFS-RT', status: 'empty' }, + ], + }, + display: { scope: 'in view', caveat }, + items: [], + }); + const text = JSON.stringify(view.result); + assert.doesNotMatch( + text, + /Fly below|No feed here yet|regions available/, + text, + ); + } + } finally { + setLocale('en'); + } + }); +} diff --git a/src/voice/planStepPresetIds.i18n.test.mjs b/src/voice/planStepPresetIds.i18n.test.mjs new file mode 100644 index 000000000..8da47b740 --- /dev/null +++ b/src/voice/planStepPresetIds.i18n.test.mjs @@ -0,0 +1,67 @@ +// N-05: plan steps for preset ids from the tool schema (sf, nyc, dc) read +// "Sf" / "Nyc" / "Dc" in every language; 'tokyo' translates. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from '../i18n/index.js'; +import { CITY_POIS } from '../locations.js'; +import { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; +import { narrationLabel, planStepLabel } from './speech.js'; + +test.afterEach(() => setLocale('en')); + +test('es: preset location ids show the city name', () => { + setLocale('es'); + assert.equal( + planStepLabel('fly_to_location', { locationId: 'tokyo' }), + 'Volar a Tokio', + ); + for (const id of ['sf', 'nyc', 'dc']) { + const label = planStepLabel('fly_to_location', { locationId: id }); + assert.doesNotMatch(label, /\b(Sf|Nyc|Dc)$/, label); + } + assert.equal( + planStepLabel('fly_to_location', { locationId: 'nyc' }), + 'Volar a Nueva York', + ); +}); + +test('ru: a preset id in the nearest-aircraft step names the city', () => { + setLocale('ru'); + assert.match( + planStepLabel('select_nearest_aircraft', { locationId: 'dc' }), + /Вашингтон/, + ); +}); + +test('en: every preset id reads as its CITY_POIS name', () => { + setLocale('en'); + for (const [id, city] of Object.entries(CITY_POIS)) { + assert.equal( + planStepLabel('fly_to_location', { locationId: id }), + `Fly to ${city.name}`, + id, + ); + assert.equal( + narrationLabel('fly_to_location', { locationId: id }), + city.name, + ); + } + // Other places are shown as given. + assert.equal( + planStepLabel('fly_to_location', { query: 'Ulaanbaatar' }), + 'Fly to Ulaanbaatar', + ); +}); + +test('every locationId the voice tools accept is a preset city', () => { + const ids = new Set(); + const visit = (node) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node.locationId?.enum)) + node.locationId.enum.forEach((id) => ids.add(id)); + Object.values(node).forEach(visit); + }; + visit(GEV_ACTION_SCHEMAS); + assert.ok(ids.size > 0, 'found the locationId enums'); + for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id); +}); diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 427341a02..779578434 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -135,6 +135,8 @@ function shownCount(count, noun) { return tn(count, '{n} datacenters', '{n} datacenters', values); case 'dams': return tn(count, '{n} dams', '{n} dams', values); + case 'air quality': + return tn(count, '{n} air quality', '{n} air quality', values); case 'results': return tn(count, '{n} results', '{n} results', values); default: @@ -328,6 +330,27 @@ const NOTE_PATTERNS = [ 'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory', )) + period, ], + // Transit's empty note when no feed covers the view (transit/queries.js); + // the model gets the English. + [ + /^Fly below ([\d,]+) km to a covered region$/, + ([, km]) => + t('Fly below {km} km to a covered region', { + km: formatNumber(Number(km.replace(/,/g, ''))), + }), + ], + [ + /^No feed here yet · ([\d,]+) regions available$/, + ([, regions]) => { + const count = Number(regions.replace(/,/g, '')); + return tn( + count, + 'No feed here yet · {n} region available', + 'No feed here yet · {n} regions available', + { n: formatNumber(count) }, + ); + }, + ], [ /^([a-z0-9-]+) (off|loading|error)$/, ([, key, status]) => diff --git a/src/voice/speech.js b/src/voice/speech.js index 6c5bd5e66..1085e11e8 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -344,13 +344,38 @@ function shownPair(items) { : items[0] || ''; } -/** Ids such as `austin` or `local-firms` read as names on screen. */ -// A place as the card shows it: a preset city in the interface language, -// whether the call named it ("Tokyo") or gave its id ("tokyo"). -function shownPlace(value, max) { - return localizePlaceName(displayName(value, max)); +/** + * 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) { + const id = typeof value === 'string' ? value.trim() : ''; + return Object.hasOwn(PRESET_PLACE_NAMES, id) ? PRESET_PLACE_NAMES[id] : value; } +// A place as the card shows it: a preset city in the interface language, +// whether the call named it ("Tokyo") or gave its id ("tokyo", "nyc"). +function shownPlace(value, max) { + return localizePlaceName(displayName(presetPlaceName(value), max)); +} + +/** Ids such as `austin` or `local-firms` read as names on screen. */ + function displayName(value, max) { const label = spokenLabel(value, max); return /^[a-z0-9-]+$/.test(label) @@ -915,9 +940,15 @@ export function narrationLabel(name, args = {}) { case 'annotate_map': return annotationTargets(args)[0] || ''; case 'fly_to_location': - return displayName(args.query || args.locationId || '', 32); + return displayName( + presetPlaceName(args.query || args.locationId || ''), + 32, + ); case 'select_nearest_aircraft': - return displayName(args.locationQuery || args.locationId || '', 32); + return displayName( + presetPlaceName(args.locationQuery || args.locationId || ''), + 32, + ); default: return ''; }