mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +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/index.js",
|
||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/sources/httpBody.js"
|
"src/sources/httpBody.js",
|
||||||
|
"src/i18n/index.js",
|
||||||
|
"src/i18n/locale.js",
|
||||||
|
"src/i18n/locales/es.json",
|
||||||
|
"src/i18n/locales/ru.json"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-3
@@ -35,7 +35,7 @@ import {
|
|||||||
DEFAULT_HUD_LAYOUT,
|
DEFAULT_HUD_LAYOUT,
|
||||||
normalizeHudLayout,
|
normalizeHudLayout,
|
||||||
} from './hudLayouts.js';
|
} from './hudLayouts.js';
|
||||||
import { formatNumber, msg, t } from './i18n/index.js';
|
import { formatNumber, getLocale, msg, t } from './i18n/index.js';
|
||||||
import { unitLabel } from './ui/unitLabels.js';
|
import { unitLabel } from './ui/unitLabels.js';
|
||||||
import { localizeLayerName } from './ui/layerPanel.js';
|
import { localizeLayerName } from './ui/layerPanel.js';
|
||||||
import { localizePlaceName } from './ui/placeNames.js';
|
import { localizePlaceName } from './ui/placeNames.js';
|
||||||
@@ -155,10 +155,12 @@ function hudCoordinate(value, positive, negative) {
|
|||||||
|
|
||||||
/** The summary's locality line in the active language (see hudLocality.js). */
|
/** The summary's locality line in the active language (see hudLocality.js). */
|
||||||
const LOCALIZED_LOCALITY = Object.freeze({
|
const LOCALIZED_LOCALITY = Object.freeze({
|
||||||
|
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
|
||||||
|
// language: the place-name table knows `Tokyo`, not `TOKYO`.
|
||||||
near: ({ poi, city, distKm }) =>
|
near: ({ poi, city, distKm }) =>
|
||||||
t('NEAR {poi} ({city}) {distance}', {
|
t('NEAR {poi} ({city}) {distance}', {
|
||||||
poi,
|
poi: poi.toLocaleUpperCase(getLocale()),
|
||||||
city: localizePlaceName(city),
|
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
|
||||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||||
}),
|
}),
|
||||||
sector: ({ latDeg, lonDeg }) =>
|
sector: ({ latDeg, lonDeg }) =>
|
||||||
|
|||||||
+6
-4
@@ -29,7 +29,7 @@ export const NEAR_POI_MAX_KM = 150;
|
|||||||
*/
|
*/
|
||||||
const ENGLISH_LOCALITY = Object.freeze({
|
const ENGLISH_LOCALITY = Object.freeze({
|
||||||
near: ({ poi, city, distKm }) =>
|
near: ({ poi, city, distKm }) =>
|
||||||
`NEAR ${poi} (${city}) ${Math.round(distKm)}KM`,
|
`NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`,
|
||||||
sector: ({ latDeg, lonDeg }) =>
|
sector: ({ latDeg, lonDeg }) =>
|
||||||
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
|
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
|
||||||
});
|
});
|
||||||
@@ -54,7 +54,9 @@ function coordinateTag(value, positive, negative) {
|
|||||||
* @param {number} latDeg Camera latitude in decimal degrees.
|
* @param {number} latDeg Camera latitude in decimal degrees.
|
||||||
* @param {number} lonDeg Camera longitude in decimal degrees.
|
* @param {number} lonDeg Camera longitude in decimal degrees.
|
||||||
* @param {{near: Function, sector: Function}} [format] Builds the line from
|
* @param {{near: Function, sector: Function}} [format] Builds the line from
|
||||||
* `{ poi, city, distKm }` (names upper-cased) or `{ latDeg, lonDeg }`.
|
* `{ poi, city, distKm }` or `{ latDeg, lonDeg }`. The names arrive as
|
||||||
|
* catalogued (`Tokyo`), so a formatter can translate them first and
|
||||||
|
* upper-case the result in the page's language.
|
||||||
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
||||||
* otherwise `SECTOR <lat> <lon>`.
|
* otherwise `SECTOR <lat> <lon>`.
|
||||||
*/
|
*/
|
||||||
@@ -67,8 +69,8 @@ export function composeLocalityTag(
|
|||||||
const distKm = Number(nearest?.distKm);
|
const distKm = Number(nearest?.distKm);
|
||||||
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
||||||
return format.near({
|
return format.near({
|
||||||
poi: String(nearest.poi).toUpperCase(),
|
poi: String(nearest.poi),
|
||||||
city: String(nearest.city).toUpperCase(),
|
city: String(nearest.city),
|
||||||
distKm,
|
distKm,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ test('a missing or malformed nearest POI never crashes the summary', () => {
|
|||||||
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
|
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The names reach a formatter as catalogued, so the HUD can translate the
|
||||||
|
// city before upper-casing it (N-04: `TOKYO` never matched the `Tokyo` key).
|
||||||
test('a formatter builds the line from the same parts', () => {
|
test('a formatter builds the line from the same parts', () => {
|
||||||
const format = {
|
const format = {
|
||||||
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
|
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
|
||||||
@@ -129,7 +131,7 @@ test('a formatter builds the line from the same parts', () => {
|
|||||||
};
|
};
|
||||||
assert.equal(
|
assert.equal(
|
||||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
|
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
|
||||||
'near:ALCATRAZ ISLAND|SAN FRANCISCO|2.4',
|
'near:Alcatraz Island|San Francisco|2.4',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
composeLocalityTag(null, 21.3, -157.8, format),
|
composeLocalityTag(null, 21.3, -157.8, format),
|
||||||
|
|||||||
@@ -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'],
|
['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
|
* Translate a count-dependent message. The catalog entry for `other` holds
|
||||||
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
|
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
|
||||||
* for Russian). `{count}` is always available to the pattern.
|
* for Russian). `{count}` is always available to the pattern: the raw number,
|
||||||
|
* unless `values.count` gives its display text, e.g. `formatNumber(n)`.
|
||||||
* @param {number} count
|
* @param {number} count
|
||||||
* @param {string} one English singular, e.g. '{count} camera'
|
* @param {string} one English singular, e.g. '{count} camera'
|
||||||
* @param {string} other English plural, e.g. '{count} cameras'
|
* @param {string} other English plural, e.g. '{count} cameras'
|
||||||
* @param {object} [values] Extra interpolation values
|
* @param {object} [values] Extra interpolation values; a `count` here is
|
||||||
|
* shown in place of the number (the plural form still follows `count`)
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function tn(count, one, other, values) {
|
export function tn(count, one, other, values) {
|
||||||
@@ -111,7 +113,7 @@ export function tn(count, one, other, values) {
|
|||||||
} else {
|
} else {
|
||||||
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
|
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
|
||||||
}
|
}
|
||||||
return interpolate(pattern, { ...values, count });
|
return interpolate(pattern, { count, ...values });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2796,5 +2796,86 @@
|
|||||||
},
|
},
|
||||||
"FOV {value}° (DATASHEET)": "FOV {value}° (FICHA TÉCNICA)",
|
"FOV {value}° (DATASHEET)": "FOV {value}° (FICHA TÉCNICA)",
|
||||||
"SPEC FOV {span}° (CAPABILITY)": "FOV DE FICHA {span}° (CAPACIDAD)",
|
"SPEC FOV {span}° (CAPABILITY)": "FOV DE FICHA {span}° (CAPACIDAD)",
|
||||||
"{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ESPECIFICACIONES: CCTV-DATABASE.COM"
|
"{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ESPECIFICACIONES: CCTV-DATABASE.COM",
|
||||||
|
"Canada only · ECCC AQHI network": "Solo Canadá · red AQHI de ECCC",
|
||||||
|
"{count} stations reporting": {
|
||||||
|
"one": "{count} estación con datos",
|
||||||
|
"many": "{count} de estaciones con datos",
|
||||||
|
"other": "{count} estaciones con datos"
|
||||||
|
},
|
||||||
|
"No readings available": "No hay lecturas disponibles",
|
||||||
|
"No current readings": "Sin lecturas actuales",
|
||||||
|
"Low health risk": "Riesgo bajo para la salud",
|
||||||
|
"Moderate health risk": "Riesgo moderado para la salud",
|
||||||
|
"High health risk": "Riesgo alto para la salud",
|
||||||
|
"Very High health risk": "Riesgo muy alto para la salud",
|
||||||
|
"Air quality request failed": "Falló la petición de calidad del aire",
|
||||||
|
"ECCC request timed out": "La petición a ECCC agotó el tiempo de espera",
|
||||||
|
"Malformed AQHI station response": "Respuesta de estaciones AQHI mal formada",
|
||||||
|
"Malformed AQHI observation response": "Respuesta de observaciones AQHI mal formada",
|
||||||
|
"No AQHI stations available": "No hay estaciones AQHI disponibles",
|
||||||
|
"{n} air quality": {
|
||||||
|
"one": "{n} estación de calidad del aire",
|
||||||
|
"many": "{n} de estaciones de calidad del aire",
|
||||||
|
"other": "{n} estaciones de calidad del aire"
|
||||||
|
},
|
||||||
|
"GNSS feed rate limited (HTTP 429)": "Fuente GNSS: límite de peticiones superado (HTTP 429)",
|
||||||
|
"GNSS feed unavailable (HTTP {status})": "Fuente GNSS no disponible (HTTP {status})",
|
||||||
|
"GNSS snapshot needs a view anchor": "La instantánea GNSS necesita un punto de referencia de la vista",
|
||||||
|
"Malformed GNSS integrity snapshot": "Instantánea de integridad GNSS mal formada",
|
||||||
|
"GNSS integrity snapshot has no observation time": "La instantánea de integridad GNSS no tiene hora de observación",
|
||||||
|
"{feed} feed unavailable": "Fuente {feed} no disponible",
|
||||||
|
"Loading {feeds}": "Cargando {feeds}",
|
||||||
|
"{count} CelesTrak groups unavailable": {
|
||||||
|
"one": "{count} grupo de CelesTrak no disponible",
|
||||||
|
"many": "{count} de grupos de CelesTrak no disponibles",
|
||||||
|
"other": "{count} grupos de CelesTrak no disponibles"
|
||||||
|
},
|
||||||
|
"Malformed launch snapshot": "Instantánea de lanzamientos mal formada",
|
||||||
|
"Malformed perimeter snapshot": "Instantánea de perímetros mal formada",
|
||||||
|
"Overpass returned an incomplete camera response": "Overpass devolvió una respuesta de cámaras incompleta",
|
||||||
|
"Fly below {km} km to a covered region": "Desciende por debajo de {km} km sobre una región con cobertura",
|
||||||
|
"No feed here yet · {n} regions available": {
|
||||||
|
"one": "Aún no hay fuente aquí · {n} región disponible",
|
||||||
|
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
|
||||||
|
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
|
||||||
|
},
|
||||||
|
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
|
||||||
|
"Decoder feeds": "Fuentes del decodificador",
|
||||||
|
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
|
||||||
|
"opening receiver": "abriendo el receptor",
|
||||||
|
"checking decoder feeds": "comprobando las fuentes del decodificador",
|
||||||
|
"listening": "escuchando",
|
||||||
|
"USB error": "error de USB",
|
||||||
|
"{count} heard": {
|
||||||
|
"one": "{count} aeronave captada",
|
||||||
|
"many": "{count} de aeronaves captadas",
|
||||||
|
"other": "{count} aeronaves captadas"
|
||||||
|
},
|
||||||
|
"{rate} msg/s": "{rate} msg/s",
|
||||||
|
"USB {rate} msg/s": "USB {rate} msg/s",
|
||||||
|
"{count} feeds live": {
|
||||||
|
"one": "{count} fuente en vivo",
|
||||||
|
"many": "{count} de fuentes en vivo",
|
||||||
|
"other": "{count} fuentes en vivo"
|
||||||
|
},
|
||||||
|
"feeds {names} stale": {
|
||||||
|
"one": "fuente {names} desactualizada",
|
||||||
|
"many": "fuentes {names} desactualizadas",
|
||||||
|
"other": "fuentes {names} desactualizadas"
|
||||||
|
},
|
||||||
|
"feeds {names} unreachable": {
|
||||||
|
"one": "fuente {names} inaccesible",
|
||||||
|
"many": "fuentes {names} inaccesibles",
|
||||||
|
"other": "fuentes {names} inaccesibles"
|
||||||
|
},
|
||||||
|
"feeds {names} invalid": {
|
||||||
|
"one": "fuente {names} no válida",
|
||||||
|
"many": "fuentes {names} no válidas",
|
||||||
|
"other": "fuentes {names} no válidas"
|
||||||
|
},
|
||||||
|
"unsaved provider keys": "las claves de proveedores sin guardar",
|
||||||
|
"Exit cockpit to fly to an aircraft": "Sal de la cabina para volar a una aeronave",
|
||||||
|
"Exit cockpit to fly to a satellite": "Sal de la cabina para volar a un satélite",
|
||||||
|
"Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos"
|
||||||
}
|
}
|
||||||
@@ -2854,5 +2854,95 @@
|
|||||||
},
|
},
|
||||||
"FOV {value}° (DATASHEET)": "ОБЗОР {value}° (ПО ПАСПОРТУ)",
|
"FOV {value}° (DATASHEET)": "ОБЗОР {value}° (ПО ПАСПОРТУ)",
|
||||||
"SPEC FOV {span}° (CAPABILITY)": "ОБЗОР ПО ПАСПОРТУ {span}° (ВОЗМОЖНОСТИ)",
|
"SPEC FOV {span}° (CAPABILITY)": "ОБЗОР ПО ПАСПОРТУ {span}° (ВОЗМОЖНОСТИ)",
|
||||||
"{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ХАРАКТЕРИСТИКИ: CCTV-DATABASE.COM"
|
"{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · ХАРАКТЕРИСТИКИ: CCTV-DATABASE.COM",
|
||||||
|
"Canada only · ECCC AQHI network": "Только Канада · сеть AQHI ECCC",
|
||||||
|
"{count} stations reporting": {
|
||||||
|
"one": "{count} станция передаёт данные",
|
||||||
|
"few": "{count} станции передают данные",
|
||||||
|
"many": "{count} станций передают данные",
|
||||||
|
"other": "{count} станции передают данные"
|
||||||
|
},
|
||||||
|
"No readings available": "Нет доступных показаний",
|
||||||
|
"No current readings": "Нет актуальных показаний",
|
||||||
|
"Low health risk": "Низкий риск для здоровья",
|
||||||
|
"Moderate health risk": "Умеренный риск для здоровья",
|
||||||
|
"High health risk": "Высокий риск для здоровья",
|
||||||
|
"Very High health risk": "Очень высокий риск для здоровья",
|
||||||
|
"Air quality request failed": "Не удалось запросить данные о качестве воздуха",
|
||||||
|
"ECCC request timed out": "Истекло время ожидания запроса к ECCC",
|
||||||
|
"Malformed AQHI station response": "Некорректный ответ со станциями AQHI",
|
||||||
|
"Malformed AQHI observation response": "Некорректный ответ с наблюдениями AQHI",
|
||||||
|
"No AQHI stations available": "Нет доступных станций AQHI",
|
||||||
|
"{n} air quality": {
|
||||||
|
"one": "{n} станция качества воздуха",
|
||||||
|
"few": "{n} станции качества воздуха",
|
||||||
|
"many": "{n} станций качества воздуха",
|
||||||
|
"other": "{n} станции качества воздуха"
|
||||||
|
},
|
||||||
|
"GNSS feed rate limited (HTTP 429)": "Поток GNSS: превышен лимит запросов (HTTP 429)",
|
||||||
|
"GNSS feed unavailable (HTTP {status})": "Поток GNSS недоступен (HTTP {status})",
|
||||||
|
"GNSS snapshot needs a view anchor": "Для снимка GNSS нужна опорная точка вида",
|
||||||
|
"Malformed GNSS integrity snapshot": "Некорректный снимок целостности GNSS",
|
||||||
|
"GNSS integrity snapshot has no observation time": "В снимке целостности GNSS нет времени наблюдения",
|
||||||
|
"{feed} feed unavailable": "Поток {feed} недоступен",
|
||||||
|
"Loading {feeds}": "Загрузка: {feeds}",
|
||||||
|
"{count} CelesTrak groups unavailable": {
|
||||||
|
"one": "{count} группа CelesTrak недоступна",
|
||||||
|
"few": "{count} группы CelesTrak недоступны",
|
||||||
|
"many": "{count} групп CelesTrak недоступны",
|
||||||
|
"other": "{count} группы CelesTrak недоступны"
|
||||||
|
},
|
||||||
|
"Malformed launch snapshot": "Некорректный снимок запусков",
|
||||||
|
"Malformed perimeter snapshot": "Некорректный снимок периметров",
|
||||||
|
"Overpass returned an incomplete camera response": "Overpass вернул неполный ответ о камерах",
|
||||||
|
"Fly below {km} km to a covered region": "Опуститесь ниже {km} км над регионом с покрытием",
|
||||||
|
"No feed here yet · {n} regions available": {
|
||||||
|
"one": "Здесь пока нет потока данных · доступен {n} регион",
|
||||||
|
"few": "Здесь пока нет потока данных · доступно {n} региона",
|
||||||
|
"many": "Здесь пока нет потока данных · доступно {n} регионов",
|
||||||
|
"other": "Здесь пока нет потока данных · доступно {n} региона"
|
||||||
|
},
|
||||||
|
"Wind speed field unavailable": "Поле скорости ветра недоступно",
|
||||||
|
"Decoder feeds": "Потоки декодера",
|
||||||
|
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
|
||||||
|
"opening receiver": "открытие приёмника",
|
||||||
|
"checking decoder feeds": "проверка потоков декодера",
|
||||||
|
"listening": "приём",
|
||||||
|
"USB error": "ошибка USB",
|
||||||
|
"{count} heard": {
|
||||||
|
"one": "слышно бортов: {count}",
|
||||||
|
"few": "слышно бортов: {count}",
|
||||||
|
"many": "слышно бортов: {count}",
|
||||||
|
"other": "слышно бортов: {count}"
|
||||||
|
},
|
||||||
|
"{rate} msg/s": "{rate} сообщ./с",
|
||||||
|
"USB {rate} msg/s": "USB {rate} сообщ./с",
|
||||||
|
"{count} feeds live": {
|
||||||
|
"one": "{count} поток онлайн",
|
||||||
|
"few": "{count} потока онлайн",
|
||||||
|
"many": "{count} потоков онлайн",
|
||||||
|
"other": "{count} потока онлайн"
|
||||||
|
},
|
||||||
|
"feeds {names} stale": {
|
||||||
|
"one": "поток {names} устарел",
|
||||||
|
"few": "потоки {names} устарели",
|
||||||
|
"many": "потоки {names} устарели",
|
||||||
|
"other": "потоки {names} устарели"
|
||||||
|
},
|
||||||
|
"feeds {names} unreachable": {
|
||||||
|
"one": "поток {names} недоступен",
|
||||||
|
"few": "потоки {names} недоступны",
|
||||||
|
"many": "потоки {names} недоступны",
|
||||||
|
"other": "потоки {names} недоступны"
|
||||||
|
},
|
||||||
|
"feeds {names} invalid": {
|
||||||
|
"one": "поток {names} некорректен",
|
||||||
|
"few": "потоки {names} некорректны",
|
||||||
|
"many": "потоки {names} некорректны",
|
||||||
|
"other": "потоки {names} некорректны"
|
||||||
|
},
|
||||||
|
"unsaved provider keys": "несохранённые ключи провайдеров",
|
||||||
|
"Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту",
|
||||||
|
"Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику",
|
||||||
|
"Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре"
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
"Soul",
|
"Soul",
|
||||||
"Techno",
|
"Techno",
|
||||||
"Trance",
|
"Trance",
|
||||||
|
"USB {rate} msg/s",
|
||||||
"VISUAL",
|
"VISUAL",
|
||||||
"VS",
|
"VS",
|
||||||
"WHOT/BHOT",
|
"WHOT/BHOT",
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
"panel",
|
"panel",
|
||||||
"view band|GLOBAL",
|
"view band|GLOBAL",
|
||||||
"voice|ERROR",
|
"voice|ERROR",
|
||||||
|
"{rate} msg/s",
|
||||||
"{speed} kn",
|
"{speed} kn",
|
||||||
"{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}",
|
||||||
"{value} KM",
|
"{value} KM",
|
||||||
|
|||||||
+16
-1
@@ -20,7 +20,7 @@ import {
|
|||||||
* the chip and the dialog are removed outright.
|
* the chip and the dialog are removed outright.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t, msg, tn } from './i18n/index.js';
|
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chip label — pure, exported for tests. The exact English values are pinned
|
* Chip label — pure, exported for tests. The exact English values are pinned
|
||||||
@@ -386,9 +386,24 @@ export async function initKeySetup({
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let disposeControls = () => {};
|
let disposeControls = () => {};
|
||||||
let disposePlacement = () => {};
|
let disposePlacement = () => {};
|
||||||
|
// A pasted key lives only in its field until Save: switching language (a
|
||||||
|
// reload) asks first while any field holds one.
|
||||||
|
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
||||||
|
!disposed &&
|
||||||
|
Object.keys(
|
||||||
|
collectKeyUpdates(
|
||||||
|
[...(root.querySelectorAll?.('input[data-env-var]') || [])].map(
|
||||||
|
(input) => ({ envVar: input.dataset?.envVar, value: input.value }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).length
|
||||||
|
? msg('unsaved provider keys')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
unregisterUnsavedWork();
|
||||||
lifetime.abort();
|
lifetime.abort();
|
||||||
signal?.removeEventListener('abort', destroy);
|
signal?.removeEventListener('abort', destroy);
|
||||||
disposeControls();
|
disposeControls();
|
||||||
|
|||||||
+145
-26
@@ -9,25 +9,42 @@ import {
|
|||||||
} from './keySetup.js';
|
} from './keySetup.js';
|
||||||
|
|
||||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||||
const home = { prepend: (node) => { node.parentNode = home; } };
|
const home = {
|
||||||
|
prepend: (node) => {
|
||||||
|
node.parentNode = home;
|
||||||
|
},
|
||||||
|
};
|
||||||
const chip = { parentNode: home, hidden: true };
|
const chip = { parentNode: home, hidden: true };
|
||||||
let moves = 0;
|
let moves = 0;
|
||||||
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
const toolbar = {
|
||||||
|
append: (node) => {
|
||||||
|
moves++;
|
||||||
|
node.parentNode = toolbar;
|
||||||
|
},
|
||||||
|
};
|
||||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
const theme = { dataset: { uiTheme: 'tactical' } };
|
||||||
let notify, disconnected = false;
|
let notify,
|
||||||
|
disconnected = false;
|
||||||
class Observer {
|
class Observer {
|
||||||
constructor(callback) { notify = callback; }
|
constructor(callback) {
|
||||||
|
notify = callback;
|
||||||
|
}
|
||||||
observe(target, options) {
|
observe(target, options) {
|
||||||
assert.equal(target, theme);
|
assert.equal(target, theme);
|
||||||
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
||||||
}
|
}
|
||||||
disconnect() { disconnected = true; }
|
disconnect() {
|
||||||
|
disconnected = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const dispose = bindKeySetupPlacement({
|
const dispose = bindKeySetupPlacement(
|
||||||
documentElement: theme,
|
{
|
||||||
getElementById: () => toolbar,
|
documentElement: theme,
|
||||||
defaultView: { MutationObserver: Observer },
|
getElementById: () => toolbar,
|
||||||
}, chip);
|
defaultView: { MutationObserver: Observer },
|
||||||
|
},
|
||||||
|
chip,
|
||||||
|
);
|
||||||
assert.equal(chip.parentNode, home);
|
assert.equal(chip.parentNode, home);
|
||||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||||
theme.dataset.uiTheme = variant;
|
theme.dataset.uiTheme = variant;
|
||||||
@@ -42,10 +59,20 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the chip counts what is missing, and retires the count at zero', () => {
|
test('the chip counts what is missing, and retires the count at zero', () => {
|
||||||
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
assert.equal(
|
||||||
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
|
keySetupChipLabel({ setCount: 0, total: 8 }),
|
||||||
|
'POWER UP · 8 KEYS WAITING',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
keySetupChipLabel({ setCount: 7, total: 8 }),
|
||||||
|
'POWER UP · 1 KEY WAITING',
|
||||||
|
);
|
||||||
assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP');
|
assert.equal(keySetupChipLabel({ setCount: 8, total: 8 }), 'POWERED UP');
|
||||||
assert.equal(keySetupChipLabel(null), 'POWERED UP', 'no status is not a broken label');
|
assert.equal(
|
||||||
|
keySetupChipLabel(null),
|
||||||
|
'POWERED UP',
|
||||||
|
'no status is not a broken label',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collectKeyUpdates keeps only non-empty trimmed values', () => {
|
test('collectKeyUpdates keeps only non-empty trimmed values', () => {
|
||||||
@@ -85,14 +112,27 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available'
|
|||||||
|
|
||||||
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
|
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
|
||||||
for (const result of [
|
for (const result of [
|
||||||
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
|
{
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
available: false,
|
||||||
|
loginFailed: true,
|
||||||
|
error: 'Sign-in failed.',
|
||||||
|
},
|
||||||
|
},
|
||||||
{ ok: false, payload: { error: 'Sign-in is local only.' } },
|
{ ok: false, payload: { error: 'Sign-in is local only.' } },
|
||||||
]) {
|
]) {
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
await assert.rejects(waitForChatGptOAuth({
|
await assert.rejects(
|
||||||
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
|
waitForChatGptOAuth({
|
||||||
sleep: () => assert.fail('failed login must stop polling'),
|
fetchImpl: async () => {
|
||||||
}), { message: result.payload.error });
|
checks += 1;
|
||||||
|
return { ok: result.ok, json: async () => result.payload };
|
||||||
|
},
|
||||||
|
sleep: () => assert.fail('failed login must stop polling'),
|
||||||
|
}),
|
||||||
|
{ message: result.payload.error },
|
||||||
|
);
|
||||||
assert.equal(checks, 1);
|
assert.equal(checks, 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -100,12 +140,34 @@ test('OAuth login polling stops on login or HTTP failure instead of waiting for
|
|||||||
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
|
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
|
||||||
let clock = 0;
|
let clock = 0;
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
|
const poll = {
|
||||||
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
|
fetchImpl: async () => {
|
||||||
|
checks += 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ available: false, pending: true }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
await waitForChatGptOAuth({
|
||||||
|
...poll,
|
||||||
|
timeoutMs: 100,
|
||||||
|
pollMs: 100,
|
||||||
|
now: () => clock,
|
||||||
|
sleep: async (ms) => {
|
||||||
|
clock += ms;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(checks, 2);
|
assert.equal(checks, 2);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
controller.abort();
|
controller.abort();
|
||||||
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
|
assert.equal(
|
||||||
|
await waitForChatGptOAuth({ ...poll, signal: controller.signal }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(checks, 2, 'aborted polling does not fetch');
|
assert.equal(checks, 2, 'aborted polling does not fetch');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,14 +175,22 @@ test('OAuth polling ignores a ready response received after the surface was abor
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const available = await waitForChatGptOAuth({
|
const available = await waitForChatGptOAuth({
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => {
|
||||||
|
controller.abort();
|
||||||
|
return { available: true };
|
||||||
|
},
|
||||||
|
}),
|
||||||
sleep: () => assert.fail('aborted polling must not continue'),
|
sleep: () => assert.fail('aborted polling must not continue'),
|
||||||
});
|
});
|
||||||
assert.equal(available, false);
|
assert.equal(available, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
||||||
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
const stripped = stripKeylessBasemapFromHash(
|
||||||
|
'lat=30.2&lon=-97.7&map=osm&style=normal',
|
||||||
|
);
|
||||||
assert.ok(stripped !== null);
|
assert.ok(stripped !== null);
|
||||||
const params = new URLSearchParams(stripped);
|
const params = new URLSearchParams(stripped);
|
||||||
assert.equal(params.get('map'), null, 'osm basemap removed');
|
assert.equal(params.get('map'), null, 'osm basemap removed');
|
||||||
@@ -128,7 +198,11 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha
|
|||||||
assert.equal(params.get('style'), 'normal', 'style survives');
|
assert.equal(params.get('style'), 'normal', 'style survives');
|
||||||
// A stack under any other name was chosen or shared on purpose.
|
// A stack under any other name was chosen or shared on purpose.
|
||||||
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
|
assert.equal(stripKeylessBasemapFromHash('map=bing-aerial&lat=1'), null);
|
||||||
assert.equal(stripKeylessBasemapFromHash('lat=1&lon=2'), null, 'no stack, nothing to do');
|
assert.equal(
|
||||||
|
stripKeylessBasemapFromHash('lat=1&lon=2'),
|
||||||
|
null,
|
||||||
|
'no stack, nothing to do',
|
||||||
|
);
|
||||||
assert.equal(stripKeylessBasemapFromHash(''), null);
|
assert.equal(stripKeylessBasemapFromHash(''), null);
|
||||||
assert.equal(stripKeylessBasemapFromHash(undefined), null);
|
assert.equal(stripKeylessBasemapFromHash(undefined), null);
|
||||||
});
|
});
|
||||||
@@ -142,11 +216,15 @@ test('aborting pending setup removes its surface and ignores a late response', a
|
|||||||
let requestSignal;
|
let requestSignal;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const pending = initKeySetup({
|
const pending = initKeySetup({
|
||||||
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
|
documentRef: {
|
||||||
|
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||||
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
fetchImpl: (_url, { signal }) => {
|
fetchImpl: (_url, { signal }) => {
|
||||||
requestSignal = signal;
|
requestSignal = signal;
|
||||||
return new Promise((resolve) => { resolveResponse = resolve; });
|
return new Promise((resolve) => {
|
||||||
|
resolveResponse = resolve;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
controller.abort();
|
controller.abort();
|
||||||
@@ -155,3 +233,44 @@ test('aborting pending setup removes its surface and ignores a late response', a
|
|||||||
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
||||||
assert.equal(await pending, null);
|
assert.equal(await pending, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
|
||||||
|
const { initKeySetup } = await import('./keySetup.js');
|
||||||
|
const { describeUnsavedWork } = await import('./i18n/index.js');
|
||||||
|
const node = () => ({
|
||||||
|
dataset: {},
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
setAttribute() {},
|
||||||
|
querySelector: () => null,
|
||||||
|
remove() {},
|
||||||
|
});
|
||||||
|
const chip = node();
|
||||||
|
const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' };
|
||||||
|
const root = {
|
||||||
|
...node(),
|
||||||
|
querySelectorAll: (selector) =>
|
||||||
|
selector === 'input[data-env-var]' ? [input] : [],
|
||||||
|
};
|
||||||
|
const controller = new AbortController();
|
||||||
|
const setup = await initKeySetup({
|
||||||
|
documentRef: {
|
||||||
|
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ keys: [], setCount: 0, total: 1 }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(setup);
|
||||||
|
assert.deepEqual(describeUnsavedWork(), []);
|
||||||
|
input.value = ' ';
|
||||||
|
assert.deepEqual(describeUnsavedWork(), []);
|
||||||
|
input.value = 'AIza-pasted';
|
||||||
|
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
|
||||||
|
controller.abort();
|
||||||
|
assert.deepEqual(describeUnsavedWork(), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// N-02: the Air quality layer (merged from main, AQHI) is fully English in
|
||||||
|
// es/ru: layer name, Weather card (title, coverage, status, legend, risk) and
|
||||||
|
// its own failure text. Place: src/layers/airQuality/airQualityLocale.test.mjs
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { setLocale, t } from '../../i18n/index.js';
|
||||||
|
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
|
||||||
|
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||||
|
import { createAirQualityLayer } from './index.js';
|
||||||
|
|
||||||
|
function inLocale(locale, run) {
|
||||||
|
setLocale(locale);
|
||||||
|
try {
|
||||||
|
return run();
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(source) {
|
||||||
|
const layer = createAirQualityLayer({
|
||||||
|
source,
|
||||||
|
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||||
|
});
|
||||||
|
const viewer = { dataSources: { add() {}, remove() {} } };
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable(viewer);
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reading = {
|
||||||
|
id: 'eccc:A',
|
||||||
|
provider: 'eccc',
|
||||||
|
scale: 'AQHI',
|
||||||
|
value: 2,
|
||||||
|
band: 'low',
|
||||||
|
stationId: 'A',
|
||||||
|
name: 'Toronto',
|
||||||
|
zone: 'ont',
|
||||||
|
lat: 43.65,
|
||||||
|
lon: -79.38,
|
||||||
|
observedMs: 1_700_000_000_000,
|
||||||
|
color: '#4cc9f0',
|
||||||
|
label: '2',
|
||||||
|
risk: 'Low health risk',
|
||||||
|
note: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const locale of ['es', 'ru']) {
|
||||||
|
test(`${locale}: the layer name is translated`, () => {
|
||||||
|
inLocale(locale, () => {
|
||||||
|
assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry');
|
||||||
|
assert.equal(layerNameForId('air-quality'), t('Air quality'));
|
||||||
|
assert.equal(localizeLayerName('Air quality'), t('Air quality'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${locale}: the Weather card of Air quality is translated`, async () => {
|
||||||
|
const layer = harness({ getSnapshot: async () => [reading] });
|
||||||
|
await layer.update();
|
||||||
|
const controls = inLocale(locale, () => layer.getRowControls());
|
||||||
|
const english = [
|
||||||
|
controls.summary.label,
|
||||||
|
controls.summary.coverage,
|
||||||
|
controls.summary.status,
|
||||||
|
...controls.legend.map((entry) => entry.label),
|
||||||
|
...controls.list.items.map((item) => item.text),
|
||||||
|
].filter((text) =>
|
||||||
|
/\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test(
|
||||||
|
text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(english, [], `English left in the ${locale} card`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${locale}: the Air quality failure text is translated in the row`, () => {
|
||||||
|
const row = inLocale(locale, () =>
|
||||||
|
LayerPanel.prototype._buildMetaText({
|
||||||
|
id: 'air-quality',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 0,
|
||||||
|
error: 'Air quality request failed',
|
||||||
|
source: 'ECCC',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(!row.includes('Air quality request failed'), row);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { msg, t, tn, translateKnown } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
AIR_QUALITY_BANDS,
|
AIR_QUALITY_BANDS,
|
||||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||||
@@ -110,7 +110,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (request.signal.aborted || _request !== request) return false;
|
if (request.signal.aborted || _request !== request) return false;
|
||||||
_lastError = error?.message || 'Air quality request failed';
|
_lastError = error?.message || msg('Air quality request failed');
|
||||||
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
|
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
|
||||||
notify();
|
notify();
|
||||||
return false;
|
return false;
|
||||||
@@ -215,25 +215,32 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
|||||||
(max, reading) => Math.max(max, reading.observedMs || 0),
|
(max, reading) => Math.max(max, reading.observedMs || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
// Readings and errors stay English data (analyst records, stats); the
|
||||||
|
// card shows them in the UI language. A provider's own error text has
|
||||||
|
// no catalog entry and shows as it came.
|
||||||
return {
|
return {
|
||||||
readout: true,
|
readout: true,
|
||||||
summary: {
|
summary: {
|
||||||
label: provider.label,
|
label: t(provider.label),
|
||||||
coverage: provider.coverage,
|
coverage: t(provider.coverage),
|
||||||
validTime: newest ? new Date(newest).toISOString() : undefined,
|
validTime: newest ? new Date(newest).toISOString() : undefined,
|
||||||
detail: `${provider.network} · ${provider.scale}`,
|
detail: `${provider.network} · ${provider.scale}`,
|
||||||
status: _lastError
|
status: _lastError
|
||||||
? _lastError
|
? translateKnown(_lastError)
|
||||||
: _count === 0
|
: _count === 0
|
||||||
? _lastUpdate === null
|
? _lastUpdate === null
|
||||||
? 'No readings available'
|
? t('No readings available')
|
||||||
: 'No current readings'
|
: t('No current readings')
|
||||||
: `${_count} stations reporting`,
|
: tn(
|
||||||
|
_count,
|
||||||
|
'{count} station reporting',
|
||||||
|
'{count} stations reporting',
|
||||||
|
),
|
||||||
units: provider.scale,
|
units: provider.scale,
|
||||||
},
|
},
|
||||||
legend: AIR_QUALITY_BANDS.map((band) => ({
|
legend: AIR_QUALITY_BANDS.map((band) => ({
|
||||||
color: band.color,
|
color: band.color,
|
||||||
label: band.label,
|
label: t(band.label),
|
||||||
})),
|
})),
|
||||||
legendCategorical: true,
|
legendCategorical: true,
|
||||||
list: {
|
list: {
|
||||||
@@ -242,7 +249,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
|||||||
id: reading.id,
|
id: reading.id,
|
||||||
ordinal: index + 1,
|
ordinal: index + 1,
|
||||||
lead: `${reading.scale} ${reading.label}`,
|
lead: `${reading.scale} ${reading.label}`,
|
||||||
text: `${reading.name} · ${reading.risk}`,
|
text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`,
|
||||||
accent: reading.color,
|
accent: reading.color,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,14 +15,36 @@
|
|||||||
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
||||||
* later provider registers against.
|
* later provider registers against.
|
||||||
*/
|
*/
|
||||||
import { msg } from '../../i18n/index.js';
|
// A portable export (package.json): no i18n here. The labels stay English data
|
||||||
|
// and the Layers panel shows them translated.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
/** Band identifiers shared across providers, in ascending severity. */
|
/** Band identifiers shared across providers, in ascending severity. */
|
||||||
export const AIR_QUALITY_BANDS = Object.freeze([
|
export const AIR_QUALITY_BANDS = Object.freeze([
|
||||||
Object.freeze({ id: 'low', label: msg('Low'), color: '#4cc9f0' }),
|
Object.freeze({
|
||||||
Object.freeze({ id: 'moderate', label: msg('Moderate'), color: '#ffd43b' }),
|
id: 'low',
|
||||||
Object.freeze({ id: 'high', label: msg('High'), color: '#ff6b35' }),
|
label: msg('Low'),
|
||||||
Object.freeze({ id: 'very-high', label: msg('Very High'), color: '#c1121f' }),
|
risk: msg('Low health risk'),
|
||||||
|
color: '#4cc9f0',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'moderate',
|
||||||
|
label: msg('Moderate'),
|
||||||
|
risk: msg('Moderate health risk'),
|
||||||
|
color: '#ffd43b',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'high',
|
||||||
|
label: msg('High'),
|
||||||
|
risk: msg('High health risk'),
|
||||||
|
color: '#ff6b35',
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'very-high',
|
||||||
|
label: msg('Very High'),
|
||||||
|
risk: msg('Very High health risk'),
|
||||||
|
color: '#c1121f',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
|
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
|
||||||
@@ -92,7 +114,7 @@ export const AIR_QUALITY_PROVIDERS = Object.freeze({
|
|||||||
scale: 'AQHI',
|
scale: 'AQHI',
|
||||||
label: msg('Air quality'),
|
label: msg('Air quality'),
|
||||||
network: 'Environment and Climate Change Canada',
|
network: 'Environment and Climate Change Canada',
|
||||||
coverage: 'Canada only · ECCC AQHI network',
|
coverage: msg('Canada only · ECCC AQHI network'),
|
||||||
/** ECCC's published band thresholds, evaluated in order. */
|
/** ECCC's published band thresholds, evaluated in order. */
|
||||||
bands: Object.freeze([
|
bands: Object.freeze([
|
||||||
Object.freeze({ id: 'low', max: 3 }),
|
Object.freeze({ id: 'low', max: 3 }),
|
||||||
@@ -162,8 +184,7 @@ export function valueLabel(providerId, value) {
|
|||||||
|
|
||||||
/** Plain-language risk phrase for a band id. */
|
/** Plain-language risk phrase for a band id. */
|
||||||
export function bandRiskText(bandId) {
|
export function bandRiskText(bandId) {
|
||||||
const band = airQualityBandById(bandId);
|
return airQualityBandById(bandId)?.risk ?? null;
|
||||||
return band ? `${band.label} health risk` : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||||
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
|
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
|
||||||
|
|
||||||
|
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||||
|
// the Layers panel and the Weather card show them translated.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
|
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
|
||||||
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
||||||
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
||||||
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
|
|||||||
const abort = () => controller.abort(signal.reason);
|
const abort = () => controller.abort(signal.reason);
|
||||||
signal?.addEventListener('abort', abort, { once: true });
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => controller.abort(new Error('ECCC request timed out')),
|
() => controller.abort(new Error(msg('ECCC request timed out'))),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const next = normalizeAqhiStations(payload);
|
const next = normalizeAqhiStations(payload);
|
||||||
if (!next) throw new Error('Malformed AQHI station response');
|
if (!next) throw new Error(msg('Malformed AQHI station response'));
|
||||||
// An empty refresh must not wipe a good catalog.
|
// An empty refresh must not wipe a good catalog.
|
||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
stations = next;
|
stations = next;
|
||||||
stationsFetchedAt = clock;
|
stationsFetchedAt = clock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stations.size === 0) throw new Error('No AQHI stations available');
|
if (stations.size === 0)
|
||||||
|
throw new Error(msg('No AQHI stations available'));
|
||||||
|
|
||||||
const payload = await getJson(
|
const payload = await getJson(
|
||||||
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
||||||
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const readings = normalizeAqhiObservations(payload, stations, clock);
|
const readings = normalizeAqhiObservations(payload, stations, clock);
|
||||||
if (!readings) throw new Error('Malformed AQHI observation response');
|
if (!readings)
|
||||||
|
throw new Error(msg('Malformed AQHI observation response'));
|
||||||
return readings;
|
return readings;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export function createOverpassAlprSource({
|
|||||||
// The shared proxy already rejects query errors. Validate here too so a
|
// The shared proxy already rejects query errors. Validate here too so a
|
||||||
// malformed or partial response never becomes an authoritative empty map.
|
// malformed or partial response never becomes an authoritative empty map.
|
||||||
if (!Array.isArray(payload?.elements) || payload.remark) {
|
if (!Array.isArray(payload?.elements) || payload.remark) {
|
||||||
throw new Error('Overpass returned an incomplete camera response');
|
throw new Error(msg('Overpass returned an incomplete camera response'));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
records: [
|
records: [
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ const LEVEL_LABELS = Object.freeze({
|
|||||||
high: msg('Low-accuracy score > 10%'),
|
high: msg('Low-accuracy score > 10%'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A source failure (English data, source.js) in the UI language. */
|
||||||
|
function shownSourceError(text) {
|
||||||
|
const http = /^GNSS feed unavailable \(HTTP (\d{3})\)$/.exec(text);
|
||||||
|
return http
|
||||||
|
? t('GNSS feed unavailable (HTTP {status})', { status: http[1] })
|
||||||
|
: translateKnown(text);
|
||||||
|
}
|
||||||
|
|
||||||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
/** Degrees at the centre of the current view, or null before the camera settles. */
|
||||||
export function defaultGnssViewAnchor(viewer) {
|
export function defaultGnssViewAnchor(viewer) {
|
||||||
const canvas = viewer?.scene?.canvas;
|
const canvas = viewer?.scene?.canvas;
|
||||||
@@ -377,7 +385,7 @@ export function createGnssIntegrityLayer({
|
|||||||
'Regional sample · 250 nm radius · cells from visited views over 30 min',
|
'Regional sample · 250 nm radius · cells from visited views over 30 min',
|
||||||
) +
|
) +
|
||||||
(_lastError && _retryAt
|
(_lastError && _retryAt
|
||||||
? ` · ${translateKnown(_lastError)} · ${
|
? ` · ${shownSourceError(_lastError)} · ${
|
||||||
_retryAt <= 8640000000000000
|
_retryAt <= 8640000000000000
|
||||||
? t('Retrying automatically at {time} UTC', {
|
? t('Retrying automatically at {time} UTC', {
|
||||||
time: new Date(_retryAt).toISOString().slice(11, 19),
|
time: new Date(_retryAt).toISOString().slice(11, 19),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||||
|
|
||||||
|
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||||
|
// the Layers panel shows them translated.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
function retryDelay(raw, nowMs) {
|
function retryDelay(raw, nowMs) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const seconds = Number(raw);
|
const seconds = Number(raw);
|
||||||
@@ -21,7 +25,7 @@ export function createAdsbGnssSource({
|
|||||||
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
|
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||||
throw new Error('GNSS snapshot needs a view anchor');
|
throw new Error(msg('GNSS snapshot needs a view anchor'));
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
lat: String(Math.round(latitude)),
|
lat: String(Math.round(latitude)),
|
||||||
lon: String(Math.round(longitude)),
|
lon: String(Math.round(longitude)),
|
||||||
@@ -37,8 +41,10 @@ export function createAdsbGnssSource({
|
|||||||
);
|
);
|
||||||
throw Object.assign(
|
throw Object.assign(
|
||||||
new Error(
|
new Error(
|
||||||
|
// The status code form is matched by FEED_ERROR_PATTERNS
|
||||||
|
// (ui/layerPanel.js) and gnss/index.js.
|
||||||
limited
|
limited
|
||||||
? 'GNSS feed rate limited (HTTP 429)'
|
? msg('GNSS feed rate limited (HTTP 429)')
|
||||||
: `GNSS feed unavailable (HTTP ${response.status})`,
|
: `GNSS feed unavailable (HTTP ${response.status})`,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -55,12 +61,12 @@ export function createAdsbGnssSource({
|
|||||||
);
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(payload?.rows))
|
if (!Array.isArray(payload?.rows))
|
||||||
throw new Error('Malformed GNSS integrity snapshot');
|
throw new Error(msg('Malformed GNSS integrity snapshot'));
|
||||||
// The snapshot's age (measured on the proxy's clock) dates every row in
|
// The snapshot's age (measured on the proxy's clock) dates every row in
|
||||||
// the rolling window; an undated snapshot could only be treated as new,
|
// the rolling window; an undated snapshot could only be treated as new,
|
||||||
// so it is refused.
|
// so it is refused.
|
||||||
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
|
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
|
||||||
throw new Error('GNSS integrity snapshot has no observation time');
|
throw new Error(msg('GNSS integrity snapshot has no observation time'));
|
||||||
return {
|
return {
|
||||||
rows: payload.rows,
|
rows: payload.rows,
|
||||||
ageMs: payload.ageMs,
|
ageMs: payload.ageMs,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// N-03: the GNSS source's own failures are English data (source.js); the
|
||||||
|
// card's info line shows them in the UI language, the HTTP form included.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { setLocale } from '../../i18n/index.js';
|
||||||
|
import { createGnssIntegrityLayer } from './index.js';
|
||||||
|
|
||||||
|
async function failedLayer(message) {
|
||||||
|
const layer = createGnssIntegrityLayer({
|
||||||
|
source: {
|
||||||
|
async getSnapshot() {
|
||||||
|
throw Object.assign(new Error(message), {
|
||||||
|
retryable: true,
|
||||||
|
retryAfterMs: 60_000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
viewAnchor: () => ({ latitude: 50, longitude: 30 }),
|
||||||
|
now: () => 1_000,
|
||||||
|
monotonicNow: () => 1_000,
|
||||||
|
});
|
||||||
|
layer.init({
|
||||||
|
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
|
||||||
|
scene: {
|
||||||
|
globe: { show: true },
|
||||||
|
groundPrimitives: { add() {}, remove() {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layer.enable();
|
||||||
|
await layer.update();
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of ['es', 'ru']) {
|
||||||
|
test(`${locale}: the GNSS source failure is translated on the card`, async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
for (const message of [
|
||||||
|
'GNSS feed unavailable (HTTP 503)',
|
||||||
|
'GNSS feed rate limited (HTTP 429)',
|
||||||
|
]) {
|
||||||
|
const layer = await failedLayer(message);
|
||||||
|
setLocale(locale);
|
||||||
|
try {
|
||||||
|
const { info } = layer.getRowControls();
|
||||||
|
assert.ok(info.includes('HTTP'), info);
|
||||||
|
assert.doesNotMatch(info, /feed (unavailable|rate limited)/, info);
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
layer.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||||
|
// the Layers panel shows them translated.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
/** Read launch records and their optional active-orbit catalog with explicit cancellation. */
|
/** Read launch records and their optional active-orbit catalog with explicit cancellation. */
|
||||||
export function createLaunchSource({
|
export function createLaunchSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
@@ -10,7 +14,7 @@ export function createLaunchSource({
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||||
throw new Error('Malformed launch snapshot');
|
throw new Error(msg('Malformed launch snapshot'));
|
||||||
return {
|
return {
|
||||||
payload,
|
payload,
|
||||||
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
|
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { localReceiverFeedName } from './feedNames.js';
|
import { localReceiverFeedName } from './feedNames.js';
|
||||||
import { LAYER_SOURCE } from './policy.js';
|
import { LAYER_SOURCE } from './policy.js';
|
||||||
|
|
||||||
// Marks English that the Layers panel translates when it shows the hint.
|
// Marks English that the Layers panel translates when it shows the status.
|
||||||
|
// Texts with a number or a feed name stay English data too; the panel shows
|
||||||
|
// them through STATUS_PIECE_PATTERNS (ui/layerPanel.js), piece by piece.
|
||||||
const msg = (text) => text;
|
const msg = (text) => text;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,8 +11,8 @@ const msg = (text) => text;
|
|||||||
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
|
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const FEED_SOURCE = 'Decoder feeds';
|
export const FEED_SOURCE = msg('Decoder feeds');
|
||||||
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
|
export const COMBINED_SOURCE = msg('WebUSB + decoder feeds');
|
||||||
|
|
||||||
function plural(count, one, many) {
|
function plural(count, one, many) {
|
||||||
return count === 1 ? one : many;
|
return count === 1 ? one : many;
|
||||||
@@ -51,13 +53,13 @@ function webUsbStatus(receiver, heard, feedState) {
|
|||||||
if (receiver.status === 'error')
|
if (receiver.status === 'error')
|
||||||
return { status: 'error', error: receiver.message };
|
return { status: 'error', error: receiver.message };
|
||||||
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||||
return { loading: true, loadingLabel: 'opening receiver' };
|
return { loading: true, loadingLabel: msg('opening receiver') };
|
||||||
if (
|
if (
|
||||||
feedState?.polling &&
|
feedState?.polling &&
|
||||||
feedState.configured === null &&
|
feedState.configured === null &&
|
||||||
!receiver.connected
|
!receiver.connected
|
||||||
)
|
)
|
||||||
return { loading: true, loadingLabel: 'checking decoder feeds' };
|
return { loading: true, loadingLabel: msg('checking decoder feeds') };
|
||||||
if (!receiver.webUsbSupported)
|
if (!receiver.webUsbSupported)
|
||||||
return {
|
return {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@@ -72,7 +74,7 @@ function webUsbStatus(receiver, heard, feedState) {
|
|||||||
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
|
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
|
||||||
const rate = Number.isFinite(receiver.messagesPerSecond)
|
const rate = Number.isFinite(receiver.messagesPerSecond)
|
||||||
? `${receiver.messagesPerSecond} msg/s`
|
? `${receiver.messagesPerSecond} msg/s`
|
||||||
: 'listening';
|
: msg('listening');
|
||||||
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
|
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
|
|||||||
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
|
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
|
||||||
const live = feedState.feeds.filter((feed) => feed.status === 'live');
|
const live = feedState.feeds.filter((feed) => feed.status === 'live');
|
||||||
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
|
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
|
||||||
if (usbActive && receiver.status === 'error') problems.push('USB error');
|
if (usbActive && receiver.status === 'error') problems.push(msg('USB error'));
|
||||||
const rate =
|
const rate =
|
||||||
usbActive &&
|
usbActive &&
|
||||||
receiver.status !== 'error' &&
|
receiver.status !== 'error' &&
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// N-10: the Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
|
||||||
|
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) have no
|
||||||
|
// catalog entries and stay English in es/ru. Place: src/layers/localAdsb/statusLocale.test.mjs
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { setLocale } from '../../i18n/index.js';
|
||||||
|
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||||
|
import { localAdsbStatus } from './status.js';
|
||||||
|
|
||||||
|
const receiver = {
|
||||||
|
webUsbSupported: true,
|
||||||
|
connected: true,
|
||||||
|
mode: 'adsb',
|
||||||
|
status: 'streaming',
|
||||||
|
messagesPerSecond: 5.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowFor(status) {
|
||||||
|
return LayerPanel.prototype._buildMetaText({
|
||||||
|
id: 'local-adsb',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 3, lastUpdate: Date.now(), ...status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of ['es', 'ru']) {
|
||||||
|
test(`${locale}: the streaming Local ADS-B row is translated`, () => {
|
||||||
|
setLocale(locale);
|
||||||
|
try {
|
||||||
|
const row = rowFor(
|
||||||
|
localAdsbStatus({ receiver, feedState: null, heard: 3 }),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(row, /\bheard\b/, row);
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||||
|
|
||||||
|
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||||
|
// the Layers panel shows them translated.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
|
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
|
||||||
export function createWfigsPerimeterSource({
|
export function createWfigsPerimeterSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
@@ -16,7 +20,7 @@ export function createWfigsPerimeterSource({
|
|||||||
);
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(payload?.rows))
|
if (!Array.isArray(payload?.rows))
|
||||||
throw new Error('Malformed perimeter snapshot');
|
throw new Error(msg('Malformed perimeter snapshot'));
|
||||||
return payload.rows;
|
return payload.rows;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function createIngestion({
|
|||||||
|
|
||||||
// At least one group loaded — proceed with a fresh rebuild, but keep the
|
// At least one group loaded — proceed with a fresh rebuild, but keep the
|
||||||
// partial outage visible at the layer control instead of presenting the
|
// partial outage visible at the layer control instead of presenting the
|
||||||
// reduced catalog as a fully healthy refresh.
|
// reduced catalog as a fully healthy refresh. English data; the panel
|
||||||
|
// shows it through FEED_ERROR_PATTERNS (ui/layerPanel.js).
|
||||||
layerState._lastError = failed.length
|
layerState._lastError = failed.length
|
||||||
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
|
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -476,6 +476,8 @@ export function createIngestion({ state, services, parts, source }) {
|
|||||||
`[Data:Transit] ${feed.id} poll failed:`,
|
`[Data:Transit] ${feed.id} poll failed:`,
|
||||||
error?.message || error,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
|
// English data; the panel shows it through FEED_ERROR_PATTERNS
|
||||||
|
// (ui/layerPanel.js).
|
||||||
status.error = `${feed.name} feed unavailable`;
|
status.error = `${feed.name} feed unavailable`;
|
||||||
status.retryInSec = Number.isFinite(error?.retryInSec)
|
status.retryInSec = Number.isFinite(error?.retryInSec)
|
||||||
? error.retryInSec
|
? error.retryInSec
|
||||||
|
|||||||
@@ -246,9 +246,11 @@ export function createQueries({ state, parts }) {
|
|||||||
error: null,
|
error: null,
|
||||||
source: 'GTFS-RT',
|
source: 'GTFS-RT',
|
||||||
status: 'zoom-in',
|
status: 'zoom-in',
|
||||||
|
// English data (the analyst's empty note to the model); the voice
|
||||||
|
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
|
||||||
coverage: state._altitudeGateOpen
|
coverage: state._altitudeGateOpen
|
||||||
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
|
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
|
||||||
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString()} km to a covered region`,
|
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Age is shown by the same rule the row state uses, so the text and the
|
// Age is shown by the same rule the row state uses, so the text and the
|
||||||
@@ -276,6 +278,8 @@ export function createQueries({ state, parts }) {
|
|||||||
: {}),
|
: {}),
|
||||||
degraded: count > 0 && health.degraded,
|
degraded: count > 0 && health.degraded,
|
||||||
loading: health.loading && count === 0,
|
loading: health.loading && count === 0,
|
||||||
|
// English data; the panel shows it through LOADING_LABEL_PATTERNS
|
||||||
|
// (ui/layerPanel.js).
|
||||||
loadingLabel:
|
loadingLabel:
|
||||||
health.loading && count === 0
|
health.loading && count === 0
|
||||||
? `Loading ${active.map((feed) => feed.name).join(', ')}`
|
? `Loading ${active.map((feed) => feed.name).join(', ')}`
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// N-09: a scalar overlay that cannot be drawn reports a marked English status
|
||||||
|
// (rendering.js), which the Wind row shows through t() in the UI language.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { setLocale, t } from '../../i18n/index.js';
|
||||||
|
import { WIND_FIELDS } from './fields.js';
|
||||||
|
import { fieldUnavailableText } from './rendering.js';
|
||||||
|
|
||||||
|
for (const locale of ['es', 'ru']) {
|
||||||
|
test(`${locale}: every field's unavailable status has a translation`, () => {
|
||||||
|
setLocale(locale);
|
||||||
|
try {
|
||||||
|
const english = Object.keys(WIND_FIELDS)
|
||||||
|
.map(fieldUnavailableText)
|
||||||
|
.filter((text) => t(text) === text);
|
||||||
|
assert.deepEqual(english, []);
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,19 @@ import {
|
|||||||
windTrailColor,
|
windTrailColor,
|
||||||
} from './fields.js';
|
} from './fields.js';
|
||||||
|
|
||||||
|
// A scalar overlay that cannot be drawn, in English for diagnostics; the
|
||||||
|
// panel status shows it translated (index.js). The scalar texts are the ones
|
||||||
|
// the source reports (source.js).
|
||||||
|
const FIELD_UNAVAILABLE = Object.freeze({
|
||||||
|
speed: msg('Wind speed field unavailable'),
|
||||||
|
temperature: msg('Temperature field unavailable'),
|
||||||
|
pressure: msg('Mean sea level pressure field unavailable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** English status for a scalar overlay with no drawable field. */
|
||||||
|
export function fieldUnavailableText(overlay) {
|
||||||
|
return FIELD_UNAVAILABLE[overlay] || `${overlay} field unavailable`;
|
||||||
|
}
|
||||||
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
|
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
|
||||||
const WIND_FIELD_FADE_LOW_METERS = 200_000;
|
const WIND_FIELD_FADE_LOW_METERS = 200_000;
|
||||||
const WIND_FIELD_LOG_RANGE = Math.log(
|
const WIND_FIELD_LOG_RANGE = Math.log(
|
||||||
@@ -337,7 +350,7 @@ export function createWindRendering({
|
|||||||
}
|
}
|
||||||
const raster = createFieldRaster(snapshot, overlay);
|
const raster = createFieldRaster(snapshot, overlay);
|
||||||
if (!raster) {
|
if (!raster) {
|
||||||
imageryError = `${overlay} field unavailable`;
|
imageryError = fieldUnavailableText(overlay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken';
|
|||||||
// The Cockpit refusal per destination noun. Nouns are English identifiers
|
// The Cockpit refusal per destination noun. Nouns are English identifiers
|
||||||
// from the callers ('camera', 'location', 'photo'…); a noun without its own
|
// from the callers ('camera', 'location', 'photo'…); a noun without its own
|
||||||
// sentence falls back to the template with the noun as given.
|
// sentence falls back to the template with the noun as given.
|
||||||
|
// navigationPolicyFrame.test.mjs collects every noun the callers pass and
|
||||||
|
// fails when one has no sentence here.
|
||||||
const COCKPIT_REFUSALS = Object.freeze({
|
const COCKPIT_REFUSALS = Object.freeze({
|
||||||
camera: msg('Exit cockpit to fly to a camera'),
|
camera: msg('Exit cockpit to fly to a camera'),
|
||||||
location: msg('Exit cockpit to fly to a location'),
|
location: msg('Exit cockpit to fly to a location'),
|
||||||
@@ -29,8 +31,18 @@ const COCKPIT_REFUSALS = Object.freeze({
|
|||||||
// «View coverage» button); the cyclone layer flies to the selected storm.
|
// «View coverage» button); the cyclone layer flies to the selected storm.
|
||||||
weather: msg('Exit cockpit to view weather coverage'),
|
weather: msg('Exit cockpit to view weather coverage'),
|
||||||
cyclone: msg('Exit cockpit to fly to a cyclone'),
|
cyclone: msg('Exit cockpit to fly to a cyclone'),
|
||||||
|
// Voice track_entity (TRACKABLE_FAMILIES kinds in gevActions.js) and
|
||||||
|
// frame_overhead, which frames the contacts around the current view.
|
||||||
|
aircraft: msg('Exit cockpit to fly to an aircraft'),
|
||||||
|
satellite: msg('Exit cockpit to fly to a satellite'),
|
||||||
|
frame: msg('Exit cockpit to frame contacts'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The destination nouns with their own Cockpit refusal (for tests). */
|
||||||
|
export const COCKPIT_REFUSAL_NOUNS = Object.freeze(
|
||||||
|
Object.keys(COCKPIT_REFUSALS),
|
||||||
|
);
|
||||||
|
|
||||||
function cockpitRefusal(noun) {
|
function cockpitRefusal(noun) {
|
||||||
const ready = COCKPIT_REFUSALS[noun];
|
const ready = COCKPIT_REFUSALS[noun];
|
||||||
return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun });
|
return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun });
|
||||||
|
|||||||
@@ -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].classList.contains('own-styles'));
|
||||||
assert.ok(gpx.children[0].style.background);
|
assert.ok(gpx.children[0].style.background);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// N-08: tn() replaces values.count with the raw number, so the
|
||||||
|
// formatNumber(...) passed by the import list is lost: "12345 map objects"
|
||||||
|
// (main: "12,345"). Append to src/ui/geoFileImport.test.mjs (uses its setup(),
|
||||||
|
// file(), rows() helpers).
|
||||||
|
test('the map-object count in a row title keeps digit grouping', async () => {
|
||||||
|
const t = setup({
|
||||||
|
load: async (f) => ({
|
||||||
|
dataSource: {},
|
||||||
|
name: f.name,
|
||||||
|
format: 'geojson',
|
||||||
|
entityCount: 12345,
|
||||||
|
removed: 0,
|
||||||
|
revoke() {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await t.tool.importFiles([file('big.geojson')]);
|
||||||
|
const [row] = rows(t.els);
|
||||||
|
assert.match(row.children[1].title, /12,345 map objects/);
|
||||||
|
t.tool.destroy();
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
|||||||
'weather-satellite': msg('Satellite clouds'),
|
'weather-satellite': msg('Satellite clouds'),
|
||||||
'weather-lightning': msg('Lightning density'),
|
'weather-lightning': msg('Lightning density'),
|
||||||
'weather-cyclones': msg('Cyclone advisories'),
|
'weather-cyclones': msg('Cyclone advisories'),
|
||||||
|
'air-quality': msg('Air quality'),
|
||||||
directions: msg('Directions'),
|
directions: msg('Directions'),
|
||||||
'alpr-cameras': msg('ALPR Cameras'),
|
'alpr-cameras': msg('ALPR Cameras'),
|
||||||
});
|
});
|
||||||
@@ -116,6 +117,8 @@ const SOURCE_LABEL_KEYS = new Map(
|
|||||||
msg('OSM routing'),
|
msg('OSM routing'),
|
||||||
msg('Mapped camera locations'),
|
msg('Mapped camera locations'),
|
||||||
msg('OpenStreetMap · community mapped'),
|
msg('OpenStreetMap · community mapped'),
|
||||||
|
msg('Decoder feeds'),
|
||||||
|
msg('WebUSB + decoder feeds'),
|
||||||
].map((key) => [key, key]),
|
].map((key) => [key, key]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
getLocale,
|
||||||
msg,
|
msg,
|
||||||
t,
|
t,
|
||||||
|
tn,
|
||||||
translateKnown,
|
translateKnown,
|
||||||
} from '../i18n/index.js';
|
} from '../i18n/index.js';
|
||||||
|
|
||||||
@@ -187,6 +188,27 @@ const FEED_ERROR_PATTERNS = [
|
|||||||
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
|
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
|
||||||
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
|
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
|
||||||
],
|
],
|
||||||
|
// Layer statuses with a number or a feed name in them: GNSS
|
||||||
|
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
|
||||||
|
// feeds (transit/ingestion.js).
|
||||||
|
[
|
||||||
|
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
|
||||||
|
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+) CelesTrak groups? unavailable$/,
|
||||||
|
([, groups]) =>
|
||||||
|
tn(
|
||||||
|
Number(groups),
|
||||||
|
'{count} CelesTrak group unavailable',
|
||||||
|
'{count} CelesTrak groups unavailable',
|
||||||
|
{ count: formatNumber(Number(groups)) },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(.+) feed unavailable$/,
|
||||||
|
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
/^Source snapshot (\d+) min old$/,
|
/^Source snapshot (\d+) min old$/,
|
||||||
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
|
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
|
||||||
@@ -222,9 +244,8 @@ const FEED_ERROR_PATTERNS = [
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** A layer's error text in the UI language (see FEED_ERRORS). */
|
/** One error text in the UI language, or null when nothing matches. */
|
||||||
export function translateFeedError(text) {
|
function feedErrorText(text) {
|
||||||
if (typeof text !== 'string') return text;
|
|
||||||
if (FEED_ERRORS.has(text)) return t(text);
|
if (FEED_ERRORS.has(text)) return t(text);
|
||||||
const known = translateKnown(text);
|
const known = translateKnown(text);
|
||||||
if (known !== text) return known;
|
if (known !== text) return known;
|
||||||
@@ -232,17 +253,88 @@ export function translateFeedError(text) {
|
|||||||
const match = pattern.exec(text);
|
const match = pattern.exec(text);
|
||||||
if (match) return format(match);
|
if (match) return format(match);
|
||||||
}
|
}
|
||||||
return text;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
|
||||||
|
* several statuses joined by " · " (Local ADS-B) translates piece by piece.
|
||||||
|
*/
|
||||||
|
export function translateFeedError(text) {
|
||||||
|
if (typeof text !== 'string') return text;
|
||||||
|
const whole = feedErrorText(text);
|
||||||
|
if (whole !== null) return whole;
|
||||||
|
if (!text.includes(ROW_TEXT_SEPARATOR)) return text;
|
||||||
|
return text
|
||||||
|
.split(ROW_TEXT_SEPARATOR)
|
||||||
|
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
|
||||||
|
.join(ROW_TEXT_SEPARATOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_TEXT_SEPARATOR = ' · ';
|
const ROW_TEXT_SEPARATOR = ' · ';
|
||||||
|
|
||||||
|
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
|
||||||
|
const FEED_PROBLEM_FORMS = Object.freeze({
|
||||||
|
stale: (count, names) =>
|
||||||
|
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
|
||||||
|
unreachable: (count, names) =>
|
||||||
|
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
|
||||||
|
names,
|
||||||
|
}),
|
||||||
|
invalid: (count, names) =>
|
||||||
|
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Working texts with a number or a name in them arrive as English data, one
|
||||||
|
// piece of a " · " joined line each: transit (transit/queries.js) and Local
|
||||||
|
// ADS-B (localAdsb/status.js). Keep these in step with the producers.
|
||||||
|
const STATUS_PIECE_PATTERNS = [
|
||||||
|
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
|
||||||
|
[
|
||||||
|
/^(\d+) heard$/,
|
||||||
|
([, heard]) =>
|
||||||
|
tn(Number(heard), '{count} heard', '{count} heard', {
|
||||||
|
count: formatNumber(Number(heard)),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
|
||||||
|
([, usb, rate]) =>
|
||||||
|
usb
|
||||||
|
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
|
||||||
|
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+) feeds? live$/,
|
||||||
|
([, live]) =>
|
||||||
|
tn(Number(live), '{count} feed live', '{count} feeds live', {
|
||||||
|
count: formatNumber(Number(live)),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^feeds? (.+) (stale|unreachable|invalid)$/,
|
||||||
|
([, names, status]) =>
|
||||||
|
FEED_PROBLEM_FORMS[status](names.split(', ').length, names),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** One piece of a status line in the UI language, or as it came. */
|
||||||
|
function translateStatusPiece(piece) {
|
||||||
|
const known = translateKnown(piece);
|
||||||
|
if (known !== piece) return known;
|
||||||
|
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
|
||||||
|
const match = pattern.exec(piece);
|
||||||
|
if (match) return format(match);
|
||||||
|
}
|
||||||
|
return piece;
|
||||||
|
}
|
||||||
|
|
||||||
/** A loading label is English data, at times several joined by " · ". */
|
/** A loading label is English data, at times several joined by " · ". */
|
||||||
function translateLoadingLabel(label) {
|
function translateLoadingLabel(label) {
|
||||||
return label
|
return label
|
||||||
.trim()
|
.trim()
|
||||||
.split(ROW_TEXT_SEPARATOR)
|
.split(ROW_TEXT_SEPARATOR)
|
||||||
.map(translateKnown)
|
.map(translateStatusPiece)
|
||||||
.join(ROW_TEXT_SEPARATOR);
|
.join(ROW_TEXT_SEPARATOR);
|
||||||
}
|
}
|
||||||
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
||||||
|
|||||||
@@ -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
|
/* A translated label longer than its button wraps inside the button instead
|
||||||
of running past the panel edge. English labels fit on one line, so the
|
of running past the panel edge. English keeps its one-line buttons (unguarded,
|
||||||
English layout is unchanged. */
|
this rule broke AUTO HOP OFF onto two lines). */
|
||||||
.cctv-controls .scene-btn {
|
html:not([lang='en']) .cctv-controls .scene-btn {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1836,9 +1836,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sits above the language picker row in the bottom-right corner
|
||||||
|
(locale-picker.css: 0.9rem inset + one ~2rem button + a gap). */
|
||||||
#intel-hud[data-variant='cyber'] .hud-bottom-right {
|
#intel-hud[data-variant='cyber'] .hud-bottom-right {
|
||||||
right: 28px;
|
right: 28px;
|
||||||
bottom: 28px;
|
bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2735,6 +2737,9 @@
|
|||||||
|
|
||||||
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout {
|
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout {
|
||||||
right: 30px;
|
right: 30px;
|
||||||
|
/* Clears the language picker row it shares the corner with, as in the
|
||||||
|
other themes (locale-picker.css). */
|
||||||
|
bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help {
|
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help {
|
||||||
|
|||||||
@@ -210,3 +210,10 @@ test('K-44: Recent Imagery readout keeps its English layout', () => {
|
|||||||
assert.equal(declaration(swap, 'width'), '40px');
|
assert.equal(declaration(swap, 'width'), '40px');
|
||||||
assert.equal(declaration(swap, 'min-width'), undefined);
|
assert.equal(declaration(swap, 'min-width'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('N-13: CCTV control buttons stay on one line in English', () => {
|
||||||
|
assert.equal(
|
||||||
|
unguardedRules(css('cctv.css'), '.cctv-controls .scene-btn').length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
return tn(count, '{n} datacenters', '{n} datacenters', values);
|
||||||
case 'dams':
|
case 'dams':
|
||||||
return tn(count, '{n} dams', '{n} dams', values);
|
return tn(count, '{n} dams', '{n} dams', values);
|
||||||
|
case 'air quality':
|
||||||
|
return tn(count, '{n} air quality', '{n} air quality', values);
|
||||||
case 'results':
|
case 'results':
|
||||||
return tn(count, '{n} results', '{n} results', values);
|
return tn(count, '{n} results', '{n} results', values);
|
||||||
default:
|
default:
|
||||||
@@ -328,6 +330,27 @@ const NOTE_PATTERNS = [
|
|||||||
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
|
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||||
)) + period,
|
)) + period,
|
||||||
],
|
],
|
||||||
|
// Transit's empty note when no feed covers the view (transit/queries.js);
|
||||||
|
// the model gets the English.
|
||||||
|
[
|
||||||
|
/^Fly below ([\d,]+) km to a covered region$/,
|
||||||
|
([, km]) =>
|
||||||
|
t('Fly below {km} km to a covered region', {
|
||||||
|
km: formatNumber(Number(km.replace(/,/g, ''))),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^No feed here yet · ([\d,]+) regions available$/,
|
||||||
|
([, regions]) => {
|
||||||
|
const count = Number(regions.replace(/,/g, ''));
|
||||||
|
return tn(
|
||||||
|
count,
|
||||||
|
'No feed here yet · {n} region available',
|
||||||
|
'No feed here yet · {n} regions available',
|
||||||
|
{ n: formatNumber(count) },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
/^([a-z0-9-]+) (off|loading|error)$/,
|
/^([a-z0-9-]+) (off|loading|error)$/,
|
||||||
([, key, status]) =>
|
([, key, status]) =>
|
||||||
|
|||||||
+38
-7
@@ -344,13 +344,38 @@ function shownPair(items) {
|
|||||||
: items[0] || '';
|
: items[0] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ids such as `austin` or `local-firms` read as names on screen. */
|
/**
|
||||||
// A place as the card shows it: a preset city in the interface language,
|
* The preset city ids the voice tools take (the `locationId` enum in
|
||||||
// whether the call named it ("Tokyo") or gave its id ("tokyo").
|
* actionSchemas.js) and the CITY_POIS names they stand for, so `sf` reads
|
||||||
function shownPlace(value, max) {
|
* "San Francisco", not "Sf". A copy, not an import: src/locations.js pulls in
|
||||||
return localizePlaceName(displayName(value, max));
|
* Cesium. planStepPresetIds.i18n.test.mjs keeps it in step with CITY_POIS.
|
||||||
|
*/
|
||||||
|
const PRESET_PLACE_NAMES = Object.freeze({
|
||||||
|
austin: 'Austin',
|
||||||
|
sf: 'San Francisco',
|
||||||
|
nyc: 'New York',
|
||||||
|
tokyo: 'Tokyo',
|
||||||
|
london: 'London',
|
||||||
|
paris: 'Paris',
|
||||||
|
dubai: 'Dubai',
|
||||||
|
dc: 'Washington DC',
|
||||||
|
tallinn: 'Tallinn',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A preset city id as its English name; any other value unchanged. */
|
||||||
|
function presetPlaceName(value) {
|
||||||
|
const id = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return Object.hasOwn(PRESET_PLACE_NAMES, id) ? PRESET_PLACE_NAMES[id] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A place as the card shows it: a preset city in the interface language,
|
||||||
|
// whether the call named it ("Tokyo") or gave its id ("tokyo", "nyc").
|
||||||
|
function shownPlace(value, max) {
|
||||||
|
return localizePlaceName(displayName(presetPlaceName(value), max));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids such as `austin` or `local-firms` read as names on screen. */
|
||||||
|
|
||||||
function displayName(value, max) {
|
function displayName(value, max) {
|
||||||
const label = spokenLabel(value, max);
|
const label = spokenLabel(value, max);
|
||||||
return /^[a-z0-9-]+$/.test(label)
|
return /^[a-z0-9-]+$/.test(label)
|
||||||
@@ -915,9 +940,15 @@ export function narrationLabel(name, args = {}) {
|
|||||||
case 'annotate_map':
|
case 'annotate_map':
|
||||||
return annotationTargets(args)[0] || '';
|
return annotationTargets(args)[0] || '';
|
||||||
case 'fly_to_location':
|
case 'fly_to_location':
|
||||||
return displayName(args.query || args.locationId || '', 32);
|
return displayName(
|
||||||
|
presetPlaceName(args.query || args.locationId || ''),
|
||||||
|
32,
|
||||||
|
);
|
||||||
case 'select_nearest_aircraft':
|
case 'select_nearest_aircraft':
|
||||||
return displayName(args.locationQuery || args.locationId || '', 32);
|
return displayName(
|
||||||
|
presetPlaceName(args.locationQuery || args.locationId || ''),
|
||||||
|
32,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user