i18n: fix the repeat-review findings

- Status pieces translate in one place (src/ui/statusText.js): a Local
  ADS-B error with a single piece now translates, and the analyst card
  splits empty-layer notes into pieces (Transit, Local ADS-B).
- Translate the ALPR retry chip text, the ECCC error on the Air quality
  card, "Upstream response too large" and the "entry {n}" feed label.
- The unmarked-text check also sees error/loadingLabel/statusMessage/
  status assignments, `status:` properties and showToast?.( /
  this._showToast( calls; the scene import preview marks its pack
  statuses, data and model-facing text carry i18n-ignore with a reason.
- Tests check the real code: the HUD locality formatter moves to
  src/hudLocalityFormat.js, the unsaved-keys test pins the teardown.
- One copy each: city names come from src/cityPois.js, the GNSS HTTP
  template and wind "field unavailable" texts; dead fallbacks removed.
- Leftovers of the reverted vessel-label change and the reformatting of
  detectionHost.test.mjs / keySetup.test.mjs are back to main.

Catalogs: es/ru 2617 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 19:28:59 +04:00
1 parent a2463ca2d2
commit 3157888780
60 files changed
+1577 -1606

No files matched your search

@@ -1,11 +1,11 @@
// 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
// The Air quality layer (AQHI) reads in the UI language: layer name, Weather
// card (title, coverage, status, legend, risk) and its own failure text.
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 { translateFeedError } from '../../ui/statusText.js';
import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) {
@@ -89,3 +89,20 @@ for (const locale of ['es', 'ru']) {
assert.ok(!row.includes('Air quality request failed'), row);
});
}
for (const locale of ['en', 'es', 'ru']) {
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
const layer = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
const card = inLocale(locale, () => layer.getRowControls().summary.status);
assert.equal(
card,
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
);
assert.equal(card, locale === 'en' ? 'ECCC HTTP 503' : 'ECCC: HTTP 503');
});
}
+7 -5
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { msg, t, tn, translateKnown } from '../../i18n/index.js';
import { msg, t, tn } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -216,8 +217,9 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
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.
// card shows them in the UI language, errors as the Layers panel row
// does. A provider's own error text has no catalog entry and shows as
// it came.
return {
readout: true,
summary: {
@@ -226,7 +228,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? translateKnown(_lastError)
? translateFeedError(_lastError)
: _count === 0
? _lastUpdate === null
? t('No readings available')
@@ -249,7 +251,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk ? t(reading.risk) : reading.risk}`,
text: `${reading.name} · ${t(reading.risk)}`,
accent: reading.color,
})),
},
+1
View File
@@ -132,6 +132,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
+3 -10
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { formatNumber, msg, t, translateKnown } from '../../i18n/index.js';
import { formatNumber, msg, t } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
@@ -31,14 +32,6 @@ 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;
@@ -385,7 +378,7 @@ export function createGnssIntegrityLayer({
'Regional sample · 250 nm radius · cells from visited views over 30 min',
) +
(_lastError && _retryAt
? ` · ${shownSourceError(_lastError)} · ${
? ` · ${translateFeedError(_lastError)} · ${
_retryAt <= 8640000000000000
? t('Retrying automatically at {time} UTC', {
time: new Date(_retryAt).toISOString().slice(11, 19),
+1 -1
View File
@@ -42,7 +42,7 @@ 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.
// (ui/statusText.js) and gnss/index.js.
limited
? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`,
+1 -1
View File
@@ -1,4 +1,4 @@
// N-03: the GNSS source's own failures are English data (source.js); the
// 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';
+1
View File
@@ -192,6 +192,7 @@ export function createModel({ state: layerState, services, parts, source }) {
launch.id || launch.slug || launch.name || `launch-${date}`,
),
name: launch.name || 'Unnamed launch',
// i18n-ignore: Launch Library status names are provider data, shown as sent
status: launch.status?.name || 'Unknown',
launchTime: Number.isFinite(date)
? new Date(date).toISOString()
@@ -0,0 +1,41 @@
// A Local ADS-B feed problem can be the whole error (nothing heard, WebUSB
// not producing); the row shows it in the UI language like any other piece.
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: false,
mode: 'adsb',
status: 'idle',
};
function rowFor(status) {
return LayerPanel.prototype._buildMetaText({
id: 'local-adsb',
enabled: true,
stats: { count: 0, lastUpdate: Date.now(), ...status },
});
}
for (const locale of ['es', 'ru']) {
for (const status of ['unreachable', 'invalid']) {
test(`${locale}: a lone ${status} decoder feed with nothing heard is translated`, () => {
setLocale(locale);
try {
const st = localAdsbStatus({
receiver,
feedState: {
configured: true,
feeds: [{ id: 'a', label: '1090', band: '1090', status }],
},
heard: 0,
});
const row = rowFor(st);
assert.doesNotMatch(row, /\b(unreachable|invalid)\b/, row);
} finally {
setLocale('en');
}
});
}
}
+3 -1
View File
@@ -3,7 +3,7 @@ import { LAYER_SOURCE } from './policy.js';
// 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.
// them through STATUS_PIECE_PATTERNS (ui/statusText.js), piece by piece.
const msg = (text) => text;
/**
@@ -75,6 +75,7 @@ function webUsbStatus(receiver, heard, feedState) {
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: msg('listening');
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
}
@@ -117,6 +118,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
return {
source,
status: 'streaming',
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
// 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
// The Local ADS-B row's working texts ("3 heard · 5.8 msg/s",
// "2 feeds live · 14 heard", "listening", "USB error", feed problems) read in
// the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
+1
View File
@@ -125,6 +125,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
+1 -1
View File
@@ -72,7 +72,7 @@ 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. English data; the panel
// shows it through FEED_ERROR_PATTERNS (ui/layerPanel.js).
// shows it through FEED_ERROR_PATTERNS (ui/statusText.js).
layerState._lastError = failed.length
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null;
+1 -1
View File
@@ -477,7 +477,7 @@ export function createIngestion({ state, services, parts, source }) {
error?.message || error,
);
// English data; the panel shows it through FEED_ERROR_PATTERNS
// (ui/layerPanel.js).
// i18n-ignore: translated on display (ui/statusText.js)
status.error = `${feed.name} feed unavailable`;
status.retryInSec = Number.isFinite(error?.retryInSec)
? error.retryInSec
+3 -2
View File
@@ -278,9 +278,10 @@ 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).
// English data; the panel shows it through STATUS_PIECE_PATTERNS
// (ui/statusText.js).
loadingLabel:
// i18n-ignore: translated on display
health.loading && count === 0
? `Loading ${active.map((feed) => feed.name).join(', ')}`
: undefined,
@@ -1,71 +0,0 @@
// The speed unit of a vessel's detection callout follows the UI language.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import aisLiveVesselsLayer, {
_setVesselOverlayHostForTest,
_setVesselStateForTest,
_updateVesselCardsForTest,
} from '../../data/aisLiveVessels.js';
import { setLocale } from '../../i18n/index.js';
const record = (mmsi, name, screen, z = 0) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: { x: 0, y: 0, z, screen },
billboard: null,
});
function detectable(cameraZ) {
const carded = record('200', 'GOLDEN BEAR', { x: 40, y: 30 });
const plain = record('300', 'BAY PILOT', { x: 41, y: 31 });
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) =>
position.screen;
_setVesselOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
_setVesselStateForTest({
viewer: {
scene: {
canvas: { clientWidth: 1600, clientHeight: 900 },
camera: { positionWC: new Cesium.Cartesian3(0, 0, cameraZ) },
},
},
records: [carded, plain],
billboardCollection: { show: true, remove() {} },
});
try {
// Both share one 118 px grid cell, so only one of them gets a card.
_updateVesselCardsForTest([carded, plain]);
return Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
} finally {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
_setVesselStateForTest({ enabled: false });
_setVesselOverlayHostForTest();
}
}
test('ru: the callout speed unit is translated', () => {
setLocale('ru');
try {
const objects = detectable(4_000_000);
assert.equal(objects['GOLDEN BEAR'].metric, '10 уз');
} finally {
setLocale('en');
}
assert.equal(detectable(4_000_000)['GOLDEN BEAR'].metric, '10 kn');
});
-1
View File
@@ -69,7 +69,6 @@ export function createVesselState({ source, services }) {
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
activeFocusCount: 0,
activeLabelCount: 0,
/** @type {Set<Object>} Records that have an ambient card this pass. */
selectedRecord: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null,
@@ -1,10 +1,11 @@
// N-09: a scalar overlay that cannot be drawn reports a marked English status
// 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';
import { SCALAR_UNAVAILABLE } from './source.js';
for (const locale of ['es', 'ru']) {
test(`${locale}: every field's unavailable status has a translation`, () => {
@@ -19,3 +20,10 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('only a known overlay has an unavailable status', () => {
assert.equal(fieldUnavailableText('constructor'), null);
assert.equal(fieldUnavailableText('none'), null);
for (const [overlay, text] of Object.entries(SCALAR_UNAVAILABLE))
assert.equal(fieldUnavailableText(overlay), text);
});
+1
View File
@@ -205,6 +205,7 @@ export function createWindGpuRendering({ cesium: C, getViewer }) {
} catch (error) {
clear();
diagnostics.error =
// i18n-ignore: GPU diagnostics only; the panel never shows this field
error instanceof Error ? error.message : 'Wind geometry unavailable';
return false;
}
+1
View File
@@ -91,6 +91,7 @@ export function createWindLayer({
model: model === 'ifs' ? 'ECMWF' : 'GFS',
validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
// i18n-ignore: English reading for AI tools; the card status uses t()
status: loading
? 'Loading forecast'
: error ||
+6 -4
View File
@@ -7,6 +7,7 @@ import { orderWeatherImagery } from '../weather/imageryOrder.js';
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
import { msg } from '../../i18n/index.js';
import { createWindGpuRendering } from './gpuRendering.js';
import { SCALAR_UNAVAILABLE } from './source.js';
import { advectParticle, sampleWind } from './model.js';
import {
createFieldRaster,
@@ -16,16 +17,17 @@ import {
// 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).
// the source reports. setOptions only accepts these overlays.
const FIELD_UNAVAILABLE = Object.freeze({
speed: msg('Wind speed field unavailable'),
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
...SCALAR_UNAVAILABLE,
});
/** English status for a scalar overlay with no drawable field. */
export function fieldUnavailableText(overlay) {
return FIELD_UNAVAILABLE[overlay] || `${overlay} field unavailable`;
return Object.hasOwn(FIELD_UNAVAILABLE, overlay)
? FIELD_UNAVAILABLE[overlay]
: null;
}
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
const WIND_FIELD_FADE_LOW_METERS = 200_000;
+7 -4
View File
@@ -4,6 +4,12 @@ import { readWindBody } from '../../sources/windBody.js';
// wind layer and layer panel show them translated.
const msg = (text) => text;
/** Status of a scalar overlay the provider could not supply, by overlay. */
export const SCALAR_UNAVAILABLE = Object.freeze({
temperature: msg('Temperature field unavailable'),
pressure: msg('Mean sea level pressure field unavailable'),
});
/** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -44,10 +50,7 @@ export function createWindSource({
const optionalUnavailable =
overlay !== 'none' &&
scalar === undefined &&
scalarError ===
(overlay === 'temperature'
? msg('Temperature field unavailable')
: msg('Mean sea level pressure field unavailable'));
scalarError === SCALAR_UNAVAILABLE[overlay];
const scalarValid =
overlay === 'none'
? scalar === undefined && scalarError === undefined