mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +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
@@ -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 { 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,
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
Reference in new issue
Block a user