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 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-16 16:39:25 +04:00
1 parent fada8b161b
commit 3adce33cc6
43 files changed
+1350 -97

No files matched your search

+5 -1
View File
@@ -3821,7 +3821,11 @@
"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/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"] "external": ["cesium"]
}, },
+5 -3
View File
@@ -35,7 +35,7 @@ import {
DEFAULT_HUD_LAYOUT, DEFAULT_HUD_LAYOUT,
normalizeHudLayout, normalizeHudLayout,
} from './hudLayouts.js'; } 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 { unitLabel } from './ui/unitLabels.js';
import { localizeLayerName } from './ui/layerPanel.js'; import { localizeLayerName } from './ui/layerPanel.js';
import { localizePlaceName } from './ui/placeNames.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). */ /** The summary's locality line in the active language (see hudLocality.js). */
const LOCALIZED_LOCALITY = Object.freeze({ 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 }) => near: ({ poi, city, distKm }) =>
t('NEAR {poi} ({city}) {distance}', { t('NEAR {poi} ({city}) {distance}', {
poi, poi: poi.toLocaleUpperCase(getLocale()),
city: localizePlaceName(city), city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
distance: hudQuantity(Math.round(distKm), 'km'), distance: hudQuantity(Math.round(distKm), 'km'),
}), }),
sector: ({ latDeg, lonDeg }) => sector: ({ latDeg, lonDeg }) =>
+6 -4
View File
@@ -29,7 +29,7 @@ export const NEAR_POI_MAX_KM = 150;
*/ */
const ENGLISH_LOCALITY = Object.freeze({ const ENGLISH_LOCALITY = Object.freeze({
near: ({ poi, city, distKm }) => near: ({ poi, city, distKm }) =>
`NEAR ${poi} (${city}) ${Math.round(distKm)}KM`, `NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`,
sector: ({ latDeg, lonDeg }) => sector: ({ latDeg, lonDeg }) =>
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`, `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} latDeg Camera latitude in decimal degrees.
* @param {number} lonDeg Camera longitude in decimal degrees. * @param {number} lonDeg Camera longitude in decimal degrees.
* @param {{near: Function, sector: Function}} [format] Builds the line from * @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 <POI> (<CITY>) <N>KM` within the bound (inclusive), * @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`. * otherwise `SECTOR <lat> <lon>`.
*/ */
@@ -67,8 +69,8 @@ export function composeLocalityTag(
const distKm = Number(nearest?.distKm); const distKm = Number(nearest?.distKm);
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) { if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
return format.near({ return format.near({
poi: String(nearest.poi).toUpperCase(), poi: String(nearest.poi),
city: String(nearest.city).toUpperCase(), city: String(nearest.city),
distKm, distKm,
}); });
} }
+3 -1
View File
@@ -122,6 +122,8 @@ test('a missing or malformed nearest POI never crashes the summary', () => {
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /); 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', () => { 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}`,
@@ -129,7 +131,7 @@ test('a formatter builds the line from the same parts', () => {
}; };
assert.equal( assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format), 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( assert.equal(
composeLocalityTag(null, 21.3, -157.8, format), composeLocalityTag(null, 21.3, -157.8, format),
+26
View File
@@ -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');
}
});
+12
View File
@@ -234,3 +234,15 @@ test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', (
['reload'], ['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 объект на карте');
});
+5 -3
View File
@@ -94,11 +94,13 @@ export function t(text, values) {
/** /**
* Translate a count-dependent message. The catalog entry for `other` holds * Translate a count-dependent message. The catalog entry for `other` holds
* one form per Intl.PluralRules category of the locale ({ one, few, many, other } * 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 {number} count
* @param {string} one English singular, e.g. '{count} camera' * @param {string} one English singular, e.g. '{count} camera'
* @param {string} other English plural, e.g. '{count} cameras' * @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} * @returns {string}
*/ */
export function tn(count, one, other, values) { export function tn(count, one, other, values) {
@@ -111,7 +113,7 @@ export function tn(count, one, other, values) {
} else { } else {
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other; pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
} }
return interpolate(pattern, { ...values, count }); return interpolate(pattern, { count, ...values });
} }
/** /**
+82 -1
View File
@@ -2796,5 +2796,86 @@
}, },
"FOV {value}° (DATASHEET)": "FOV {value}° (FICHA TÉCNICA)", "FOV {value}° (DATASHEET)": "FOV {value}° (FICHA TÉCNICA)",
"SPEC FOV {span}° (CAPABILITY)": "FOV DE FICHA {span}° (CAPACIDAD)", "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"
} }
+91 -1
View File
@@ -2854,5 +2854,95 @@
}, },
"FOV {value}° (DATASHEET)": "ОБЗОР {value}° (ПО ПАСПОРТУ)", "FOV {value}° (DATASHEET)": "ОБЗОР {value}° (ПО ПАСПОРТУ)",
"SPEC FOV {span}° (CAPABILITY)": "ОБЗОР ПО ПАСПОРТУ {span}° (ВОЗМОЖНОСТИ)", "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": "Выйдите из кабины, чтобы показать контакты в кадре"
} }
+2
View File
@@ -88,6 +88,7 @@
"Soul", "Soul",
"Techno", "Techno",
"Trance", "Trance",
"USB {rate} msg/s",
"VISUAL", "VISUAL",
"VS", "VS",
"WHOT/BHOT", "WHOT/BHOT",
@@ -107,6 +108,7 @@
"panel", "panel",
"view band|GLOBAL", "view band|GLOBAL",
"voice|ERROR", "voice|ERROR",
"{rate} msg/s",
"{speed} kn", "{speed} kn",
"{type} SPD: {speed} HDG: {heading}", "{type} SPD: {speed} HDG: {heading}",
"{value} KM", "{value} KM",
+16 -1
View File
@@ -20,7 +20,7 @@ import {
* the chip and the dialog are removed outright. * 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 * Chip label — pure, exported for tests. The exact English values are pinned
@@ -386,9 +386,24 @@ export async function initKeySetup({
let disposed = false; let disposed = false;
let disposeControls = () => {}; let disposeControls = () => {};
let disposePlacement = () => {}; 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 = () => { const destroy = () => {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
unregisterUnsavedWork();
lifetime.abort(); lifetime.abort();
signal?.removeEventListener('abort', destroy); signal?.removeEventListener('abort', destroy);
disposeControls(); disposeControls();
+145 -26
View File
@@ -9,25 +9,42 @@ import {
} from './keySetup.js'; } from './keySetup.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 = { prepend: (node) => { node.parentNode = home; } }; const home = {
prepend: (node) => {
node.parentNode = home;
},
};
const chip = { parentNode: home, hidden: true }; const chip = { parentNode: home, hidden: true };
let moves = 0; let moves = 0;
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } }; const toolbar = {
append: (node) => {
moves++;
node.parentNode = toolbar;
},
};
const theme = { dataset: { uiTheme: 'tactical' } }; const theme = { dataset: { uiTheme: 'tactical' } };
let notify, disconnected = false; let notify,
disconnected = false;
class Observer { class Observer {
constructor(callback) { notify = callback; } constructor(callback) {
notify = callback;
}
observe(target, options) { observe(target, options) {
assert.equal(target, theme); assert.equal(target, theme);
assert.deepEqual(options.attributeFilter, ['data-ui-theme']); assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
} }
disconnect() { disconnected = true; } disconnect() {
disconnected = true;
}
} }
const dispose = bindKeySetupPlacement({ const dispose = bindKeySetupPlacement(
documentElement: theme, {
getElementById: () => toolbar, documentElement: theme,
defaultView: { MutationObserver: Observer }, getElementById: () => toolbar,
}, chip); defaultView: { MutationObserver: Observer },
},
chip,
);
assert.equal(chip.parentNode, home); assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) { for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant; 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', () => { 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(
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING'); 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({ 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', () => { 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 () => { 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(waitForChatGptOAuth({ await assert.rejects(
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; }, waitForChatGptOAuth({
sleep: () => assert.fail('failed login must stop polling'), fetchImpl: async () => {
}), { message: result.payload.error }); 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); 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 () => { 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 = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } }; const poll = {
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false); 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); assert.equal(checks, 2);
const controller = new AbortController(); const controller = new AbortController();
controller.abort(); 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'); 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 controller = new AbortController();
const available = await waitForChatGptOAuth({ const available = await waitForChatGptOAuth({
signal: controller.signal, 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'), 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('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); 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');
@@ -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'); 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(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(''), null);
assert.equal(stripKeylessBasemapFromHash(undefined), 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; let requestSignal;
const controller = new AbortController(); const controller = new AbortController();
const pending = initKeySetup({ const pending = initKeySetup({
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root }, documentRef: {
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) => { resolveResponse = resolve; }); return new Promise((resolve) => {
resolveResponse = resolve;
});
}, },
}); });
controller.abort(); 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: [] }) }); 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 () => {
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(), []);
});
@@ -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);
});
}
+17 -10
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js'; import { msg, t, tn, translateKnown } from '../../i18n/index.js';
import { import {
AIR_QUALITY_BANDS, AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID, AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -110,7 +110,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
return true; return true;
} catch (error) { } catch (error) {
if (request.signal.aborted || _request !== request) return false; 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); console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify(); notify();
return false; return false;
@@ -215,25 +215,32 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
(max, reading) => Math.max(max, reading.observedMs || 0), (max, reading) => Math.max(max, reading.observedMs || 0),
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 { return {
readout: true, readout: true,
summary: { summary: {
label: provider.label, label: t(provider.label),
coverage: provider.coverage, coverage: t(provider.coverage),
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
? _lastError ? translateKnown(_lastError)
: _count === 0 : _count === 0
? _lastUpdate === null ? _lastUpdate === null
? 'No readings available' ? t('No readings available')
: 'No current readings' : t('No current readings')
: `${_count} stations reporting`, : tn(
_count,
'{count} station reporting',
'{count} stations reporting',
),
units: provider.scale, units: provider.scale,
}, },
legend: AIR_QUALITY_BANDS.map((band) => ({ legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color, color: band.color,
label: band.label, label: t(band.label),
})), })),
legendCategorical: true, legendCategorical: true,
list: { list: {
@@ -242,7 +249,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}`, text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`,
accent: reading.color, accent: reading.color,
})), })),
}, },
+29 -8
View File
@@ -15,14 +15,36 @@
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a * Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against. * 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. */ /** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([ export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: msg('Low'), color: '#4cc9f0' }), Object.freeze({
Object.freeze({ id: 'moderate', label: msg('Moderate'), color: '#ffd43b' }), id: 'low',
Object.freeze({ id: 'high', label: msg('High'), color: '#ff6b35' }), label: msg('Low'),
Object.freeze({ id: 'very-high', label: msg('Very High'), color: '#c1121f' }), 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])); 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', scale: 'AQHI',
label: msg('Air quality'), label: msg('Air quality'),
network: 'Environment and Climate Change Canada', 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. */ /** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([ bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }), Object.freeze({ id: 'low', max: 3 }),
@@ -162,8 +184,7 @@ export function valueLabel(providerId, value) {
/** Plain-language risk phrase for a band id. */ /** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) { export function bandRiskText(bandId) {
const band = airQualityBandById(bandId); return airQualityBandById(bandId)?.risk ?? null;
return band ? `${band.label} health risk` : null;
} }
/** /**
+10 -4
View File
@@ -1,6 +1,10 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.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 COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`; const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`; const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
const abort = () => controller.abort(signal.reason); const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true }); signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout( const timer = setTimeout(
() => controller.abort(new Error('ECCC request timed out')), () => controller.abort(new Error(msg('ECCC request timed out'))),
timeoutMs, timeoutMs,
); );
try { try {
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
signal, signal,
); );
const next = normalizeAqhiStations(payload); 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. // An empty refresh must not wipe a good catalog.
if (next.size > 0) { if (next.size > 0) {
stations = next; stations = next;
stationsFetchedAt = clock; 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( const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`, `${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
signal, signal,
); );
const readings = normalizeAqhiObservations(payload, stations, clock); 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; return readings;
}, },
}; };
+1 -1
View File
@@ -245,7 +245,7 @@ export function createOverpassAlprSource({
// The shared proxy already rejects query errors. Validate here too so a // The shared proxy already rejects query errors. Validate here too so a
// malformed or partial response never becomes an authoritative empty map. // malformed or partial response never becomes an authoritative empty map.
if (!Array.isArray(payload?.elements) || payload.remark) { 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 { return {
records: [ records: [
+9 -1
View File
@@ -31,6 +31,14 @@ 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;
@@ -377,7 +385,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
? ` · ${translateKnown(_lastError)} · ${ ? ` · ${shownSourceError(_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),
+10 -4
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js'; 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) { function retryDelay(raw, nowMs) {
if (!raw) return null; if (!raw) return null;
const seconds = Number(raw); const seconds = Number(raw);
@@ -21,7 +25,7 @@ export function createAdsbGnssSource({
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) { async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted(); signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) 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({ const params = new URLSearchParams({
lat: String(Math.round(latitude)), lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)), lon: String(Math.round(longitude)),
@@ -37,8 +41,10 @@ export function createAdsbGnssSource({
); );
throw Object.assign( throw Object.assign(
new Error( new Error(
// The status code form is matched by FEED_ERROR_PATTERNS
// (ui/layerPanel.js) and gnss/index.js.
limited limited
? '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})`,
), ),
{ {
@@ -55,12 +61,12 @@ export function createAdsbGnssSource({
); );
signal?.throwIfAborted(); signal?.throwIfAborted();
if (!Array.isArray(payload?.rows)) 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 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, // the rolling window; an undated snapshot could only be treated as new,
// so it is refused. // so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0) 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 { return {
rows: payload.rows, rows: payload.rows,
ageMs: payload.ageMs, ageMs: payload.ageMs,
@@ -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();
}
}
});
}
+5 -1
View File
@@ -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. */ /** Read launch records and their optional active-orbit catalog with explicit cancellation. */
export function createLaunchSource({ export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
@@ -10,7 +14,7 @@ export function createLaunchSource({
const payload = await response.json(); const payload = await response.json();
signal?.throwIfAborted(); signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results)) if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot'); throw new Error(msg('Malformed launch snapshot'));
return { return {
payload, payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR', stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
+9 -7
View File
@@ -1,7 +1,9 @@
import { localReceiverFeedName } from './feedNames.js'; import { localReceiverFeedName } from './feedNames.js';
import { LAYER_SOURCE } from './policy.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; 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. * WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
*/ */
export const FEED_SOURCE = 'Decoder feeds'; export const FEED_SOURCE = msg('Decoder feeds');
export const COMBINED_SOURCE = 'WebUSB + decoder feeds'; export const COMBINED_SOURCE = msg('WebUSB + decoder feeds');
function plural(count, one, many) { function plural(count, one, many) {
return count === 1 ? one : many; return count === 1 ? one : many;
@@ -51,13 +53,13 @@ function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error') if (receiver.status === 'error')
return { status: 'error', error: receiver.message }; return { status: 'error', error: receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning') if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: 'opening receiver' }; return { loading: true, loadingLabel: msg('opening receiver') };
if ( if (
feedState?.polling && feedState?.polling &&
feedState.configured === null && feedState.configured === null &&
!receiver.connected !receiver.connected
) )
return { loading: true, loadingLabel: 'checking decoder feeds' }; return { loading: true, loadingLabel: msg('checking decoder feeds') };
if (!receiver.webUsbSupported) if (!receiver.webUsbSupported)
return { return {
status: 'idle', status: 'idle',
@@ -72,7 +74,7 @@ function webUsbStatus(receiver, heard, feedState) {
return { status: 'idle', statusMessage: msg('receiver is in FM mode') }; return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
const rate = Number.isFinite(receiver.messagesPerSecond) const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s` ? `${receiver.messagesPerSecond} msg/s`
: 'listening'; : msg('listening');
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` }; 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 source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
const live = feedState.feeds.filter((feed) => feed.status === 'live'); const live = feedState.feeds.filter((feed) => feed.status === 'live');
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean); 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 = const rate =
usbActive && usbActive &&
receiver.status !== 'error' && receiver.status !== 'error' &&
@@ -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');
}
});
}
+5 -1
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js'; 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. */ /** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
export function createWfigsPerimeterSource({ export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args), fetchImpl = (...args) => globalThis.fetch(...args),
@@ -16,7 +20,7 @@ export function createWfigsPerimeterSource({
); );
signal?.throwIfAborted(); signal?.throwIfAborted();
if (!Array.isArray(payload?.rows)) if (!Array.isArray(payload?.rows))
throw new Error('Malformed perimeter snapshot'); throw new Error(msg('Malformed perimeter snapshot'));
return payload.rows; return payload.rows;
}, },
}; };
+2 -1
View File
@@ -71,7 +71,8 @@ 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. // reduced catalog as a fully healthy refresh. English data; the panel
// shows it through FEED_ERROR_PATTERNS (ui/layerPanel.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;
+2
View File
@@ -476,6 +476,8 @@ export function createIngestion({ state, services, parts, source }) {
`[Data:Transit] ${feed.id} poll failed:`, `[Data:Transit] ${feed.id} poll failed:`,
error?.message || error, error?.message || error,
); );
// English data; the panel shows it through FEED_ERROR_PATTERNS
// (ui/layerPanel.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
+5 -1
View File
@@ -246,9 +246,11 @@ export function createQueries({ state, parts }) {
error: null, error: null,
source: 'GTFS-RT', source: 'GTFS-RT',
status: 'zoom-in', 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 coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` ? `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 // 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, 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
// (ui/layerPanel.js).
loadingLabel: loadingLabel:
health.loading && count === 0 health.loading && count === 0
? `Loading ${active.map((feed) => feed.name).join(', ')}` ? `Loading ${active.map((feed) => feed.name).join(', ')}`
@@ -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');
}
});
}
+14 -1
View File
@@ -14,6 +14,19 @@ import {
windTrailColor, windTrailColor,
} from './fields.js'; } 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_HIGH_METERS = 1_200_000;
const WIND_FIELD_FADE_LOW_METERS = 200_000; const WIND_FIELD_FADE_LOW_METERS = 200_000;
const WIND_FIELD_LOG_RANGE = Math.log( const WIND_FIELD_LOG_RANGE = Math.log(
@@ -337,7 +350,7 @@ export function createWindRendering({
} }
const raster = createFieldRaster(snapshot, overlay); const raster = createFieldRaster(snapshot, overlay);
if (!raster) { if (!raster) {
imageryError = `${overlay} field unavailable`; imageryError = fieldUnavailableText(overlay);
return; return;
} }
if ( if (
+12
View File
@@ -14,6 +14,8 @@ 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'…); a noun without its own
// sentence falls back to the template with the noun as given. // 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({ const COCKPIT_REFUSALS = Object.freeze({
camera: msg('Exit cockpit to fly to a camera'), camera: msg('Exit cockpit to fly to a camera'),
location: msg('Exit cockpit to fly to a location'), 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. // «View coverage» button); the cyclone layer flies to the selected storm.
weather: msg('Exit cockpit to view weather coverage'), weather: msg('Exit cockpit to view weather coverage'),
cyclone: msg('Exit cockpit to fly to a cyclone'), 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) { function cockpitRefusal(noun) {
const ready = COCKPIT_REFUSALS[noun]; const ready = COCKPIT_REFUSALS[noun];
return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun }); return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun });
+158
View File
@@ -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`,
);
}
});
+21
View File
@@ -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].classList.contains('own-styles'));
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 () => {
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();
});
+3
View File
@@ -39,6 +39,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'weather-satellite': msg('Satellite clouds'), 'weather-satellite': msg('Satellite clouds'),
'weather-lightning': msg('Lightning density'), 'weather-lightning': msg('Lightning density'),
'weather-cyclones': msg('Cyclone advisories'), 'weather-cyclones': msg('Cyclone advisories'),
'air-quality': msg('Air quality'),
directions: msg('Directions'), directions: msg('Directions'),
'alpr-cameras': msg('ALPR Cameras'), 'alpr-cameras': msg('ALPR Cameras'),
}); });
@@ -116,6 +117,8 @@ 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]),
); );
+33
View File
@@ -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, []);
});
+97 -5
View File
@@ -19,6 +19,7 @@ import {
getLocale, getLocale,
msg, msg,
t, t,
tn,
translateKnown, translateKnown,
} from '../i18n/index.js'; } from '../i18n/index.js';
@@ -187,6 +188,27 @@ const FEED_ERROR_PATTERNS = [
/^Vector tiles unavailable \(HTTP (\d+)\)$/, /^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }), ([, 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$/, /^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }), ([, 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). */ /** One error text in the UI language, or null when nothing matches. */
export function translateFeedError(text) { function feedErrorText(text) {
if (typeof text !== 'string') return text;
if (FEED_ERRORS.has(text)) return t(text); if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text); const known = translateKnown(text);
if (known !== text) return known; if (known !== text) return known;
@@ -232,17 +253,88 @@ export function translateFeedError(text) {
const match = pattern.exec(text); const match = pattern.exec(text);
if (match) return format(match); 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 = ' · '; 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 " · ". */ /** A loading label is English data, at times several joined by " · ". */
function translateLoadingLabel(label) { function translateLoadingLabel(label) {
return label return label
.trim() .trim()
.split(ROW_TEXT_SEPARATOR) .split(ROW_TEXT_SEPARATOR)
.map(translateKnown) .map(translateStatusPiece)
.join(ROW_TEXT_SEPARATOR); .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
+90
View File
@@ -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');
}
});
}
+3 -3
View File
@@ -79,9 +79,9 @@ 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 labels fit on one line, so the of running past the panel edge. English keeps its one-line buttons (unguarded,
English layout is unchanged. */ this rule broke AUTO HOP OFF onto two lines). */
.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;
} }
+6 -1
View File
@@ -1836,9 +1836,11 @@
display: none; 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 { #intel-hud[data-variant='cyber'] .hud-bottom-right {
right: 28px; right: 28px;
bottom: 28px; bottom: 3.5rem;
} }
} }
@@ -2735,6 +2737,9 @@
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout { :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout {
right: 30px; 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 { :root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help {
@@ -210,3 +210,10 @@ test('K-44: Recent Imagery readout keeps its English layout', () => {
assert.equal(declaration(swap, 'width'), '40px'); assert.equal(declaration(swap, 'width'), '40px');
assert.equal(declaration(swap, 'min-width'), undefined); 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,
);
});
+72
View File
@@ -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');
}
});
}
+67
View File
@@ -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);
});
+23
View File
@@ -135,6 +135,8 @@ function shownCount(count, noun) {
return tn(count, '{n} datacenters', '{n} datacenters', values); return tn(count, '{n} datacenters', '{n} datacenters', values);
case 'dams': case 'dams':
return tn(count, '{n} dams', '{n} dams', values); return tn(count, '{n} dams', '{n} dams', values);
case 'air quality':
return tn(count, '{n} air quality', '{n} air quality', values);
case 'results': case 'results':
return tn(count, '{n} results', '{n} results', values); return tn(count, '{n} results', '{n} results', values);
default: default:
@@ -328,6 +330,27 @@ const NOTE_PATTERNS = [
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory', 'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
)) + period, )) + 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)$/, /^([a-z0-9-]+) (off|loading|error)$/,
([, key, status]) => ([, key, status]) =>
+38 -7
View File
@@ -344,13 +344,38 @@ function shownPair(items) {
: items[0] || ''; : 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, * The preset city ids the voice tools take (the `locationId` enum in
// whether the call named it ("Tokyo") or gave its id ("tokyo"). * actionSchemas.js) and the CITY_POIS names they stand for, so `sf` reads
function shownPlace(value, max) { * "San Francisco", not "Sf". A copy, not an import: src/locations.js pulls in
return localizePlaceName(displayName(value, max)); * 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) { 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)
@@ -915,9 +940,15 @@ export function narrationLabel(name, args = {}) {
case 'annotate_map': case 'annotate_map':
return annotationTargets(args)[0] || ''; return annotationTargets(args)[0] || '';
case 'fly_to_location': case 'fly_to_location':
return displayName(args.query || args.locationId || '', 32); return displayName(
presetPlaceName(args.query || args.locationId || ''),
32,
);
case 'select_nearest_aircraft': case 'select_nearest_aircraft':
return displayName(args.locationQuery || args.locationId || '', 32); return displayName(
presetPlaceName(args.locationQuery || args.locationId || ''),
32,
);
default: default:
return ''; return '';
} }