mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
fada8b161b
commit
3adce33cc6
43 files changed
+1350
-97
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Выйдите из кабины, чтобы показать контакты в кадре"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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 '';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user