i18n: fix the final pre-merge review findings

Fixes the P1 and the confirmed P2 findings of the final review of the
i18n branch.

- CI: the air-quality model no longer imports i18n (portable module),
  so check:boundaries passes again.
- Air quality layer: named in the layer panel, HUD and voice; its
  Weather card, bands and errors translate. A test keeps the layer-name
  table in step with the layer registry.
- Layer statuses that still reached ru/es in English: GNSS, CelesTrak,
  Transit loading and feed errors, launches, perimeters, ALPR, ECCC,
  wind "field unavailable", Local ADS-B streaming lines.
- Voice and HUD: preset ids show city names ("Fly to San Francisco"),
  the HUD city translates, cockpit refusals cover frame, aircraft and
  satellite; empty Transit notes translate on the analyst card.
- tn() keeps a caller's formatted count ("12,345 map objects").
- Cyber HUD: the language button no longer covers the bottom-right
  card or the cockpit position readout.
- CCTV buttons wrap only in non-English locales; English layout matches
  main again.
- Switching language asks first when Provider Settings holds unsaved
  keys.

Catalogs: es/ru 2611 messages each, complete.

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

No files matched your search

@@ -0,0 +1,91 @@
// N-02: the Air quality layer (merged from main, AQHI) is fully English in
// es/ru: layer name, Weather card (title, coverage, status, legend, risk) and
// its own failure text. Place: src/layers/airQuality/airQualityLocale.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../../i18n/index.js';
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
function harness(source) {
const layer = createAirQualityLayer({
source,
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
const viewer = { dataSources: { add() {}, remove() {} } };
layer.init(viewer);
layer.enable(viewer);
return layer;
}
const reading = {
id: 'eccc:A',
provider: 'eccc',
scale: 'AQHI',
value: 2,
band: 'low',
stationId: 'A',
name: 'Toronto',
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: '2',
risk: 'Low health risk',
note: null,
};
for (const locale of ['es', 'ru']) {
test(`${locale}: the layer name is translated`, () => {
inLocale(locale, () => {
assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry');
assert.equal(layerNameForId('air-quality'), t('Air quality'));
assert.equal(localizeLayerName('Air quality'), t('Air quality'));
});
});
test(`${locale}: the Weather card of Air quality is translated`, async () => {
const layer = harness({ getSnapshot: async () => [reading] });
await layer.update();
const controls = inLocale(locale, () => layer.getRowControls());
const english = [
controls.summary.label,
controls.summary.coverage,
controls.summary.status,
...controls.legend.map((entry) => entry.label),
...controls.list.items.map((item) => item.text),
].filter((text) =>
/\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test(
text,
),
);
assert.deepEqual(english, [], `English left in the ${locale} card`);
});
test(`${locale}: the Air quality failure text is translated in the row`, () => {
const row = inLocale(locale, () =>
LayerPanel.prototype._buildMetaText({
id: 'air-quality',
enabled: true,
stats: {
count: 0,
error: 'Air quality request failed',
source: 'ECCC',
lastUpdate: null,
},
}),
);
assert.ok(!row.includes('Air quality request failed'), row);
});
}
+17 -10
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { msg, t, tn, translateKnown } from '../../i18n/index.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -110,7 +110,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
return true;
} catch (error) {
if (request.signal.aborted || _request !== request) return false;
_lastError = error?.message || 'Air quality request failed';
_lastError = error?.message || msg('Air quality request failed');
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify();
return false;
@@ -215,25 +215,32 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
(max, reading) => Math.max(max, reading.observedMs || 0),
0,
);
// Readings and errors stay English data (analyst records, stats); the
// card shows them in the UI language. A provider's own error text has
// no catalog entry and shows as it came.
return {
readout: true,
summary: {
label: provider.label,
coverage: provider.coverage,
label: t(provider.label),
coverage: t(provider.coverage),
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? _lastError
? translateKnown(_lastError)
: _count === 0
? _lastUpdate === null
? 'No readings available'
: 'No current readings'
: `${_count} stations reporting`,
? t('No readings available')
: t('No current readings')
: tn(
_count,
'{count} station reporting',
'{count} stations reporting',
),
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: band.label,
label: t(band.label),
})),
legendCategorical: true,
list: {
@@ -242,7 +249,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk}`,
text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`,
accent: reading.color,
})),
},
+29 -8
View File
@@ -15,14 +15,36 @@
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against.
*/
import { msg } from '../../i18n/index.js';
// A portable export (package.json): no i18n here. The labels stay English data
// and the Layers panel shows them translated.
const msg = (text) => text;
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: msg('Low'), color: '#4cc9f0' }),
Object.freeze({ id: 'moderate', label: msg('Moderate'), color: '#ffd43b' }),
Object.freeze({ id: 'high', label: msg('High'), color: '#ff6b35' }),
Object.freeze({ id: 'very-high', label: msg('Very High'), color: '#c1121f' }),
Object.freeze({
id: 'low',
label: msg('Low'),
risk: msg('Low health risk'),
color: '#4cc9f0',
}),
Object.freeze({
id: 'moderate',
label: msg('Moderate'),
risk: msg('Moderate health risk'),
color: '#ffd43b',
}),
Object.freeze({
id: 'high',
label: msg('High'),
risk: msg('High health risk'),
color: '#ff6b35',
}),
Object.freeze({
id: 'very-high',
label: msg('Very High'),
risk: msg('Very High health risk'),
color: '#c1121f',
}),
]);
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
@@ -92,7 +114,7 @@ export const AIR_QUALITY_PROVIDERS = Object.freeze({
scale: 'AQHI',
label: msg('Air quality'),
network: 'Environment and Climate Change Canada',
coverage: 'Canada only · ECCC AQHI network',
coverage: msg('Canada only · ECCC AQHI network'),
/** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }),
@@ -162,8 +184,7 @@ export function valueLabel(providerId, value) {
/** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) {
const band = airQualityBandById(bandId);
return band ? `${band.label} health risk` : null;
return airQualityBandById(bandId)?.risk ?? null;
}
/**
+10 -4
View File
@@ -1,6 +1,10 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel and the Weather card show them translated.
const msg = (text) => text;
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('ECCC request timed out')),
() => controller.abort(new Error(msg('ECCC request timed out'))),
timeoutMs,
);
try {
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
signal,
);
const next = normalizeAqhiStations(payload);
if (!next) throw new Error('Malformed AQHI station response');
if (!next) throw new Error(msg('Malformed AQHI station response'));
// An empty refresh must not wipe a good catalog.
if (next.size > 0) {
stations = next;
stationsFetchedAt = clock;
}
}
if (stations.size === 0) throw new Error('No AQHI stations available');
if (stations.size === 0)
throw new Error(msg('No AQHI stations available'));
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
if (!readings) throw new Error('Malformed AQHI observation response');
if (!readings)
throw new Error(msg('Malformed AQHI observation response'));
return readings;
},
};
+1 -1
View File
@@ -245,7 +245,7 @@ export function createOverpassAlprSource({
// The shared proxy already rejects query errors. Validate here too so a
// malformed or partial response never becomes an authoritative empty map.
if (!Array.isArray(payload?.elements) || payload.remark) {
throw new Error('Overpass returned an incomplete camera response');
throw new Error(msg('Overpass returned an incomplete camera response'));
}
return {
records: [
+9 -1
View File
@@ -31,6 +31,14 @@ const LEVEL_LABELS = Object.freeze({
high: msg('Low-accuracy score > 10%'),
});
/** A source failure (English data, source.js) in the UI language. */
function shownSourceError(text) {
const http = /^GNSS feed unavailable \(HTTP (\d{3})\)$/.exec(text);
return http
? t('GNSS feed unavailable (HTTP {status})', { status: http[1] })
: translateKnown(text);
}
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
@@ -377,7 +385,7 @@ export function createGnssIntegrityLayer({
'Regional sample · 250 nm radius · cells from visited views over 30 min',
) +
(_lastError && _retryAt
? ` · ${translateKnown(_lastError)} · ${
? ` · ${shownSourceError(_lastError)} · ${
_retryAt <= 8640000000000000
? t('Retrying automatically at {time} UTC', {
time: new Date(_retryAt).toISOString().slice(11, 19),
+10 -4
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
function retryDelay(raw, nowMs) {
if (!raw) return null;
const seconds = Number(raw);
@@ -21,7 +25,7 @@ export function createAdsbGnssSource({
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error('GNSS snapshot needs a view anchor');
throw new Error(msg('GNSS snapshot needs a view anchor'));
const params = new URLSearchParams({
lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)),
@@ -37,8 +41,10 @@ export function createAdsbGnssSource({
);
throw Object.assign(
new Error(
// The status code form is matched by FEED_ERROR_PATTERNS
// (ui/layerPanel.js) and gnss/index.js.
limited
? 'GNSS feed rate limited (HTTP 429)'
? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`,
),
{
@@ -55,12 +61,12 @@ export function createAdsbGnssSource({
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
throw new Error(msg('Malformed GNSS integrity snapshot'));
// The snapshot's age (measured on the proxy's clock) dates every row in
// the rolling window; an undated snapshot could only be treated as new,
// so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
throw new Error('GNSS integrity snapshot has no observation time');
throw new Error(msg('GNSS integrity snapshot has no observation time'));
return {
rows: payload.rows,
ageMs: payload.ageMs,
@@ -0,0 +1,54 @@
// N-03: the GNSS source's own failures are English data (source.js); the
// card's info line shows them in the UI language, the HTTP form included.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { setLocale } from '../../i18n/index.js';
import { createGnssIntegrityLayer } from './index.js';
async function failedLayer(message) {
const layer = createGnssIntegrityLayer({
source: {
async getSnapshot() {
throw Object.assign(new Error(message), {
retryable: true,
retryAfterMs: 60_000,
});
},
},
viewAnchor: () => ({ latitude: 50, longitude: 30 }),
now: () => 1_000,
monotonicNow: () => 1_000,
});
layer.init({
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: { add() {}, remove() {} },
},
});
layer.enable();
await layer.update();
return layer;
}
for (const locale of ['es', 'ru']) {
test(`${locale}: the GNSS source failure is translated on the card`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
for (const message of [
'GNSS feed unavailable (HTTP 503)',
'GNSS feed rate limited (HTTP 429)',
]) {
const layer = await failedLayer(message);
setLocale(locale);
try {
const { info } = layer.getRowControls();
assert.ok(info.includes('HTTP'), info);
assert.doesNotMatch(info, /feed (unavailable|rate limited)/, info);
} finally {
setLocale('en');
layer.destroy();
}
}
});
}
+5 -1
View File
@@ -1,3 +1,7 @@
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/** Read launch records and their optional active-orbit catalog with explicit cancellation. */
export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -10,7 +14,7 @@ export function createLaunchSource({
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
throw new Error(msg('Malformed launch snapshot'));
return {
payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
+9 -7
View File
@@ -1,7 +1,9 @@
import { localReceiverFeedName } from './feedNames.js';
import { LAYER_SOURCE } from './policy.js';
// Marks English that the Layers panel translates when it shows the hint.
// Marks English that the Layers panel translates when it shows the status.
// Texts with a number or a feed name stay English data too; the panel shows
// them through STATUS_PIECE_PATTERNS (ui/layerPanel.js), piece by piece.
const msg = (text) => text;
/**
@@ -9,8 +11,8 @@ const msg = (text) => text;
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
*/
export const FEED_SOURCE = 'Decoder feeds';
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
export const FEED_SOURCE = msg('Decoder feeds');
export const COMBINED_SOURCE = msg('WebUSB + decoder feeds');
function plural(count, one, many) {
return count === 1 ? one : many;
@@ -51,13 +53,13 @@ function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: 'opening receiver' };
return { loading: true, loadingLabel: msg('opening receiver') };
if (
feedState?.polling &&
feedState.configured === null &&
!receiver.connected
)
return { loading: true, loadingLabel: 'checking decoder feeds' };
return { loading: true, loadingLabel: msg('checking decoder feeds') };
if (!receiver.webUsbSupported)
return {
status: 'idle',
@@ -72,7 +74,7 @@ function webUsbStatus(receiver, heard, feedState) {
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: 'listening';
: msg('listening');
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
}
@@ -103,7 +105,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
const live = feedState.feeds.filter((feed) => feed.status === 'live');
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
if (usbActive && receiver.status === 'error') problems.push('USB error');
if (usbActive && receiver.status === 'error') problems.push(msg('USB error'));
const rate =
usbActive &&
receiver.status !== 'error' &&
@@ -0,0 +1,38 @@
// N-10: the Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) have no
// catalog entries and stay English in es/ru. Place: src/layers/localAdsb/statusLocale.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
const receiver = {
webUsbSupported: true,
connected: true,
mode: 'adsb',
status: 'streaming',
messagesPerSecond: 5.8,
};
function rowFor(status) {
return LayerPanel.prototype._buildMetaText({
id: 'local-adsb',
enabled: true,
stats: { count: 3, lastUpdate: Date.now(), ...status },
});
}
for (const locale of ['es', 'ru']) {
test(`${locale}: the streaming Local ADS-B row is translated`, () => {
setLocale(locale);
try {
const row = rowFor(
localAdsbStatus({ receiver, feedState: null, heard: 3 }),
);
assert.doesNotMatch(row, /\bheard\b/, row);
} finally {
setLocale('en');
}
});
}
+5 -1
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -16,7 +20,7 @@ export function createWfigsPerimeterSource({
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed perimeter snapshot');
throw new Error(msg('Malformed perimeter snapshot'));
return payload.rows;
},
};
+2 -1
View File
@@ -71,7 +71,8 @@ export function createIngestion({
// At least one group loaded — proceed with a fresh rebuild, but keep the
// partial outage visible at the layer control instead of presenting the
// reduced catalog as a fully healthy refresh.
// reduced catalog as a fully healthy refresh. English data; the panel
// shows it through FEED_ERROR_PATTERNS (ui/layerPanel.js).
layerState._lastError = failed.length
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null;
+2
View File
@@ -476,6 +476,8 @@ export function createIngestion({ state, services, parts, source }) {
`[Data:Transit] ${feed.id} poll failed:`,
error?.message || error,
);
// English data; the panel shows it through FEED_ERROR_PATTERNS
// (ui/layerPanel.js).
status.error = `${feed.name} feed unavailable`;
status.retryInSec = Number.isFinite(error?.retryInSec)
? error.retryInSec
+5 -1
View File
@@ -246,9 +246,11 @@ export function createQueries({ state, parts }) {
error: null,
source: 'GTFS-RT',
status: 'zoom-in',
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString()} km to a covered region`,
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
};
}
// Age is shown by the same rule the row state uses, so the text and the
@@ -276,6 +278,8 @@ export function createQueries({ state, parts }) {
: {}),
degraded: count > 0 && health.degraded,
loading: health.loading && count === 0,
// English data; the panel shows it through LOADING_LABEL_PATTERNS
// (ui/layerPanel.js).
loadingLabel:
health.loading && count === 0
? `Loading ${active.map((feed) => feed.name).join(', ')}`
@@ -0,0 +1,21 @@
// N-09: a scalar overlay that cannot be drawn reports a marked English status
// (rendering.js), which the Wind row shows through t() in the UI language.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js';
import { WIND_FIELDS } from './fields.js';
import { fieldUnavailableText } from './rendering.js';
for (const locale of ['es', 'ru']) {
test(`${locale}: every field's unavailable status has a translation`, () => {
setLocale(locale);
try {
const english = Object.keys(WIND_FIELDS)
.map(fieldUnavailableText)
.filter((text) => t(text) === text);
assert.deepEqual(english, []);
} finally {
setLocale('en');
}
});
}
+14 -1
View File
@@ -14,6 +14,19 @@ import {
windTrailColor,
} from './fields.js';
// A scalar overlay that cannot be drawn, in English for diagnostics; the
// panel status shows it translated (index.js). The scalar texts are the ones
// the source reports (source.js).
const FIELD_UNAVAILABLE = Object.freeze({
speed: msg('Wind speed field unavailable'),
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
});
/** English status for a scalar overlay with no drawable field. */
export function fieldUnavailableText(overlay) {
return FIELD_UNAVAILABLE[overlay] || `${overlay} field unavailable`;
}
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
const WIND_FIELD_FADE_LOW_METERS = 200_000;
const WIND_FIELD_LOG_RANGE = Math.log(
@@ -337,7 +350,7 @@ export function createWindRendering({
}
const raster = createFieldRaster(snapshot, overlay);
if (!raster) {
imageryError = `${overlay} field unavailable`;
imageryError = fieldUnavailableText(overlay);
return;
}
if (