mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge branch 'main' into upstream/perimeters-stale
This commit is contained in:
172 files changed
+4441
-733
No files matched your search
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }] } },
|
||||
|
||||
@@ -124,7 +124,13 @@ class PlaybackClock {
|
||||
subscribe(listener) {
|
||||
if (this._destroyed || typeof listener !== 'function') return () => {};
|
||||
this._sceneClockListeners.add(listener);
|
||||
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
|
||||
if (this._sceneClockSnapshot) {
|
||||
try {
|
||||
listener({ ...this._sceneClockSnapshot });
|
||||
} catch (error) {
|
||||
console.warn('[Scenes] Scene clock listener failed:', error);
|
||||
}
|
||||
}
|
||||
return () => this._sceneClockListeners.delete(listener);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a
|
||||
});
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => {
|
||||
const f = fixture();
|
||||
f.clock.publish(scene, shot, 1);
|
||||
let calls = 0;
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const unsubscribe = f.clock.subscribe(() => {
|
||||
calls++;
|
||||
throw new Error('listener failure');
|
||||
});
|
||||
assert.equal(typeof unsubscribe, 'function');
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:');
|
||||
f.clock.publish(scene, shot, 2);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(warnings.length, 2);
|
||||
unsubscribe();
|
||||
f.clock.publish(scene, shot, 3);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(warnings.length, 2);
|
||||
f.clock.destroy();
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
@@ -178,6 +178,92 @@ test('synchronous stop before execution prevents any side effect; rejection unlo
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => {
|
||||
let executed = 0;
|
||||
let throwOnBusy = true;
|
||||
let throwOnEnd = true;
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const session = createInteractionSession({
|
||||
execute: () => {
|
||||
executed++;
|
||||
return true;
|
||||
},
|
||||
changed: (state) => {
|
||||
if (throwOnBusy && state.busy) throw new Error('start failed');
|
||||
if (throwOnEnd && !state.busy && state.selected === 'a') {
|
||||
throw new Error('end failed');
|
||||
}
|
||||
},
|
||||
});
|
||||
session.activate([{ id: 'a' }]);
|
||||
const result = await session.dispatch('a');
|
||||
assert.equal(result, true);
|
||||
assert.equal(executed, 1);
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: true,
|
||||
busy: false,
|
||||
selected: 'a',
|
||||
count: 1,
|
||||
});
|
||||
assert.equal(warnings.length, 2);
|
||||
assert.equal(
|
||||
warnings[0][0],
|
||||
'[Scenes] Interaction session observer failed:',
|
||||
);
|
||||
assert.equal(
|
||||
warnings[1][0],
|
||||
'[Scenes] Interaction session observer failed:',
|
||||
);
|
||||
throwOnBusy = false;
|
||||
throwOnEnd = false;
|
||||
assert.equal(await session.dispatch('a'), true);
|
||||
assert.equal(executed, 2);
|
||||
session.clear();
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: false,
|
||||
busy: false,
|
||||
selected: null,
|
||||
count: 0,
|
||||
});
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('an observer that always throws cannot stop activate or clear', () => {
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const session = createInteractionSession({
|
||||
execute: () => true,
|
||||
changed: () => {
|
||||
throw new Error('observer failed');
|
||||
},
|
||||
});
|
||||
session.activate([{ id: 'a' }, { id: 'b' }]);
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: true,
|
||||
busy: false,
|
||||
selected: null,
|
||||
count: 2,
|
||||
});
|
||||
session.clear();
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: false,
|
||||
busy: false,
|
||||
selected: null,
|
||||
count: 0,
|
||||
});
|
||||
assert.equal(warnings.length, 3);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
|
||||
const { SceneDirector } = await import('../../scenes/director.js');
|
||||
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
||||
|
||||
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
selected = null;
|
||||
let actions = new Map();
|
||||
const state = () => ({ active, busy, selected, count: actions.size });
|
||||
function notify() {
|
||||
try {
|
||||
changed(state());
|
||||
} catch (error) {
|
||||
console.warn('[Scenes] Interaction session observer failed:', error);
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
active = false;
|
||||
busy = false;
|
||||
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
actions.clear();
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
changed(state());
|
||||
notify();
|
||||
}
|
||||
return {
|
||||
clear,
|
||||
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
clear();
|
||||
actions = new Map(items.map((item) => [item.id, item]));
|
||||
active = !!actions.size;
|
||||
changed(state());
|
||||
notify();
|
||||
},
|
||||
async dispatch(id) {
|
||||
const item = actions.get(id);
|
||||
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
controller = current;
|
||||
busy = true;
|
||||
selected = id;
|
||||
changed(state());
|
||||
notify();
|
||||
let abort;
|
||||
try {
|
||||
const cancelled = new Promise((resolve) => {
|
||||
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
if (controller === current) {
|
||||
busy = false;
|
||||
controller = null;
|
||||
changed(state());
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export function buildPlaybackQueue(
|
||||
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
||||
* (false refuses that initial handoff). Between scenes and during final cleanup
|
||||
* it receives no cancelled token, so resource release can finish after Stop.
|
||||
* The first error wins when the final release also fails.
|
||||
* An empty/already-cancelled queue acquires and releases no resources.
|
||||
*
|
||||
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
|
||||
@@ -53,6 +54,7 @@ export async function playSceneQueue(
|
||||
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
||||
let activeScene = null;
|
||||
let completedShots = 0;
|
||||
let runFailed = false;
|
||||
try {
|
||||
if (!queue.length || cancelled()) {
|
||||
return {
|
||||
@@ -68,8 +70,9 @@ export async function playSceneQueue(
|
||||
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
||||
const { scene, shot } = queue[index];
|
||||
if (activeScene && activeScene.id !== scene.id) {
|
||||
await adapter.releaseScene(activeScene);
|
||||
const toRelease = activeScene;
|
||||
activeScene = null;
|
||||
await adapter.releaseScene(toRelease);
|
||||
if (cancelled()) break;
|
||||
}
|
||||
activeScene = scene;
|
||||
@@ -82,8 +85,23 @@ export async function playSceneQueue(
|
||||
}
|
||||
if (!cancelled()) await adapter.complete?.();
|
||||
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
||||
} catch (error) {
|
||||
runFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
|
||||
if (releaseOnFinish && activeScene) {
|
||||
const toRelease = activeScene;
|
||||
activeScene = null;
|
||||
try {
|
||||
await adapter.releaseScene(toRelease);
|
||||
} catch (error) {
|
||||
if (runFailed) {
|
||||
console.warn('[Scenes] Scene release failed:', error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,3 +196,68 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy
|
||||
(error) => error === failure,
|
||||
);
|
||||
});
|
||||
|
||||
test('a phase failure is preserved when final scene release also throws', async () => {
|
||||
const phaseError = new Error('travel failed');
|
||||
const releaseError = new Error('release failed');
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const f = fixture({
|
||||
travel: async () => {
|
||||
throw phaseError;
|
||||
},
|
||||
releaseScene: async () => {
|
||||
throw releaseError;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||
(error) => error === phaseError,
|
||||
);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0][0], '[Scenes] Scene release failed:');
|
||||
assert.equal(warnings[0][1], releaseError);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('a failing inter-scene release releases the active scene only once', async () => {
|
||||
const releaseError = new Error('release failed');
|
||||
const released = [];
|
||||
const f = fixture({
|
||||
releaseScene: async (scene) => {
|
||||
released.push(scene.id);
|
||||
throw releaseError;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
|
||||
(error) => error === releaseError,
|
||||
);
|
||||
assert.deepEqual(released, ['a']);
|
||||
});
|
||||
|
||||
test('a falsy phase failure is still preserved over a release failure', async () => {
|
||||
const releaseError = new Error('release failed');
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const f = fixture({
|
||||
travel: async () => {
|
||||
throw undefined;
|
||||
},
|
||||
releaseScene: async () => {
|
||||
throw releaseError;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||
(error) => error === undefined,
|
||||
);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,8 @@ export function hudQuantity(value, unit, digits = 0) {
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const label = unitLabel(unit);
|
||||
if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number;
|
||||
if (getLocale().startsWith('zh-'))
|
||||
return label ? `${number} ${label}` : number;
|
||||
return `${number}${label.toUpperCase()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { createModel } from '../layers/firms/model.js';
|
||||
import { createQueries } from '../layers/firms/queries.js';
|
||||
|
||||
const cases = [
|
||||
[0, '不到 1 分钟前', '<1m ago'],
|
||||
[120000, '2 分钟前', '2m ago'],
|
||||
[7200000, '2 小时前', '2h ago'],
|
||||
[0, '不到 1 分钟前', '<1m ago', '不到 1 分鐘前'],
|
||||
[120000, '2 分钟前', '2m ago', '2 分鐘前'],
|
||||
[7200000, '2 小时前', '2h ago', '2 小時前'],
|
||||
];
|
||||
|
||||
for (const [age, zh, en] of cases) {
|
||||
for (const [age, zh, en, hant] of cases) {
|
||||
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
|
||||
const now = 1800000000000;
|
||||
context.mock.method(Date, 'now', () => now);
|
||||
@@ -36,6 +36,8 @@ for (const [age, zh, en] of cases) {
|
||||
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
|
||||
setLocale('zh-CN');
|
||||
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
|
||||
setLocale('zh-Hant');
|
||||
assert.equal(methods.getStats().loadingLabel, `實時 · 更新於 ${hant}`);
|
||||
} finally {
|
||||
setLocale(previousLocale);
|
||||
}
|
||||
|
||||
@@ -35,12 +35,18 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
// Simplified Chinese by default; Traditional tags wait for zh-TW.
|
||||
// Explicit script tags take precedence over regional defaults.
|
||||
assert.equal(normalizeLocale('zh'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-TW'), null);
|
||||
assert.equal(normalizeLocale('zh-Hant-HK'), null);
|
||||
assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant');
|
||||
assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant');
|
||||
assert.equal(normalizeLocale('zh-TW'), 'zh-Hant');
|
||||
assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant');
|
||||
assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant');
|
||||
assert.equal(normalizeLocale('zh_HK'), 'zh-Hant');
|
||||
assert.equal(normalizeLocale('zh-MO'), 'zh-Hant');
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -19,8 +19,9 @@ import {
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN });
|
||||
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
|
||||
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
|
||||
|
||||
+9
-6
@@ -8,14 +8,15 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']);
|
||||
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']);
|
||||
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
ru: 'Русский',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-Hant': '中文(繁體)',
|
||||
});
|
||||
|
||||
/** Right-to-left locales. */
|
||||
@@ -23,17 +24,19 @@ const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'.
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
|
||||
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLocale(candidate) {
|
||||
if (typeof candidate !== 'string') return null;
|
||||
const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
|
||||
// Traditional Chinese readers are not given Simplified: no zh-TW yet.
|
||||
if (primary === 'zh')
|
||||
if (primary === 'zh') {
|
||||
if (rest.includes('hans')) return 'zh-CN';
|
||||
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
|
||||
? null
|
||||
? 'zh-Hant'
|
||||
: 'zh-CN';
|
||||
}
|
||||
return LOCALES.includes(primary) ? primary : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "ошибка прокси маршрутов"
|
||||
}
|
||||
@@ -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": "路线代理出错"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -198,5 +198,62 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"GEO",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"House",
|
||||
"Ironbow",
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"hemisphere|E",
|
||||
"hemisphere|N",
|
||||
"hemisphere|S",
|
||||
"hemisphere|W",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t, tn, getLocale } from './index.js';
|
||||
import {
|
||||
resolveLocale,
|
||||
LOCALE_STORAGE_KEY,
|
||||
writeStoredLocale,
|
||||
applyDocumentLanguage,
|
||||
} from './locale.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
|
||||
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
|
||||
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
|
||||
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
|
||||
|
||||
test('Traditional Chinese resolves from URL, saved preference and browser language', () => {
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '?lang=zh-TW' },
|
||||
storage: { getItem: () => 'en' },
|
||||
languages: ['en'],
|
||||
}),
|
||||
'zh-Hant',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: {
|
||||
getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'zh-TW' : null),
|
||||
},
|
||||
languages: ['en'],
|
||||
}),
|
||||
'zh-Hant',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: { getItem: () => null },
|
||||
languages: ['zh-Hant', 'en'],
|
||||
}),
|
||||
'zh-Hant',
|
||||
);
|
||||
});
|
||||
|
||||
test('Traditional Chinese persists and exposes the script locale for legacy choices', () => {
|
||||
const stored = new Map();
|
||||
assert.equal(
|
||||
writeStoredLocale('zh-TW', {
|
||||
setItem: (key, value) => stored.set(key, value),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(stored.get(LOCALE_STORAGE_KEY), 'zh-Hant');
|
||||
const doc = { documentElement: {} };
|
||||
assert.equal(applyDocumentLanguage(doc, 'zh-TW'), true);
|
||||
assert.equal(doc.documentElement.lang, 'zh-Hant');
|
||||
assert.equal(doc.documentElement.dir, 'ltr');
|
||||
});
|
||||
|
||||
test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => {
|
||||
const previous = getLocale();
|
||||
try {
|
||||
setLocale('zh-Hant');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km');
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
|
||||
assert.equal(
|
||||
LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
|
||||
'扇區 21.33N 77.50W',
|
||||
);
|
||||
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
|
||||
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
|
||||
assert.equal(cctvSourceStatusLabel('ok'), '正常');
|
||||
assert.equal(t('SAVE KEYS'), '保存密鑰');
|
||||
assert.equal(
|
||||
tn(2, 'Click {count} more point.', 'Click {count} more points.'),
|
||||
'再點擊 2 個點。',
|
||||
);
|
||||
} finally {
|
||||
setLocale(previous);
|
||||
}
|
||||
});
|
||||
@@ -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. */
|
||||
@@ -659,7 +647,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,
|
||||
};
|
||||
@@ -678,7 +668,7 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
error: error || t('No route found'),
|
||||
error: error || msg('No route found'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -1160,13 +1150,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;
|
||||
@@ -1183,9 +1170,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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* 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.
|
||||
|
||||
@@ -51,7 +51,7 @@ 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 (
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -33,7 +33,9 @@ export const READOUT_WORDS = Object.freeze({
|
||||
date: (day) => displayDay(day, LONG_DAY),
|
||||
daysAgo: (delta) => {
|
||||
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
|
||||
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
|
||||
return getLocale().startsWith('zh-')
|
||||
? text.replace(/(\d)(?=天)/g, '$1 ')
|
||||
: text;
|
||||
},
|
||||
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
|
||||
// (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
|
||||
+34
-34
@@ -283,22 +283,33 @@ 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.
|
||||
* 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) {
|
||||
@@ -350,6 +361,7 @@ export class SdrController {
|
||||
gain: this._gainByMode.fm,
|
||||
seeking: false,
|
||||
seekMessage: '',
|
||||
errorText: null,
|
||||
snr: null,
|
||||
aircraft: [],
|
||||
decodedMessages: 0,
|
||||
@@ -555,10 +567,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 +788,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 });
|
||||
}
|
||||
@@ -1075,6 +1084,7 @@ export class SdrController {
|
||||
this._setState({
|
||||
status: 'connecting',
|
||||
message: t('Waiting for an RTL-SDR device…'),
|
||||
errorText: null,
|
||||
});
|
||||
let acquisition = null;
|
||||
try {
|
||||
@@ -1085,7 +1095,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 +1163,7 @@ export class SdrController {
|
||||
if (superseded()) return false;
|
||||
this._setState({
|
||||
connected: false,
|
||||
status: 'error',
|
||||
message: describeConnectionError(error),
|
||||
...errorState(describeConnectionError(error)),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -1507,8 +1516,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;
|
||||
@@ -1549,11 +1557,12 @@ export class SdrController {
|
||||
message: t('Switching to {mode}…', {
|
||||
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
|
||||
}),
|
||||
errorText: null,
|
||||
});
|
||||
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 +1587,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 +1628,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 +1682,7 @@ export class SdrController {
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: describeConnectionError(error),
|
||||
});
|
||||
this._setState(errorState(describeConnectionError(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1873,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,
|
||||
|
||||
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -149,7 +149,7 @@ export function formatCockpitDistance(distanceM) {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
|
||||
return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
|
||||
@@ -20,6 +20,11 @@ export const LOCALE_SEARCH_THRESHOLD = 8;
|
||||
/** Type-ahead keystrokes closer together than this extend one prefix. */
|
||||
export const TYPEAHEAD_RESET_MS = 500;
|
||||
|
||||
const CHINESE_LABELS = {
|
||||
'zh-CN': 'Chinese (Simplified)',
|
||||
'zh-Hant': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Text folded for search: no diacritics, lower case. 'Español' → 'espanol',
|
||||
* 'Ёжик' → 'ежик'.
|
||||
@@ -464,12 +469,13 @@ export function mountLocalePicker({
|
||||
item.dataset.locale = option.code;
|
||||
const name = element(documentRef, 'span', 'locale-picker-native');
|
||||
name.textContent = option.nativeName;
|
||||
// Every row reads the same: the language named in itself, then its code.
|
||||
// The interface-language name only helps the search find it.
|
||||
// Chinese variants are distinguished by script rather than region.
|
||||
const meta = element(documentRef, 'span', 'locale-picker-meta');
|
||||
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
|
||||
codeLabel.setAttribute('translate', 'no');
|
||||
codeLabel.textContent = option.code.toUpperCase();
|
||||
codeLabel.textContent =
|
||||
CHINESE_LABELS[option.code] || option.code.toUpperCase();
|
||||
if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en';
|
||||
meta.append(codeLabel);
|
||||
const text = element(documentRef, 'span', 'locale-picker-text');
|
||||
text.append(name, meta);
|
||||
|
||||
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'en', 'es', 'zh-CN'],
|
||||
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['es', 'en', 'ru', 'zh-CN'],
|
||||
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['en', 'es', 'ru', 'zh-CN'],
|
||||
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
|
||||
);
|
||||
const rows = buildLocaleOptions({
|
||||
locales: MANY,
|
||||
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'en');
|
||||
model.keydown({ key: 'End' });
|
||||
assert.equal(model.activeOption.code, 'zh-CN');
|
||||
assert.equal(model.activeOption.code, 'zh-Hant');
|
||||
model.keydown({ key: 'Home' });
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
model.keydown({ key: 'ArrowUp' });
|
||||
|
||||
+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. */
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -168,8 +168,9 @@ function presentViewer(state) {
|
||||
const { street } = state;
|
||||
const right = [];
|
||||
if (street.isPano) right.push('360°');
|
||||
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
|
||||
if (Number.isFinite(street.bearing))
|
||||
right.push(`${Math.round(street.bearing)}°`);
|
||||
right.push(`${Math.round(street.bearing) % 360}°`);
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
|
||||
@@ -273,6 +273,14 @@ test('viewer caption reads "Image by" left, date right, and links to Mapillary',
|
||||
assert.equal(view.viewer.open, true);
|
||||
});
|
||||
|
||||
test('a bearing that rounds to north reads 0°, not 360°', () => {
|
||||
const caption = (bearing) =>
|
||||
presentStreetLevelPanel(snapshot({ street: { open: true, bearing } }))
|
||||
.viewer.captionRight;
|
||||
assert.equal(caption(359.4), '359°');
|
||||
assert.equal(caption(359.6), '0°');
|
||||
});
|
||||
|
||||
test('an image without a creator name leaves the left caption empty', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
|
||||
@@ -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,46 @@ 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' }),
|
||||
);
|
||||
// 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 {
|
||||
setLocale('en');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user