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/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"]
},
+5 -3
View File
@@ -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 }) =>
+6 -4
View File
@@ -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 <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`.
*/
@@ -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,
});
}
+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 /);
});
// 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),
+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'],
]);
});
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
* 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 });
}
/**
+82 -1
View File
@@ -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"
}
+91 -1
View File
@@ -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": "Выйдите из кабины, чтобы показать контакты в кадре"
}
+2
View File
@@ -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",
+16 -1
View File
@@ -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();
+145 -26
View File
@@ -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(), []);
});
@@ -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 { 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,
})),
},
+29 -8
View File
@@ -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;
}
/**
+10 -4
View File
@@ -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;
},
};
+1 -1
View File
@@ -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: [
+9 -1
View File
@@ -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),
+10 -4
View File
@@ -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,
@@ -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. */
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',
+9 -7
View File
@@ -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' &&
@@ -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';
// 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;
},
};
+2 -1
View File
@@ -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;
+2
View File
@@ -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
+5 -1
View File
@@ -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(', ')}`
@@ -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,
} 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 (
+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
// 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 });
+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].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-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]),
);
+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,
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
+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
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;
}
+6 -1
View File
@@ -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 {
@@ -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,
);
});
+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);
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]) =>
+38 -7
View File
@@ -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 '';
}