mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge branch 'codex/fix-seek-layer-state' of https://github.com/oneill9/gods-eye-view into pr1374-fix
This commit is contained in:
commit
4bbe175ba3
16 files changed
+351
-93
No files matched your search
@@ -0,0 +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. The route
|
||||||
|
proxy's "routing busy" and "route proxy error" refusals are now translated
|
||||||
|
too (daikaginza, #1209).
|
||||||
@@ -6070,6 +6070,11 @@ reception does not interrupt internet radio.
|
|||||||
Changes apply to the open receiver without reconnecting.
|
Changes apply to the open receiver without reconnecting.
|
||||||
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
|
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
|
||||||
aircraft with a fresh position and the IQ level.
|
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
|
- **Devices.** An already-authorized receiver opens without the WebUSB
|
||||||
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
|
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.
|
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
/** 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';
|
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||||
|
|
||||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
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
|
* Summarize sorted nearby results without promoting missing/stale feeds to a
|
||||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
* 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(
|
export function summarizeAwarenessCohort(
|
||||||
items,
|
items,
|
||||||
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
|
|||||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
count: null,
|
count: null,
|
||||||
nearest: [],
|
nearest: [],
|
||||||
reason: t('feed unavailable'),
|
reason: msg('feed unavailable'),
|
||||||
};
|
};
|
||||||
if (stale)
|
if (stale)
|
||||||
return {
|
return {
|
||||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
count: null,
|
count: null,
|
||||||
nearest: [],
|
nearest: [],
|
||||||
reason: t('feed stale'),
|
reason: msg('feed stale'),
|
||||||
};
|
};
|
||||||
const normalized = (Array.isArray(items) ? items : [])
|
const normalized = (Array.isArray(items) ? items : [])
|
||||||
.filter(
|
.filter(
|
||||||
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
|
|||||||
count: normalized.length,
|
count: normalized.length,
|
||||||
nearest: normalized.slice(0, limit),
|
nearest: normalized.slice(0, limit),
|
||||||
reason: normalized.length
|
reason: normalized.length
|
||||||
? t('observed or mapped nearby context')
|
? msg('observed or mapped nearby context')
|
||||||
: t('no observed or mapped objects in current feeds'),
|
: msg('no observed or mapped objects in current feeds'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { setLocale, translateKnown } from '../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
AWARENESS_RELATIONSHIP,
|
AWARENESS_RELATIONSHIP,
|
||||||
findByDoublingRadius,
|
findByDoublingRadius,
|
||||||
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
|
|||||||
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
|
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', () => {
|
test('navigation prioritizes nearby targets from the selected cohort', () => {
|
||||||
const cohorts = [
|
const cohorts = [
|
||||||
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
|
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
|
||||||
|
|||||||
@@ -3001,5 +3001,7 @@
|
|||||||
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
|
"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í.",
|
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
|
||||||
"Preview is off.": "La vista previa está desactivada.",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3070,5 +3070,7 @@
|
|||||||
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
|
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
|
||||||
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
|
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
|
||||||
"Preview is off.": "Предпросмотр отключён.",
|
"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": "ошибка прокси маршрутов"
|
||||||
}
|
}
|
||||||
@@ -2863,5 +2863,7 @@
|
|||||||
"{count} placemarks": {
|
"{count} placemarks": {
|
||||||
"other": "{count} 个地标"
|
"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": "路线代理出错"
|
||||||
}
|
}
|
||||||
@@ -2863,5 +2863,7 @@
|
|||||||
"{count} placemarks": {
|
"{count} placemarks": {
|
||||||
"other": "{count} 個地標"
|
"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": "路線代理出錯"
|
||||||
}
|
}
|
||||||
@@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({
|
|||||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The route proxy's refusal texts (server/providers/places/routes.js). They
|
* The row error for a failed route request (createDirectionsSource errors),
|
||||||
* stay English on the wire and in the AI tools; the row shows them translated.
|
* 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) {
|
export function presentRoutingFailure(error) {
|
||||||
if (error?.status === 429) {
|
if (error?.status === 429) {
|
||||||
return error.refusal
|
return error.refusal
|
||||||
? t('{error} — try again in a moment', {
|
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||||
error: presentRoutingRefusal(error.refusal),
|
`${error.refusal} — try again in a moment`
|
||||||
})
|
: msg('Routing is rate limited — try again in a moment');
|
||||||
: t('Routing is rate limited — try again in a moment');
|
|
||||||
}
|
}
|
||||||
if (Number.isFinite(error?.status) && error.status > 0)
|
if (Number.isFinite(error?.status) && error.status > 0)
|
||||||
return t('Routing unavailable (HTTP {status})', { status: error.status });
|
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||||
return error?.message || t('Routing unavailable');
|
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. */
|
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
|
||||||
@@ -659,7 +647,9 @@ export function directionsStats(state) {
|
|||||||
return {
|
return {
|
||||||
count: route?.steps.length || 0,
|
count: route?.steps.length || 0,
|
||||||
lastUpdate,
|
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',
|
status: 'empty',
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
@@ -678,7 +668,7 @@ export function directionsStats(state) {
|
|||||||
return {
|
return {
|
||||||
count: 0,
|
count: 0,
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
error: error || t('No route found'),
|
error: error || msg('No route found'),
|
||||||
status: 'empty',
|
status: 'empty',
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
@@ -1160,13 +1150,10 @@ export function createDirectionsLayer({ services, source }) {
|
|||||||
clearRouteGraphics();
|
clearRouteGraphics();
|
||||||
_status = 'error';
|
_status = 'error';
|
||||||
_error =
|
_error =
|
||||||
payload?.error === 'no route found'
|
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||||
? t('No route found between A and B')
|
payload?.error && payload.error !== 'no route found'
|
||||||
: payload?.error
|
? `Routing failed: ${payload.error}`
|
||||||
? t('Routing failed: {error}', {
|
: msg('No route found between A and B');
|
||||||
error: presentRoutingRefusal(payload.error),
|
|
||||||
})
|
|
||||||
: t('No route found between A and B');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_route = route;
|
_route = route;
|
||||||
@@ -1183,9 +1170,9 @@ export function createDirectionsLayer({ services, source }) {
|
|||||||
// happened instead.
|
// happened instead.
|
||||||
_error =
|
_error =
|
||||||
error?.name === 'AbortError'
|
error?.name === 'AbortError'
|
||||||
? t('Routing timed out')
|
? msg('Routing timed out')
|
||||||
: error?.name === 'TypeError'
|
: error?.name === 'TypeError'
|
||||||
? t('Routing unavailable — no response from the routing service')
|
? msg('Routing unavailable — no response from the routing service')
|
||||||
: presentRoutingFailure(error);
|
: presentRoutingFailure(error);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({
|
|||||||
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
|
* 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
|
* 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
|
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
|
||||||
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
|
* `src/sources/adsbRecords.js`, and the optional `errorText` is the English
|
||||||
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
|
* 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
|
* by the server from local 1090/978 MHz decoders; it polls only while this
|
||||||
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
|
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
|
||||||
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
|
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function feedsConfigured(feedState) {
|
|||||||
|
|
||||||
function webUsbStatus(receiver, heard, feedState) {
|
function webUsbStatus(receiver, heard, feedState) {
|
||||||
if (receiver.status === 'error')
|
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')
|
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||||
return { loading: true, loadingLabel: msg('opening receiver') };
|
return { loading: true, loadingLabel: msg('opening receiver') };
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// "USB error", feed problems, even as the whole error) read in the UI language.
|
// "USB error", feed problems, even as the whole error) read in the UI language.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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 { LayerPanel } from '../../ui/layerPanel.js';
|
||||||
import { localAdsbStatus } from './status.js';
|
import { localAdsbStatus } from './status.js';
|
||||||
|
|
||||||
@@ -66,3 +67,45 @@ 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);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+34
-34
@@ -283,22 +283,33 @@ function clampFmFrequency(value) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A failed connection in English; errorState() shows it translated. */
|
||||||
function describeConnectionError(error) {
|
function describeConnectionError(error) {
|
||||||
const message = String(error?.message || error || '').trim();
|
const message = String(error?.message || error || '').trim();
|
||||||
if (/No device was selected|NotFoundError/i.test(message))
|
if (/No device was selected|NotFoundError/i.test(message))
|
||||||
return t('No RTL-SDR device selected');
|
return msg('No RTL-SDR device selected');
|
||||||
if (
|
if (
|
||||||
/does not support the WebUSB|usb/i.test(message) &&
|
/does not support the WebUSB|usb/i.test(message) &&
|
||||||
/support/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)) {
|
if (/access|permission|claim|busy/i.test(message)) {
|
||||||
return t(
|
return msg(
|
||||||
'RTL-SDR access failed; close other SDR apps and check USB permissions',
|
'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.
|
||||||
|
* 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) {
|
||||||
|
return { status: 'error', message: translateKnown(text), errorText: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamingMessage(mode) {
|
function streamingMessage(mode) {
|
||||||
@@ -350,6 +361,7 @@ export class SdrController {
|
|||||||
gain: this._gainByMode.fm,
|
gain: this._gainByMode.fm,
|
||||||
seeking: false,
|
seeking: false,
|
||||||
seekMessage: '',
|
seekMessage: '',
|
||||||
|
errorText: null,
|
||||||
snr: null,
|
snr: null,
|
||||||
aircraft: [],
|
aircraft: [],
|
||||||
decodedMessages: 0,
|
decodedMessages: 0,
|
||||||
@@ -555,10 +567,7 @@ export class SdrController {
|
|||||||
// A failed worker (e.g. its module did not load) never recovers: drop
|
// A failed worker (e.g. its module did not load) never recovers: drop
|
||||||
// it so the next session builds and configures a fresh one.
|
// it so the next session builds and configures a fresh one.
|
||||||
this._discardWorker();
|
this._discardWorker();
|
||||||
this._setState({
|
this._setState(errorState(msg('SDR decoder worker failed')));
|
||||||
status: 'error',
|
|
||||||
message: t('SDR decoder worker failed'),
|
|
||||||
});
|
|
||||||
if (this._device) void this.stop({ preserveMessage: true });
|
if (this._device) void this.stop({ preserveMessage: true });
|
||||||
};
|
};
|
||||||
return worker;
|
return worker;
|
||||||
@@ -779,16 +788,16 @@ export class SdrController {
|
|||||||
// Published before stopping; stop() preserves it, and nothing is set
|
// Published before stopping; stop() preserves it, and nothing is set
|
||||||
// afterwards, so a reconnect started during teardown keeps its own state.
|
// afterwards, so a reconnect started during teardown keeps its own state.
|
||||||
this._setState({
|
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,
|
connected: false,
|
||||||
decoderOverload: reason,
|
decoderOverload: reason,
|
||||||
decoderOverloadSnapshot: snapshot,
|
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 });
|
void this.stop({ preserveMessage: true });
|
||||||
}
|
}
|
||||||
@@ -1075,6 +1084,7 @@ export class SdrController {
|
|||||||
this._setState({
|
this._setState({
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
message: t('Waiting for an RTL-SDR device…'),
|
message: t('Waiting for an RTL-SDR device…'),
|
||||||
|
errorText: null,
|
||||||
});
|
});
|
||||||
let acquisition = null;
|
let acquisition = null;
|
||||||
try {
|
try {
|
||||||
@@ -1085,7 +1095,7 @@ export class SdrController {
|
|||||||
// consumes the click activation.
|
// consumes the click activation.
|
||||||
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
||||||
throw new Error(
|
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;
|
if (superseded()) return false;
|
||||||
@@ -1153,8 +1163,7 @@ export class SdrController {
|
|||||||
if (superseded()) return false;
|
if (superseded()) return false;
|
||||||
this._setState({
|
this._setState({
|
||||||
connected: false,
|
connected: false,
|
||||||
status: 'error',
|
...errorState(describeConnectionError(error)),
|
||||||
message: describeConnectionError(error),
|
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1507,8 +1516,7 @@ export class SdrController {
|
|||||||
console.warn('[SDR] Sample read failed:', error);
|
console.warn('[SDR] Sample read failed:', error);
|
||||||
this._setState({
|
this._setState({
|
||||||
connected: false,
|
connected: false,
|
||||||
status: 'error',
|
...errorState(msg('RTL-SDR sample stream stopped')),
|
||||||
message: t('RTL-SDR sample stream stopped'),
|
|
||||||
});
|
});
|
||||||
await this.stop({ preserveMessage: true });
|
await this.stop({ preserveMessage: true });
|
||||||
break;
|
break;
|
||||||
@@ -1549,11 +1557,12 @@ export class SdrController {
|
|||||||
message: t('Switching to {mode}…', {
|
message: t('Switching to {mode}…', {
|
||||||
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
|
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
|
||||||
}),
|
}),
|
||||||
|
errorText: null,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
||||||
throw new Error(
|
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
|
// A stall teardown, stop() or reconnect during the switch supersedes
|
||||||
@@ -1578,10 +1587,7 @@ export class SdrController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this._owns(token, device)) return false;
|
if (!this._owns(token, device)) return false;
|
||||||
console.warn('[SDR] Mode switch failed:', error);
|
console.warn('[SDR] Mode switch failed:', error);
|
||||||
this._setState({
|
this._setState(errorState(describeConnectionError(error)));
|
||||||
status: 'error',
|
|
||||||
message: describeConnectionError(error),
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1622,10 +1628,7 @@ export class SdrController {
|
|||||||
if (targetMode === 'adsb') this._setState(this._resetAdsbCounters());
|
if (targetMode === 'adsb') this._setState(this._resetAdsbCounters());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[SDR] Gain change failed:', error);
|
console.warn('[SDR] Gain change failed:', error);
|
||||||
this._setState({
|
this._setState(errorState(msg('RTL-SDR gain change failed')));
|
||||||
status: 'error',
|
|
||||||
message: t('RTL-SDR gain change failed'),
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return setting;
|
return setting;
|
||||||
@@ -1679,10 +1682,7 @@ export class SdrController {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._setState({
|
this._setState(errorState(describeConnectionError(error)));
|
||||||
status: 'error',
|
|
||||||
message: describeConnectionError(error),
|
|
||||||
});
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1873,7 +1873,7 @@ export class SdrController {
|
|||||||
status: preserveMessage ? this.state.status : idleStatus,
|
status: preserveMessage ? this.state.status : idleStatus,
|
||||||
...(preserveMessage
|
...(preserveMessage
|
||||||
? {}
|
? {}
|
||||||
: { message: message || t('RTL-SDR disconnected') }),
|
: { message: message || t('RTL-SDR disconnected'), errorText: null }),
|
||||||
snr: null,
|
snr: null,
|
||||||
samplesPerSecond: 0,
|
samplesPerSecond: 0,
|
||||||
workerBlocks: 0,
|
workerBlocks: 0,
|
||||||
|
|||||||
+35
-1
@@ -112,12 +112,46 @@ 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'),
|
||||||
|
msg('routing busy — too many routes at once'),
|
||||||
|
msg('route proxy error'),
|
||||||
|
]);
|
||||||
|
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. */
|
/** One error text in the UI language, or null when nothing matches. */
|
||||||
function feedErrorText(text) {
|
function feedErrorText(text) {
|
||||||
if (FEED_ERRORS.has(text)) return t(text);
|
if (FEED_ERRORS.has(text)) return t(text);
|
||||||
const known = translateKnown(text);
|
const known = translateKnown(text);
|
||||||
if (known !== text) return known;
|
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);
|
const match = pattern.exec(text);
|
||||||
if (match) return format(match);
|
if (match) return format(match);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js';
|
|||||||
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
|
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
|
||||||
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||||
import { createWeatherSource } from '../layers/weather/source.js';
|
import { createWeatherSource } from '../layers/weather/source.js';
|
||||||
import {
|
import { presentRoutingFailure } from '../layers/directions/index.js';
|
||||||
presentRoutingFailure,
|
import { translateFeedError } from './statusText.js';
|
||||||
presentRoutingRefusal,
|
|
||||||
} from '../layers/directions/index.js';
|
|
||||||
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
|
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
|
||||||
|
|
||||||
const NO_IMAGERY = 'No street-level imagery within 50 m of that point';
|
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`, () => {
|
test(`${locale}: the route proxy's refusals are shown translated`, () => {
|
||||||
setLocale(locale);
|
setLocale(locale);
|
||||||
try {
|
try {
|
||||||
const shown = t('Routing failed: {error}', {
|
// Directions stores these in English; the Layers panel shows them
|
||||||
error: presentRoutingRefusal('route leg too long'),
|
// 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/);
|
assert.doesNotMatch(shown, /route leg too long/);
|
||||||
const limited = presentRoutingFailure(
|
const limited = presentRoutingFailure(
|
||||||
Object.assign(new Error('rate limited — try again in a moment'), {
|
Object.assign(new Error('rate limited — try again in a moment'), {
|
||||||
@@ -63,16 +65,46 @@ for (const locale of ['es', 'ru']) {
|
|||||||
refusal: 'rate limited',
|
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(
|
const http = presentRoutingFailure(
|
||||||
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
|
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
|
||||||
status: 503,
|
status: 503,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
assert.notEqual(http, 'Routing unavailable (HTTP 503)');
|
assert.equal(http, 'Routing unavailable (HTTP 503)');
|
||||||
assert.match(http, /503/);
|
assert.notEqual(translateFeedError(http), http);
|
||||||
|
assert.match(translateFeedError(http), /503/);
|
||||||
// A text the proxy never sends is shown as given.
|
// 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' }),
|
||||||
|
);
|
||||||
|
// Every refusal the proxy sends (server/providers/places/routes.js),
|
||||||
|
// and "rate limited" after "Routing failed:", which the feed pattern
|
||||||
|
// "<source> 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 {
|
} finally {
|
||||||
setLocale('en');
|
setLocale('en');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user