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

+21
View File
@@ -713,3 +713,24 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
assert.ok(!gpx.children[0].classList.contains('own-styles'));
assert.ok(gpx.children[0].style.background);
});
// N-08: tn() replaces values.count with the raw number, so the
// formatNumber(...) passed by the import list is lost: "12345 map objects"
// (main: "12,345"). Append to src/ui/geoFileImport.test.mjs (uses its setup(),
// file(), rows() helpers).
test('the map-object count in a row title keeps digit grouping', async () => {
const t = setup({
load: async (f) => ({
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 12345,
removed: 0,
revoke() {},
}),
});
await t.tool.importFiles([file('big.geojson')]);
const [row] = rows(t.els);
assert.match(row.children[1].title, /12,345 map objects/);
t.tool.destroy();
});
+3
View File
@@ -39,6 +39,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
'weather-satellite': msg('Satellite clouds'),
'weather-lightning': msg('Lightning density'),
'weather-cyclones': msg('Cyclone advisories'),
'air-quality': msg('Air quality'),
directions: msg('Directions'),
'alpr-cameras': msg('ALPR Cameras'),
});
@@ -116,6 +117,8 @@ const SOURCE_LABEL_KEYS = new Map(
msg('OSM routing'),
msg('Mapped camera locations'),
msg('OpenStreetMap · community mapped'),
msg('Decoder feeds'),
msg('WebUSB + decoder feeds'),
].map((key) => [key, key]),
);
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { APPLICATION_LAYER_METADATA } from '../app/constructCatalog.js';
import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS } from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
// language: the Bhote Koshi pair belong to a scripted scene.
const UNTRANSLATED_NAMES = new Set(['bhote-koshi-2026', 'bhote-koshi-locator']);
const catalogIds = APPLICATION_LAYER_METADATA.map((layer) => layer.id);
test('every catalog layer has a translatable name', () => {
const missing = catalogIds.filter(
(id) =>
!UNTRANSLATED_NAMES.has(id) &&
!Object.hasOwn(LAYER_NAME_KEYS, id) &&
!Object.hasOwn(PANEL_LABEL_KEYS, id),
);
assert.deepEqual(
missing,
[],
'add the layer to LAYER_NAME_KEYS in src/ui/layerNames.js',
);
});
test('the name tables list only catalog layers', () => {
const unknown = [
...Object.keys(LAYER_NAME_KEYS),
...Object.keys(PANEL_LABEL_KEYS),
...UNTRANSLATED_NAMES,
].filter((id) => !catalogIds.includes(id));
assert.deepEqual(unknown, []);
});
+97 -5
View File
@@ -19,6 +19,7 @@ import {
getLocale,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
@@ -187,6 +188,27 @@ const FEED_ERROR_PATTERNS = [
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
],
// Layer statuses with a number or a feed name in them: GNSS
// (gnss/source.js), CelesTrak groups (satellites/ingestion.js) and transit
// feeds (transit/ingestion.js).
[
/^GNSS feed unavailable \(HTTP (\d{3})\)$/,
([, status]) => t('GNSS feed unavailable (HTTP {status})', { status }),
],
[
/^(\d+) CelesTrak groups? unavailable$/,
([, groups]) =>
tn(
Number(groups),
'{count} CelesTrak group unavailable',
'{count} CelesTrak groups unavailable',
{ count: formatNumber(Number(groups)) },
),
],
[
/^(.+) feed unavailable$/,
([, feed]) => t('{feed} feed unavailable', { feed: translateKnown(feed) }),
],
[
/^Source snapshot (\d+) min old$/,
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
@@ -222,9 +244,8 @@ const FEED_ERROR_PATTERNS = [
],
];
/** A layer's error text in the UI language (see FEED_ERRORS). */
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
/** One error text in the UI language, or null when nothing matches. */
function feedErrorText(text) {
if (FEED_ERRORS.has(text)) return t(text);
const known = translateKnown(text);
if (known !== text) return known;
@@ -232,17 +253,88 @@ export function translateFeedError(text) {
const match = pattern.exec(text);
if (match) return format(match);
}
return text;
return null;
}
/**
* A layer's error text in the UI language (see FEED_ERRORS). An error made of
* several statuses joined by " · " (Local ADS-B) translates piece by piece.
*/
export function translateFeedError(text) {
if (typeof text !== 'string') return text;
const whole = feedErrorText(text);
if (whole !== null) return whole;
if (!text.includes(ROW_TEXT_SEPARATOR)) return text;
return text
.split(ROW_TEXT_SEPARATOR)
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
.join(ROW_TEXT_SEPARATOR);
}
const ROW_TEXT_SEPARATOR = ' · ';
// Feed states named in a Local ADS-B feed problem ("feeds 1090, 978 stale").
const FEED_PROBLEM_FORMS = Object.freeze({
stale: (count, names) =>
tn(count, 'feed {names} stale', 'feeds {names} stale', { names }),
unreachable: (count, names) =>
tn(count, 'feed {names} unreachable', 'feeds {names} unreachable', {
names,
}),
invalid: (count, names) =>
tn(count, 'feed {names} invalid', 'feeds {names} invalid', { names }),
});
// Working texts with a number or a name in them arrive as English data, one
// piece of a " · " joined line each: transit (transit/queries.js) and Local
// ADS-B (localAdsb/status.js). Keep these in step with the producers.
const STATUS_PIECE_PATTERNS = [
[/^Loading (.+)$/, ([, feeds]) => t('Loading {feeds}', { feeds })],
[
/^(\d+) heard$/,
([, heard]) =>
tn(Number(heard), '{count} heard', '{count} heard', {
count: formatNumber(Number(heard)),
}),
],
[
/^(USB )?(\d+(?:\.\d+)?) msg\/s$/,
([, usb, rate]) =>
usb
? t('USB {rate} msg/s', { rate: formatNumber(Number(rate)) })
: t('{rate} msg/s', { rate: formatNumber(Number(rate)) }),
],
[
/^(\d+) feeds? live$/,
([, live]) =>
tn(Number(live), '{count} feed live', '{count} feeds live', {
count: formatNumber(Number(live)),
}),
],
[
/^feeds? (.+) (stale|unreachable|invalid)$/,
([, names, status]) =>
FEED_PROBLEM_FORMS[status](names.split(', ').length, names),
],
];
/** One piece of a status line in the UI language, or as it came. */
function translateStatusPiece(piece) {
const known = translateKnown(piece);
if (known !== piece) return known;
for (const [pattern, format] of STATUS_PIECE_PATTERNS) {
const match = pattern.exec(piece);
if (match) return format(match);
}
return piece;
}
/** A loading label is English data, at times several joined by " · ". */
function translateLoadingLabel(label) {
return label
.trim()
.split(ROW_TEXT_SEPARATOR)
.map(translateKnown)
.map(translateStatusPiece)
.join(ROW_TEXT_SEPARATOR);
}
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
+90
View File
@@ -0,0 +1,90 @@
// N-03: layer status texts produced without msg()/catalog entries (GNSS,
// Transit, CelesTrak, AQHI) stay English in the es/ru DATA LAYERS row.
// Place: src/ui/layerStatusLocale.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { LayerPanel } from './layerPanel.js';
const row = (id, stats) =>
LayerPanel.prototype._buildMetaText({ id, enabled: true, stats });
const now = Date.now();
// [layer id, stats, English fragment that must not reach the row]
const cases = [
[
'gnss-integrity',
{
count: 0,
error: 'GNSS feed rate limited (HTTP 429)',
source: 'GNSS',
lastUpdate: now,
},
'rate limited',
],
[
'gnss-integrity',
{
count: 0,
error: 'GNSS feed unavailable (HTTP 503)',
source: 'GNSS',
lastUpdate: now,
},
'feed unavailable',
],
[
'transit',
{
count: 0,
error: 'GO Transit feed unavailable',
source: 'GTFS-RT',
lastUpdate: now,
},
'feed unavailable',
],
[
'transit',
{
count: 0,
loading: true,
loadingLabel: 'Loading GO Transit, Metro Transit',
source: 'GTFS-RT',
},
'Loading',
],
[
'satellites',
{
count: 10,
error: '1 CelesTrak group unavailable',
source: 'CelesTrak',
lastUpdate: now,
},
'group unavailable',
],
[
'air-quality',
{
count: 0,
error: 'ECCC request timed out',
source: 'ECCC',
lastUpdate: now,
},
'request timed out',
],
];
for (const locale of ['es', 'ru']) {
test(`${locale}: layer status texts are translated in the row`, () => {
setLocale(locale);
try {
const english = cases
.map(([id, stats, marker]) => [id, row(id, stats), marker])
.filter(([, text, marker]) => text.includes(marker))
.map(([id, text]) => `${id}: ${text}`);
assert.deepEqual(english, []);
} finally {
setLocale('en');
}
});
}
+3 -3
View File
@@ -79,9 +79,9 @@ html[lang='es'] .cctv-controls {
}
/* A translated label longer than its button wraps inside the button instead
of running past the panel edge. English labels fit on one line, so the
English layout is unchanged. */
.cctv-controls .scene-btn {
of running past the panel edge. English keeps its one-line buttons (unguarded,
this rule broke AUTO HOP OFF onto two lines). */
html:not([lang='en']) .cctv-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
+6 -1
View File
@@ -1836,9 +1836,11 @@
display: none;
}
/* Sits above the language picker row in the bottom-right corner
(locale-picker.css: 0.9rem inset + one ~2rem button + a gap). */
#intel-hud[data-variant='cyber'] .hud-bottom-right {
right: 28px;
bottom: 28px;
bottom: 3.5rem;
}
}
@@ -2735,6 +2737,9 @@
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout {
right: 30px;
/* Clears the language picker row it shares the corner with, as in the
other themes (locale-picker.css). */
bottom: 3.5rem;
}
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help {
@@ -210,3 +210,10 @@ test('K-44: Recent Imagery readout keeps its English layout', () => {
assert.equal(declaration(swap, 'width'), '40px');
assert.equal(declaration(swap, 'min-width'), undefined);
});
test('N-13: CCTV control buttons stay on one line in English', () => {
assert.equal(
unguardedRules(css('cctv.css'), '.cctv-controls .scene-btn').length,
0,
);
});