mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
a2463ca2d2
commit
3157888780
60 files changed
+1577
-1606
No files matched your search
@@ -714,10 +714,6 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
|
||||
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) => ({
|
||||
|
||||
@@ -117,8 +117,6 @@ 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]),
|
||||
);
|
||||
|
||||
|
||||
+1
-190
@@ -14,12 +14,12 @@ import {
|
||||
} from './layerNames.js';
|
||||
export { localizeLayerName } from './layerNames.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { translateFeedError, translateLoadingLabel } from './statusText.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
|
||||
@@ -146,197 +146,8 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Live-source failures arrive as English data (src/sources/live/contract.js,
|
||||
// standalone.js, flights and military ingestion), some with a source name or a
|
||||
// number in them; the row shows them in the UI language. Keep these in step
|
||||
// with the producers. Anything else shows its catalog entry, or as it came.
|
||||
const FEED_ERRORS = new Set([
|
||||
msg('OpenSky OAuth client missing/invalid'),
|
||||
msg('OpenSky OAuth rejected credentials'),
|
||||
msg('OpenSky username/password rejected'),
|
||||
msg('OpenSky auth missing'),
|
||||
msg('OpenSky auth required'),
|
||||
msg('OpenSky auth invalid'),
|
||||
msg('OpenSky auth failed'),
|
||||
]);
|
||||
const FEED_ERROR_PATTERNS = [
|
||||
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
|
||||
// ahead of the generic "<source> HTTP <status>".
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
|
||||
// (sources/vectorTiles.js) failures carry their own wording; the generic
|
||||
// pattern below would take "Weather image" for a source name.
|
||||
[
|
||||
/^Weather (image )?HTTP (\d+)$/,
|
||||
([, image, status]) =>
|
||||
image
|
||||
? t('Weather image HTTP {status}', { status })
|
||||
: t('Weather HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^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 }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited \(anonymous\)$/,
|
||||
([, source]) =>
|
||||
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited$/,
|
||||
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) HTTP (\d{3})$/,
|
||||
([, source, status]) =>
|
||||
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
|
||||
],
|
||||
[
|
||||
/^(.+) network error$/,
|
||||
([, source]) =>
|
||||
t('{source} network error', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) aircraft rows$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) response$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} response', { source: sourceLabel(source) }),
|
||||
],
|
||||
];
|
||||
|
||||
/** 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;
|
||||
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
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(translateStatusPiece)
|
||||
.join(ROW_TEXT_SEPARATOR);
|
||||
}
|
||||
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
|
||||
// error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 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
|
||||
// The DATA LAYERS row shows the English status texts that GNSS, Transit,
|
||||
// CelesTrak and AQHI produce in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from '../loadingFeedback.js';
|
||||
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
|
||||
import { getLocale, t, msg } from '../i18n/index.js';
|
||||
import { localizeLayerName, translateFeedError } from './layerPanel.js';
|
||||
import { localizeLayerName } from './layerPanel.js';
|
||||
import { translateFeedError } from './statusText.js';
|
||||
|
||||
// loadingFeedback.js composes the shared status copy inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same modules), so
|
||||
@@ -56,6 +57,7 @@ const CHIP_ERROR_KEYS = new Map(
|
||||
msg('Vector tile request timed out'),
|
||||
msg('Camera coverage unavailable'),
|
||||
msg('Camera source temporarily unavailable'),
|
||||
msg('Overpass returned an incomplete camera response'),
|
||||
].map((key) => [key.toUpperCase(), key]),
|
||||
);
|
||||
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// catalog entry, not only the default one.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
import {
|
||||
aggregateLayerLoading,
|
||||
createLoadingFeedbackState,
|
||||
presentLoadingFeedback,
|
||||
reduceLoadingFeedback,
|
||||
} from '../loadingFeedback.js';
|
||||
|
||||
const elements = {
|
||||
'global-loading-status': { hidden: true, dataset: {} },
|
||||
@@ -107,4 +113,30 @@ for (const locale of ['es', 'ru']) {
|
||||
assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
|
||||
assert.notEqual(localized.detail, 'retry pending');
|
||||
});
|
||||
|
||||
// An incomplete Overpass answer (alpr/source.js) on the ALPR retry chip,
|
||||
// which shows the layer's error upper-cased.
|
||||
test(`${locale}: the ALPR retry chip translates an incomplete Overpass answer`, () => {
|
||||
setLocale(locale);
|
||||
const error = 'Overpass returned an incomplete camera response';
|
||||
assert.notEqual(t(error), error, 'the catalog has the entry');
|
||||
const now = Date.now();
|
||||
const layer = {
|
||||
id: 'alpr-cameras',
|
||||
name: 'ALPR cameras',
|
||||
enabled: true,
|
||||
stats: { status: 'unavailable', error, retryAt: now + 12000 },
|
||||
};
|
||||
const summary = aggregateLayerLoading([layer]);
|
||||
const state = reduceLoadingFeedback(
|
||||
createLoadingFeedbackState(),
|
||||
summary,
|
||||
now,
|
||||
);
|
||||
const shown = localizeLoadingPresentation(
|
||||
presentLoadingFeedback(state, summary, now),
|
||||
);
|
||||
assert.equal(shown.state, 'retry', 'the ALPR retry chip is up');
|
||||
assert.equal(shown.label, t(error).toLocaleUpperCase(locale));
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// Shell toasts, prompts and launcher labels that reach the page through
|
||||
// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant
|
||||
// are invisible to the unmarked-text checker, so pin them here: marked in the
|
||||
// code, present in every shipped catalog, and no bare literal left at the
|
||||
// call sites.
|
||||
// Shell toasts, prompts and launcher labels: pin that they are marked in the
|
||||
// code, present in every shipped catalog, and that no bare literal is left at
|
||||
// the call sites (`window.prompt/confirm` and constants are invisible to the
|
||||
// unmarked-text checker).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -27,7 +26,6 @@ const KEYS = [
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a photo',
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Exit cockpit to fly to a {noun}',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'Double-click the header to snap the panel back',
|
||||
@@ -84,12 +82,10 @@ test('the Cockpit refusal keeps its English wording per noun', () => {
|
||||
cockpitActive: true,
|
||||
showToast,
|
||||
});
|
||||
runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast });
|
||||
assert.deepEqual(log, [
|
||||
'Exit cockpit to fly to a camera',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a contact',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Layer status texts in the UI language.
|
||||
*
|
||||
* Layers report errors, loading labels and status notes as English data
|
||||
* (stats, analyst records, what the model receives); the Layers panel, the
|
||||
* loading chip, layer cards and the voice card show them translated through
|
||||
* here, by their shape.
|
||||
*
|
||||
* Pure: imports only i18n and layer names, no DOM.
|
||||
* @module ui/statusText
|
||||
*/
|
||||
import { sourceLabel } from './layerNames.js';
|
||||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
|
||||
const STATUS_SEPARATOR = ' · ';
|
||||
|
||||
// Live-source failures arrive as English data (src/sources/live/contract.js,
|
||||
// standalone.js, flights and military ingestion), some with a source name or a
|
||||
// number in them; the row shows them in the UI language. Keep these in step
|
||||
// with the producers. Anything else shows its catalog entry, or as it came.
|
||||
const FEED_ERRORS = new Set([
|
||||
msg('OpenSky OAuth client missing/invalid'),
|
||||
msg('OpenSky OAuth rejected credentials'),
|
||||
msg('OpenSky username/password rejected'),
|
||||
msg('OpenSky auth missing'),
|
||||
msg('OpenSky auth required'),
|
||||
msg('OpenSky auth invalid'),
|
||||
msg('OpenSky auth failed'),
|
||||
]);
|
||||
const FEED_ERROR_PATTERNS = [
|
||||
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
|
||||
// ahead of the generic "<source> HTTP <status>".
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
|
||||
// (sources/vectorTiles.js) failures carry their own wording; the generic
|
||||
// pattern below would take "Weather image" for a source name.
|
||||
[
|
||||
/^Weather (image )?HTTP (\d+)$/,
|
||||
([, image, status]) =>
|
||||
image
|
||||
? t('Weather image HTTP {status}', { status })
|
||||
: t('Weather HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^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 }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited \(anonymous\)$/,
|
||||
([, source]) =>
|
||||
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited$/,
|
||||
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) HTTP (\d{3})$/,
|
||||
([, source, status]) =>
|
||||
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
|
||||
],
|
||||
[
|
||||
/^(.+) network error$/,
|
||||
([, source]) =>
|
||||
t('{source} network error', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) aircraft rows$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) response$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} response', { source: sourceLabel(source) }),
|
||||
],
|
||||
];
|
||||
|
||||
/** 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;
|
||||
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
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,
|
||||
* and so does a lone status piece ("feed 1090 unreachable").
|
||||
*/
|
||||
export function translateFeedError(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const whole = feedErrorText(text);
|
||||
if (whole !== null) return whole;
|
||||
return text
|
||||
.split(STATUS_SEPARATOR)
|
||||
.map((piece) => feedErrorText(piece) ?? translateStatusPiece(piece))
|
||||
.join(STATUS_SEPARATOR);
|
||||
}
|
||||
|
||||
// A decoder feed with no band is named by the server's "entry N" (its place
|
||||
// in LOCAL_RECEIVER_FEEDS, server/providers/local-receivers.js).
|
||||
const FEED_ENTRY = /^entry (\d+)$/;
|
||||
|
||||
function shownFeedName(name) {
|
||||
const entry = FEED_ENTRY.exec(name);
|
||||
return entry ? t('entry {n}', { n: entry[1] }) : name;
|
||||
}
|
||||
|
||||
// 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). The Layers panel and the voice card's analyst
|
||||
// notes show them through here. 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]) => {
|
||||
const list = names.split(', ');
|
||||
return FEED_PROBLEM_FORMS[status](
|
||||
list.length,
|
||||
list.map(shownFeedName).join(', '),
|
||||
);
|
||||
},
|
||||
],
|
||||
// A quiet transit feed in the coverage line, "TTC 0 (95s old)"
|
||||
// (transit/queries.js); the feed name and the numbers stay as they came.
|
||||
[
|
||||
/^(.+) (\d+) \((\d+)s old\)$/,
|
||||
([, feed, count, seconds]) =>
|
||||
t('{feed} {count} ({seconds}s old)', { feed, count, seconds }),
|
||||
],
|
||||
];
|
||||
|
||||
/** One piece of a status line in the UI language, or as it came. */
|
||||
export 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 status line of " · " joined pieces in the UI language, piece by piece. */
|
||||
export function translateStatusLine(text) {
|
||||
return text
|
||||
.split(STATUS_SEPARATOR)
|
||||
.map(translateStatusPiece)
|
||||
.join(STATUS_SEPARATOR);
|
||||
}
|
||||
|
||||
/** A loading label is English data, at times several joined by " · ". */
|
||||
export function translateLoadingLabel(label) {
|
||||
return translateStatusLine(label.trim());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Layer status texts arrive as English data and show in the UI language by
|
||||
// their shape; English shows exactly as it came.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t, translateKnown } from '../i18n/index.js';
|
||||
import { sourceLabel } from './layerNames.js';
|
||||
import {
|
||||
translateFeedError,
|
||||
translateLoadingLabel,
|
||||
translateStatusLine,
|
||||
} from './statusText.js';
|
||||
import { FEED_SOURCE, COMBINED_SOURCE } from '../layers/localAdsb/status.js';
|
||||
import { readResponseTextCapped } from '../sources/httpBody.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LINES = [
|
||||
'feed 1090 unreachable',
|
||||
'feeds 1090, entry 2 invalid',
|
||||
'3 heard · USB 5.8 msg/s · feed 1090 stale',
|
||||
'2 feeds live · 14 heard',
|
||||
'TTC 0 (95s old)',
|
||||
'TTC 0 · GO Transit 4 (95s old)',
|
||||
'Loading TTC, MiWay',
|
||||
];
|
||||
|
||||
test('en: status lines and errors show as they came', () => {
|
||||
setLocale('en');
|
||||
for (const text of [
|
||||
...STATUS_LINES,
|
||||
'ECCC HTTP 503',
|
||||
'GNSS feed unavailable (HTTP 503)',
|
||||
'Upstream response too large',
|
||||
]) {
|
||||
assert.equal(translateFeedError(text), text);
|
||||
assert.equal(translateStatusLine(text), text);
|
||||
assert.equal(translateLoadingLabel(` ${text} `), text);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a lone feed problem translates like a joined one`, () => {
|
||||
inLocale(locale, () => {
|
||||
const lone = translateFeedError('feed 1090 unreachable');
|
||||
assert.doesNotMatch(lone, /unreachable/, lone);
|
||||
assert.equal(
|
||||
translateFeedError('feed 1090 unreachable · 2 heard').split(' · ')[0],
|
||||
lone,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: a feed with no band is named in the UI language`, () => {
|
||||
inLocale(locale, () => {
|
||||
const shown = translateFeedError('feeds 1090, entry 2 invalid');
|
||||
assert.doesNotMatch(shown, /\b(entry|invalid)\b/, shown);
|
||||
assert.ok(shown.includes(t('entry {n}', { n: 2 })), shown);
|
||||
assert.ok(shown.includes('1090'), shown);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: a quiet transit feed keeps its name and numbers`, () => {
|
||||
inLocale(locale, () => {
|
||||
const shown = translateStatusLine('TTC 0 · GO Transit 4 (95s old)');
|
||||
assert.doesNotMatch(shown, /\bold\b/, shown);
|
||||
assert.ok(shown.startsWith('TTC 0 · GO Transit 4 '), shown);
|
||||
assert.ok(shown.includes('95'), shown);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: an oversized upstream response is translated`, async () => {
|
||||
const response = new Response('x'.repeat(10), {
|
||||
headers: { 'content-length': '10' },
|
||||
});
|
||||
const error = await readResponseTextCapped(response, 4).catch((e) => e);
|
||||
assert.equal(error.message, 'Upstream response too large');
|
||||
inLocale(locale, () => {
|
||||
assert.notEqual(translateKnown(error.message), error.message);
|
||||
assert.equal(translateFeedError(error.message), t(error.message));
|
||||
});
|
||||
});
|
||||
|
||||
test(`${locale}: Local ADS-B source names translate without their own entry`, () => {
|
||||
inLocale(locale, () => {
|
||||
for (const source of [FEED_SOURCE, COMBINED_SOURCE]) {
|
||||
assert.notEqual(t(source), source, 'the catalog has the entry');
|
||||
assert.equal(sourceLabel(source), t(source));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -79,8 +79,8 @@ html[lang='es'] .cctv-controls {
|
||||
}
|
||||
|
||||
/* A translated label longer than its button wraps inside the button instead
|
||||
of running past the panel edge. English keeps its one-line buttons (unguarded,
|
||||
this rule broke AUTO HOP OFF onto two lines). */
|
||||
of running past the panel edge. English is excluded so its labels (AUTO HOP
|
||||
OFF) stay on one line. */
|
||||
html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
Reference in new issue
Block a user