From 4d2a71c1b279307747928ab53c7d36a3909e30a1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 1/6] fix(layers): report Directions errors as English data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Directions row errors (pointer blocked, no route, routing timed out, no response from the routing service) and the route request failures ("Routing failed: {error}", "{error} — try again in a moment", "Routing unavailable (HTTP {status})", the rate-limit and fallback texts) were translated inside getStats(), so the layer snapshot that voice tools and the analyst read carried Spanish or Russian text. Fixed texts are marked with msg() and the interpolated ones are built in English. The Layers panel translates them where it shows them, through a ROUTING_ERROR_PATTERNS block in statusText.js that the proxy's refusal texts move into. English reads as before. In other locales two things differ slightly: a proxy text inside "Routing failed: X" that is not one of the known refusals is now looked up in the catalog instead of shown as given, and the "— try again in a moment" shape is recognised on every layer's error, not only on Directions. --- src/layers/directions/index.js | 51 +++------ src/layers/directions/statusLocale.test.mjs | 121 ++++++++++++++++++++ src/ui/statusText.js | 34 +++++- src/ui/untranslatedErrors.test.mjs | 32 ++++-- 4 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 src/layers/directions/statusLocale.test.mjs diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..70f49bfb9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({ export const DEFAULT_DIRECTIONS_MODE = 'car'; /** - * The route proxy's refusal texts (server/providers/places/routes.js). They - * stay English on the wire and in the AI tools; the row shows them translated. + * The row error for a failed route request (createDirectionsSource errors), + * as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS + * in src/ui/statusText.js). */ -const ROUTING_REFUSALS = new Set([ - msg('rate limited'), - msg('routing service is rate limited'), - msg('route leg too long'), - msg('route too long'), -]); - -/** A refusal text from the route proxy in the language; unknown ones as given. */ -export function presentRoutingRefusal(text) { - return ROUTING_REFUSALS.has(text) ? t(text) : text; -} - -/** The row text for a failed route request (createDirectionsSource errors). */ export function presentRoutingFailure(error) { if (error?.status === 429) { return error.refusal - ? t('{error} — try again in a moment', { - error: presentRoutingRefusal(error.refusal), - }) - : t('Routing is rate limited — try again in a moment'); + ? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + `${error.refusal} — try again in a moment` + : msg('Routing is rate limited — try again in a moment'); } if (Number.isFinite(error?.status) && error.status > 0) - return t('Routing unavailable (HTTP {status})', { status: error.status }); - return error?.message || t('Routing unavailable'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + return `Routing unavailable (HTTP ${error.status})`; + return error?.message || msg('Routing unavailable'); } /** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ @@ -643,7 +631,9 @@ export function directionsStats(state) { return { count: route?.steps.length || 0, lastUpdate, - error: t('Another map tool is using clicks — close it, then SET A again'), + error: msg( + 'Another map tool is using clicks — close it, then SET A again', + ), status: 'empty', source, }; @@ -662,7 +652,7 @@ export function directionsStats(state) { return { count: 0, lastUpdate, - error: error || t('No route found'), + error: error || msg('No route found'), status: 'empty', source, }; @@ -1144,13 +1134,10 @@ export function createDirectionsLayer({ services, source }) { clearRouteGraphics(); _status = 'error'; _error = - payload?.error === 'no route found' - ? t('No route found between A and B') - : payload?.error - ? t('Routing failed: {error}', { - error: presentRoutingRefusal(payload.error), - }) - : t('No route found between A and B'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + payload?.error && payload.error !== 'no route found' + ? `Routing failed: ${payload.error}` + : msg('No route found between A and B'); return; } _route = route; @@ -1167,9 +1154,9 @@ export function createDirectionsLayer({ services, source }) { // happened instead. _error = error?.name === 'AbortError' - ? t('Routing timed out') + ? msg('Routing timed out') : error?.name === 'TypeError' - ? t('Routing unavailable — no response from the routing service') + ? msg('Routing unavailable — no response from the routing service') : presentRoutingFailure(error); } finally { clearTimeout(timer); diff --git a/src/layers/directions/statusLocale.test.mjs b/src/layers/directions/statusLocale.test.mjs new file mode 100644 index 000000000..d76605d88 --- /dev/null +++ b/src/layers/directions/statusLocale.test.mjs @@ -0,0 +1,121 @@ +// Directions reports its fixed errors as English data (getStats(), and so the +// layer snapshot the voice tools and the analyst read); the Layers panel row +// shows them in the UI language. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale, t as translate } from '../../i18n/index.js'; +import { LayerPanel } from '../../ui/layerPanel.js'; +import { directionsStats, presentRoutingFailure } from './index.js'; + +const idle = { + enabled: true, + mode: 'car', + armed: null, + a: null, + b: null, + status: 'idle', + error: null, + route: null, + lastUpdate: null, +}; + +const cases = [ + [ + { ...idle, pointerBlocked: true }, + 'Another map tool is using clicks — close it, then SET A again', + ], + [{ ...idle, status: 'error' }, 'No route found'], + [ + { ...idle, status: 'error', error: 'Routing timed out' }, + 'Routing timed out', + ], +]; + +function rowFor(stats) { + return LayerPanel.prototype._buildMetaText({ + id: 'directions', + enabled: true, + stats, + }); +} + +// Route request failures with the proxy's refusal or an HTTP status in them: +// [the request error, its English row error, the row text in the language] +const failures = () => [ + [ + { status: 429, refusal: 'route leg too long' }, + 'route leg too long — try again in a moment', + translate('{error} — try again in a moment', { + error: translate('route leg too long'), + }), + ], + [ + { status: 429, refusal: null }, + 'Routing is rate limited — try again in a moment', + translate('Routing is rate limited — try again in a moment'), + ], + [ + { status: 503 }, + 'Routing unavailable (HTTP 503)', + translate('Routing unavailable (HTTP {status})', { status: 503 }), + ], + [ + { status: 0, message: '' }, + 'Routing unavailable', + translate('Routing unavailable'), + ], +]; + +for (const locale of ['es', 'ru']) { + test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [state, english] of cases) { + const stats = directionsStats(state); + assert.equal(stats.error, english); + const row = rowFor(stats); + assert.ok(row.includes(translate(english)), `${locale}: ${row}`); + assert.ok(!row.includes(english), `${locale}: ${row}`); + } + }); +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [failure, english, shown] of failures()) { + const error = presentRoutingFailure(failure); + assert.equal(error, english); + const stats = directionsStats({ ...idle, status: 'error', error }); + assert.equal(stats.error, english); + assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`); + assert.notEqual(shown, english); + } + // The proxy's refusal after "Routing failed:", and a text it never sends. + for (const [refusal, shownRefusal] of [ + ['route too long', translate('route too long')], + ['something else', 'something else'], + ]) { + const row = rowFor( + directionsStats({ + ...idle, + status: 'error', + error: `Routing failed: ${refusal}`, + }), + ); + assert.ok( + row.includes( + translate('Routing failed: {error}', { error: shownRefusal }), + ), + `${locale}: ${row}`, + ); + } + }); +} + +test('en: the Directions row shows the English error', () => { + setLocale('en'); + const row = rowFor(directionsStats({ ...idle, status: 'error' })); + assert.match(row, /No route found/); +}); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index b0899d55e..dcd614bca 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -112,12 +112,44 @@ const FEED_ERROR_PATTERNS = [ ], ]; +// Directions route failures (layers/directions/index.js), English data with +// the route proxy's refusal or an HTTP status in them. Ahead of the feed +// patterns, which would take "Routing failed: rate limited" for a source. +// The route proxy's refusal texts (server/providers/places/routes.js) show +// translated; any other text in {error} shows its catalog entry, or as given. +const ROUTING_REFUSALS = new Set([ + msg('rate limited'), + msg('routing service is rate limited'), + msg('route leg too long'), + msg('route too long'), +]); +const routingRefusal = (text) => + ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); +const ROUTING_ERROR_PATTERNS = [ + [ + /^Routing unavailable \(HTTP (\d+)\)$/, + ([, status]) => t('Routing unavailable (HTTP {status})', { status }), + ], + [ + /^Routing failed: (.+)$/, + ([, error]) => + t('Routing failed: {error}', { error: routingRefusal(error) }), + ], + [ + /^(.+) — try again in a moment$/, + ([, error]) => + t('{error} — try again in a moment', { error: routingRefusal(error) }), + ], +]; + +const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS]; + /** 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) { + for (const [pattern, format] of ERROR_PATTERNS) { const match = pattern.exec(text); if (match) return format(match); } diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index 40a8e2848..d8d05d6f0 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js'; import { presentStreetLevelPanel } from './streetLevelPresentation.js'; import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; import { createWeatherSource } from '../layers/weather/source.js'; -import { - presentRoutingFailure, - presentRoutingRefusal, -} from '../layers/directions/index.js'; +import { presentRoutingFailure } from '../layers/directions/index.js'; +import { translateFeedError } from './statusText.js'; import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js'; const NO_IMAGERY = 'No street-level imagery within 50 m of that point'; @@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) { test(`${locale}: the route proxy's refusals are shown translated`, () => { setLocale(locale); try { - const shown = t('Routing failed: {error}', { - error: presentRoutingRefusal('route leg too long'), - }); + // Directions stores these in English; the Layers panel shows them + // through translateFeedError. + const shown = translateFeedError('Routing failed: route leg too long'); + assert.equal( + shown, + t('Routing failed: {error}', { error: t('route leg too long') }), + ); assert.doesNotMatch(shown, /route leg too long/); const limited = presentRoutingFailure( Object.assign(new Error('rate limited — try again in a moment'), { @@ -63,16 +65,24 @@ for (const locale of ['es', 'ru']) { refusal: 'rate limited', }), ); - assert.doesNotMatch(limited, /rate limited|try again in a moment/); + assert.equal(limited, 'rate limited — try again in a moment'); + assert.doesNotMatch( + translateFeedError(limited), + /rate limited|try again in a moment/, + ); const http = presentRoutingFailure( Object.assign(new Error('Routing unavailable (HTTP 503)'), { status: 503, }), ); - assert.notEqual(http, 'Routing unavailable (HTTP 503)'); - assert.match(http, /503/); + assert.equal(http, 'Routing unavailable (HTTP 503)'); + assert.notEqual(translateFeedError(http), http); + assert.match(translateFeedError(http), /503/); // A text the proxy never sends is shown as given. - assert.equal(presentRoutingRefusal('something else'), 'something else'); + assert.equal( + translateFeedError('Routing failed: something else'), + t('Routing failed: {error}', { error: 'something else' }), + ); } finally { setLocale('en'); } From 3d64a54a219942404123e6d16456187a5a9182fe Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 2/6] fix(layers): keep the Local ADS-B receiver error in English The Local ADS-B layer copied the SDR controller's `message`, which is Radio card text in the UI language, into its `error` stat. The controller now publishes an English `errorText` next to the translated `message` for every receiver error, and the layer reports that. A receiver is any object with getState(); one that does not publish errorText still shows its error from `message`, as before. The Radio card text is unchanged, and the Layers panel still shows the error translated. --- src/layers/localAdsb/index.js | 8 +-- src/layers/localAdsb/status.js | 2 +- src/layers/localAdsb/statusLocale.test.mjs | 41 +++++++++++++- src/sdr/controller.js | 62 ++++++++++------------ 4 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..1028450b5 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -66,10 +66,12 @@ const DEFAULT_DISPLAY = Object.freeze({ * Local ADS-B layer: draws aircraft heard by the user's own receivers. * * Two inputs feed it. The receiver is any object with `getState()` returning - * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`, * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from - * `src/sources/adsbRecords.js`. The browser WebUSB session is one such - * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * `src/sources/adsbRecords.js`, and the optional `errorText` is the English + * text of an `error` status (`message`, the UI-language one, stands in for a + * receiver without it). The browser WebUSB session is one such receiver. + * The optional `feeds` session (`./feeds.js`) supplies records read * by the server from local 1090/978 MHz decoders; it polls only while this * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..7e19997b6 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -51,7 +51,7 @@ export function feedsConfigured(feedState) { function webUsbStatus(receiver, heard, feedState) { if (receiver.status === 'error') - return { status: 'error', error: receiver.message }; + return { status: 'error', error: receiver.errorText ?? receiver.message }; if (receiver.status === 'connecting' || receiver.status === 'tuning') return { loading: true, loadingLabel: msg('opening receiver') }; if ( diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 8fe8245db..d6ac821a4 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -2,7 +2,8 @@ // "USB error", feed problems, even as the whole error) read in the UI language. import assert from 'node:assert/strict'; import test from 'node:test'; -import { setLocale } from '../../i18n/index.js'; +import { setLocale, t } from '../../i18n/index.js'; +import { SdrController } from '../../sdr/controller.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { localAdsbStatus } from './status.js'; @@ -66,3 +67,41 @@ for (const locale of ['es', 'ru']) { }); } } + +// A receiver error is English data for the layer (stats, and what the voice +// tools and the analyst read); the Radio card and the row show it translated. +for (const locale of ['es', 'ru']) { + test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => { + const receiver = new SdrController({ + storage: null, + webUsb: {}, + providerFactory: () => ({ + get: async () => { + throw new Error('No device was selected.'); + }, + }), + }); + setLocale(locale); + try { + assert.equal(await receiver.connect('adsb'), false); + const state = receiver.getState(); + assert.equal(state.status, 'error'); + assert.equal(state.errorText, 'No RTL-SDR device selected'); + assert.equal(state.message, t('No RTL-SDR device selected')); + assert.notEqual(state.message, state.errorText); + + const status = localAdsbStatus({ + receiver: state, + feedState: null, + heard: 0, + }); + assert.equal(status.error, 'No RTL-SDR device selected'); + const row = rowFor(status, 0); + assert.ok(row.includes(state.message), row); + assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + } finally { + setLocale('en'); + await receiver.destroy(); + } + }); +} diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..9a8923339 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -283,22 +283,32 @@ function clampFmFrequency(value) { ); } +/** A failed connection in English; errorState() shows it translated. */ function describeConnectionError(error) { const message = String(error?.message || error || '').trim(); if (/No device was selected|NotFoundError/i.test(message)) - return t('No RTL-SDR device selected'); + return msg('No RTL-SDR device selected'); if ( /does not support the WebUSB|usb/i.test(message) && /support/i.test(message) ) { - return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); + return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); } if (/access|permission|claim|busy/i.test(message)) { - return t( + return msg( 'RTL-SDR access failed; close other SDR apps and check USB permissions', ); } - return message ? translateKnown(message) : t('RTL-SDR connection failed'); + return message || msg('RTL-SDR connection failed'); +} + +/** + * State fields for a receiver error: `message` in the UI language for the + * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * @param {string} text English error text (ours or the browser's). + */ +function errorState(text) { + return { status: 'error', message: translateKnown(text), errorText: text }; } function streamingMessage(mode) { @@ -555,10 +565,7 @@ export class SdrController { // A failed worker (e.g. its module did not load) never recovers: drop // it so the next session builds and configures a fresh one. this._discardWorker(); - this._setState({ - status: 'error', - message: t('SDR decoder worker failed'), - }); + this._setState(errorState(msg('SDR decoder worker failed'))); if (this._device) void this.stop({ preserveMessage: true }); }; return worker; @@ -779,16 +786,16 @@ export class SdrController { // Published before stopping; stop() preserves it, and nothing is set // afterwards, so a reconnect started during teardown keeps its own state. this._setState({ - status: 'error', + ...errorState( + reason === 'startup' + ? msg('SDR decoder did not start; receiver stopped') + : reason === 'stalled' + ? msg('SDR decoder stopped responding; receiver stopped') + : msg('SDR decoder fell behind; receiver stopped'), + ), connected: false, decoderOverload: reason, decoderOverloadSnapshot: snapshot, - message: - reason === 'startup' - ? t('SDR decoder did not start; receiver stopped') - : reason === 'stalled' - ? t('SDR decoder stopped responding; receiver stopped') - : t('SDR decoder fell behind; receiver stopped'), }); void this.stop({ preserveMessage: true }); } @@ -1085,7 +1092,7 @@ export class SdrController { // consumes the click activation. if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; press Connect again to resume it'), + msg('Browser audio is suspended; press Connect again to resume it'), ); } if (superseded()) return false; @@ -1153,8 +1160,7 @@ export class SdrController { if (superseded()) return false; this._setState({ connected: false, - status: 'error', - message: describeConnectionError(error), + ...errorState(describeConnectionError(error)), }); return false; } @@ -1507,8 +1513,7 @@ export class SdrController { console.warn('[SDR] Sample read failed:', error); this._setState({ connected: false, - status: 'error', - message: t('RTL-SDR sample stream stopped'), + ...errorState(msg('RTL-SDR sample stream stopped')), }); await this.stop({ preserveMessage: true }); break; @@ -1553,7 +1558,7 @@ export class SdrController { try { if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; select FM again to resume it'), + msg('Browser audio is suspended; select FM again to resume it'), ); } // A stall teardown, stop() or reconnect during the switch supersedes @@ -1578,10 +1583,7 @@ export class SdrController { } catch (error) { if (!this._owns(token, device)) return false; console.warn('[SDR] Mode switch failed:', error); - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } @@ -1622,10 +1624,7 @@ export class SdrController { if (targetMode === 'adsb') this._setState(this._resetAdsbCounters()); } catch (error) { console.warn('[SDR] Gain change failed:', error); - this._setState({ - status: 'error', - message: t('RTL-SDR gain change failed'), - }); + this._setState(errorState(msg('RTL-SDR gain change failed'))); return null; } return setting; @@ -1679,10 +1678,7 @@ export class SdrController { }); return true; } catch (error) { - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } From e82f6d5c9c23091b6c7f62fa3a049b4fa1c476e0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 3/6] fix(awareness): keep cohort reasons English in data summarizeAwarenessCohort() translated `reason`, which is retained in the awareness context snapshot. Mark the four reasons with msg() instead; the awareness panel already shows them through translateKnown(). --- src/data/militaryAwarenessEngine.js | 13 +++++++------ src/data/militaryAwarenessEngine.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..5f359b757 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,6 +1,6 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js'; const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); @@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) { /** * Summarize sorted nearby results without promoting missing/stale feeds to a * negative finding. `UNKNOWN` is an input-data state, never an out-of-range - * claim in this MVP. + * claim in this MVP. `reason` is English data; the awareness panel shows it + * in the UI language. */ export function summarizeAwarenessCohort( items, @@ -95,14 +96,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed unavailable'), + reason: msg('feed unavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed stale'), + reason: msg('feed stale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -119,8 +120,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? t('observed or mapped nearby context') - : t('no observed or mapped objects in current feeds'), + ? msg('observed or mapped nearby context') + : msg('no observed or mapped objects in current feeds'), }; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..8b8771f14 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale, translateKnown } from '../i18n/index.js'; import { AWARENESS_RELATIONSHIP, findByDoublingRadius, @@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => { assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale'); }); +test('cohort reasons stay English in data and translate where the panel shows them', (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const reasons = [ + summarizeAwarenessCohort([], { available: false }).reason, + summarizeAwarenessCohort([], { stale: true }).reason, + summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason, + summarizeAwarenessCohort([]).reason, + ]; + assert.deepEqual(reasons, [ + 'feed unavailable', + 'feed stale', + 'observed or mapped nearby context', + 'no observed or mapped objects in current feeds', + ]); + // The awareness panel shows a reason through translateKnown(). + for (const reason of reasons) assert.notEqual(translateKnown(reason), reason); +}); + test('navigation prioritizes nearby targets from the selected cohort', () => { const cohorts = [ { id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } }, From c0356341b0b86f13e172e583083e47e5faf64504 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 4/6] docs(changelog): add the #1209 entry --- changelog.d/1209-layer-errors-english-in-data.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1209-layer-errors-english-in-data.md diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md new file mode 100644 index 000000000..7731e9020 --- /dev/null +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -0,0 +1,4 @@ +- Keep Directions route errors, the Local ADS-B receiver error and Global + Context cohort reasons English in layer data; the Layers panel, Radio card + and awareness panel still show them in the interface language + (daikaginza, #1209). From 38d16f0a8180b9626579aa0e9fb5e20a9e4d04f3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:52 +0700 Subject: [PATCH 5/6] docs(sdr): record the message / errorText split for receiver errors --- docs/CURRENT-STATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..3fad1d23c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5552,6 +5552,11 @@ reception does not interrupt internet radio. Changes apply to the open receiver without reconnecting. - **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, aircraft with a fresh position and the IQ level. +- **Error text.** A receiver error sets two fields (`errorState()` in + `src/sdr/controller.js`): `message` is the UI language for the Radio card, + `errorText` is the English text the Local ADS-B layer reports as its stats + `error` (`localAdsbStatus()`, falling back to `message` when a receiver sets + none); the Layers panel translates it where it shows it. - **Devices.** An already-authorized receiver opens without the WebUSB picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. From 8cedd159e3cbbb8d5c09e21d5ae751699e94d78a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:13:39 +0000 Subject: [PATCH 6/6] fix(layers): translate every route proxy refusal and clear stale receiver errorText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route proxy also refuses with 'routing busy — too many routes at once' (429) and 'route proxy error'; both showed in English in every locale. Mark them in ROUTING_REFUSALS and add es, ru, zh-CN and zh-Hant entries. errorText is now cleared on a new connection, a mode switch and stop(), so it never outlives the error it describes. Tests cover the two refusals, 'Routing failed: rate limited' (pattern order) and the stop() reset. --- .../1209-layer-errors-english-in-data.md | 5 +++-- src/i18n/locales/es.json | 4 +++- src/i18n/locales/ru.json | 4 +++- src/i18n/locales/zh-CN.json | 4 +++- src/i18n/locales/zh-Hant.json | 4 +++- src/layers/localAdsb/statusLocale.test.mjs | 4 ++++ src/sdr/controller.js | 6 ++++- src/ui/statusText.js | 2 ++ src/ui/untranslatedErrors.test.mjs | 22 +++++++++++++++++++ 9 files changed, 48 insertions(+), 7 deletions(-) diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md index 7731e9020..00360147a 100644 --- a/changelog.d/1209-layer-errors-english-in-data.md +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -1,4 +1,5 @@ - Keep Directions route errors, the Local ADS-B receiver error and Global Context cohort reasons English in layer data; the Layers panel, Radio card - and awareness panel still show them in the interface language - (daikaginza, #1209). + and awareness panel still show them in the interface language. The route + proxy's "routing busy" and "route proxy error" refusals are now translated + too (daikaginza, #1209). diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..e1e404001 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,7 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", + "route proxy error": "error del proxy de rutas" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..739f80dac 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,7 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", + "route proxy error": "ошибка прокси маршрутов" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..c415e3776 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多", + "route proxy error": "路线代理出错" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d95a18e39..3f649178e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 個地標" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", + "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", + "route proxy error": "路線代理出錯" } diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index d6ac821a4..61eb763cf 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -99,6 +99,10 @@ for (const locale of ['es', 'ru']) { const row = rowFor(status, 0); assert.ok(row.includes(state.message), row); assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + + // The English text goes with the error: stop() clears it. + await receiver.stop(); + assert.equal(receiver.getState().errorText, null); } finally { setLocale('en'); await receiver.destroy(); diff --git a/src/sdr/controller.js b/src/sdr/controller.js index c8377c337..260ad6647 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -305,6 +305,7 @@ function describeConnectionError(error) { /** * State fields for a receiver error: `message` in the UI language for the * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * A new connection, a mode switch and stop() clear `errorText` again. * @param {string} text English error text (ours or the browser's). */ function errorState(text) { @@ -360,6 +361,7 @@ export class SdrController { gain: this._gainByMode.fm, seeking: false, seekMessage: '', + errorText: null, snr: null, aircraft: [], decodedMessages: 0, @@ -1082,6 +1084,7 @@ export class SdrController { this._setState({ status: 'connecting', message: t('Waiting for an RTL-SDR device…'), + errorText: null, }); let acquisition = null; try { @@ -1554,6 +1557,7 @@ export class SdrController { message: t('Switching to {mode}…', { mode: nextMode === 'adsb' ? 'ADS-B' : 'FM', }), + errorText: null, }); try { if (nextMode === 'fm' && !(await this._ensureAudio())) { @@ -1869,7 +1873,7 @@ export class SdrController { status: preserveMessage ? this.state.status : idleStatus, ...(preserveMessage ? {} - : { message: message || t('RTL-SDR disconnected') }), + : { message: message || t('RTL-SDR disconnected'), errorText: null }), snr: null, samplesPerSecond: 0, workerBlocks: 0, diff --git a/src/ui/statusText.js b/src/ui/statusText.js index e9eafc1d2..1af6b8b2a 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -122,6 +122,8 @@ const ROUTING_REFUSALS = new Set([ msg('routing service is rate limited'), msg('route leg too long'), msg('route too long'), + msg('routing busy — too many routes at once'), + msg('route proxy error'), ]); const routingRefusal = (text) => ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index d8d05d6f0..226bf1364 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -83,6 +83,28 @@ for (const locale of ['es', 'ru']) { translateFeedError('Routing failed: something else'), t('Routing failed: {error}', { error: 'something else' }), ); + // Every refusal the proxy sends (server/providers/places/routes.js), + // and "rate limited" after "Routing failed:", which the feed pattern + // " rate limited" would otherwise take. + for (const [english, shown] of [ + [ + 'routing busy — too many routes at once — try again in a moment', + t('{error} — try again in a moment', { + error: t('routing busy — too many routes at once'), + }), + ], + [ + 'Routing failed: route proxy error', + t('Routing failed: {error}', { error: t('route proxy error') }), + ], + [ + 'Routing failed: rate limited', + t('Routing failed: {error}', { error: t('rate limited') }), + ], + ]) { + assert.equal(translateFeedError(english), shown); + assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/); + } } finally { setLocale('en'); }