mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into upstream/geocode-place-region
This commit is contained in:
36 files changed
+1339
-138
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
- Director clock listeners, interaction observers and scene release failures
|
||||||
|
no longer leave the director inconsistent: each scene is released once, and
|
||||||
|
a phase error is kept (with the release failure logged) when cleanup also
|
||||||
|
fails (deepeshbind, #1014).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Restore a shot's declared layer visibility when seeking within it after manual
|
||||||
|
layer changes, including toggles still in flight. Only changed layers are
|
||||||
|
reconciled; refused restoration reports a failed seek and keeps the last
|
||||||
|
position, while undeclared operator layers are preserved (@oneill9, #1374).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
|
||||||
|
the last snapshot is being shown (STALE, its age, "data may be stale"), and
|
||||||
|
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
|
||||||
+15
-1
@@ -560,6 +560,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading
|
|||||||
and roll the short way for any authored angles.
|
and roll the short way for any authored angles.
|
||||||
A seek to a shot's start, as a shot action makes, selects that shot rather than
|
A seek to a shot's start, as a shot action makes, selects that shot rather than
|
||||||
the end of the previous one.
|
the end of the previous one.
|
||||||
|
Same-shot seeking restores declared layer visibility after a manual toggle,
|
||||||
|
including one still in flight, by reconciling only the changed layers through
|
||||||
|
the cancellable layer admission path. Refused restoration reports a failed
|
||||||
|
seek and keeps the last settled position. Undeclared operator layers remain
|
||||||
|
untouched, and the shot is not rebuilt.
|
||||||
See [camera directions](DIRECTOR-CAMERA.md).
|
See [camera directions](DIRECTOR-CAMERA.md).
|
||||||
|
|
||||||
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
||||||
@@ -1144,6 +1149,10 @@ Shot selection updates the highlight without replacing the row, preserving
|
|||||||
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
||||||
late file and failed-action completions cannot update removed presentation.
|
late file and failed-action completions cannot update removed presentation.
|
||||||
|
|
||||||
|
Director clock listeners, interaction observers and scene release failures no
|
||||||
|
longer leave the director inconsistent, and a phase error is kept when cleanup
|
||||||
|
also fails.
|
||||||
|
|
||||||
## Cockpit component ownership
|
## Cockpit component ownership
|
||||||
|
|
||||||
Cockpit presentation is separated from its camera/controller behavior. Existing
|
Cockpit presentation is separated from its camera/controller behavior. Existing
|
||||||
@@ -3851,7 +3860,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
|
|||||||
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||||
|
|
||||||
|
|
||||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||||
|
|
||||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||||
@@ -6067,6 +6076,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.
|
||||||
|
|||||||
@@ -88,6 +88,70 @@ try {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||||
|
const visibilityRestored = await page.evaluate(async () => {
|
||||||
|
// QA layer registration exists only in Vite dev builds.
|
||||||
|
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
|
||||||
|
const { sceneDirector: director, dataManager } = window.__godsEyeView;
|
||||||
|
const layerId = 'director-seek-visibility-qa';
|
||||||
|
window.__gevQaRegisterLayer(dataManager, {
|
||||||
|
id: layerId,
|
||||||
|
name: 'Director seek visibility QA',
|
||||||
|
updateInterval: -1,
|
||||||
|
init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
update() {},
|
||||||
|
destroy() {},
|
||||||
|
getStats: () => ({ count: 0 }),
|
||||||
|
});
|
||||||
|
const scene = {
|
||||||
|
id: 'visibility-qa',
|
||||||
|
title: 'Visibility QA',
|
||||||
|
shots: [
|
||||||
|
{
|
||||||
|
id: 'visibility-shot',
|
||||||
|
title: 'Visibility shot',
|
||||||
|
durationSec: 0.4,
|
||||||
|
holdSec: 0,
|
||||||
|
camera: director.styleManager.getCameraState(),
|
||||||
|
visual: director.styleManager.getVisualState(),
|
||||||
|
layers: { [layerId]: { enabled: true } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
director._project.scenes.push(scene);
|
||||||
|
try {
|
||||||
|
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
|
||||||
|
flyDuration: 0.2,
|
||||||
|
});
|
||||||
|
const disabled =
|
||||||
|
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
|
||||||
|
!dataManager.layers.get(layerId).enabled;
|
||||||
|
const sought = await director.seekScene(scene.id, 0.5);
|
||||||
|
return (
|
||||||
|
loaded?.started &&
|
||||||
|
disabled &&
|
||||||
|
sought &&
|
||||||
|
dataManager.layers.get(layerId).enabled
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
director.stopScene();
|
||||||
|
director._project.scenes = director._project.scenes.filter(
|
||||||
|
({ id }) => id !== scene.id,
|
||||||
|
);
|
||||||
|
await window.__gevQaUnregisterLayer(dataManager, layerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (visibilityRestored === 'skipped') {
|
||||||
|
console.log(
|
||||||
|
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
check(
|
||||||
|
'same-shot seek restores manually changed layer visibility',
|
||||||
|
visibilityRestored,
|
||||||
|
);
|
||||||
|
}
|
||||||
const content = await page.evaluate(async () => {
|
const content = await page.evaluate(async () => {
|
||||||
const d = window.__godsEyeView.sceneDirector;
|
const d = window.__godsEyeView.sceneDirector;
|
||||||
const scene = d._project.scenes.find(({ title }) =>
|
const scene = d._project.scenes.find(({ title }) =>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const API_URL =
|
|||||||
// The service caps a single response at its maxRecordCount (2000); a peak
|
// The service caps a single response at its maxRecordCount (2000); a peak
|
||||||
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
||||||
const MAX_PAGES = 5;
|
const MAX_PAGES = 5;
|
||||||
|
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
|
||||||
|
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const exceededTransferLimit = (payload) =>
|
const exceededTransferLimit = (payload) =>
|
||||||
payload?.exceededTransferLimit === true ||
|
payload?.exceededTransferLimit === true ||
|
||||||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
|
|||||||
});
|
});
|
||||||
return { value: await promise, stale: false };
|
return { value: await promise, stale: false };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (previous) return { value: previous.value, stale: true };
|
// A failed refresh serves the last answer, marked stale. A perimeter
|
||||||
|
// snapshot only for a day: older than that, it is no picture of
|
||||||
|
// current fires. (InciWeb entries keep their previous behaviour.)
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
|
||||||
|
)
|
||||||
|
return { value: previous.value, stale: true };
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
viewer,
|
viewer,
|
||||||
_sceneSeekGeneration: 0,
|
_sceneSeekGeneration: 0,
|
||||||
_loadGeneration: 0,
|
_loadGeneration: 0,
|
||||||
|
_bumpSceneSeekGeneration:
|
||||||
|
SceneDirector.prototype._bumpSceneSeekGeneration,
|
||||||
_cancelActiveSceneTravel:
|
_cancelActiveSceneTravel:
|
||||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||||
|
|||||||
@@ -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 }] } },
|
||||||
|
|||||||
@@ -124,7 +124,13 @@ class PlaybackClock {
|
|||||||
subscribe(listener) {
|
subscribe(listener) {
|
||||||
if (this._destroyed || typeof listener !== 'function') return () => {};
|
if (this._destroyed || typeof listener !== 'function') return () => {};
|
||||||
this._sceneClockListeners.add(listener);
|
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);
|
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);
|
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);
|
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 () => {
|
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
|
||||||
const { SceneDirector } = await import('../../scenes/director.js');
|
const { SceneDirector } = await import('../../scenes/director.js');
|
||||||
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
||||||
@@ -188,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
|
|||||||
_claimCameraOwnership: () =>
|
_claimCameraOwnership: () =>
|
||||||
assert.fail('Must reload the released pack before camera seek'),
|
assert.fail('Must reload the released pack before camera seek'),
|
||||||
});
|
});
|
||||||
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
|
assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
|
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
selected = null;
|
selected = null;
|
||||||
let actions = new Map();
|
let actions = new Map();
|
||||||
const state = () => ({ active, busy, selected, count: actions.size });
|
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() {
|
function clear() {
|
||||||
active = false;
|
active = false;
|
||||||
busy = false;
|
busy = false;
|
||||||
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
actions.clear();
|
actions.clear();
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
controller = null;
|
controller = null;
|
||||||
changed(state());
|
notify();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
clear,
|
clear,
|
||||||
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
clear();
|
clear();
|
||||||
actions = new Map(items.map((item) => [item.id, item]));
|
actions = new Map(items.map((item) => [item.id, item]));
|
||||||
active = !!actions.size;
|
active = !!actions.size;
|
||||||
changed(state());
|
notify();
|
||||||
},
|
},
|
||||||
async dispatch(id) {
|
async dispatch(id) {
|
||||||
const item = actions.get(id);
|
const item = actions.get(id);
|
||||||
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
controller = current;
|
controller = current;
|
||||||
busy = true;
|
busy = true;
|
||||||
selected = id;
|
selected = id;
|
||||||
changed(state());
|
notify();
|
||||||
let abort;
|
let abort;
|
||||||
try {
|
try {
|
||||||
const cancelled = new Promise((resolve) => {
|
const cancelled = new Promise((resolve) => {
|
||||||
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
if (controller === current) {
|
if (controller === current) {
|
||||||
busy = false;
|
busy = false;
|
||||||
controller = null;
|
controller = null;
|
||||||
changed(state());
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function buildPlaybackQueue(
|
|||||||
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
||||||
* (false refuses that initial handoff). Between scenes and during final cleanup
|
* (false refuses that initial handoff). Between scenes and during final cleanup
|
||||||
* it receives no cancelled token, so resource release can finish after Stop.
|
* 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.
|
* An empty/already-cancelled queue acquires and releases no resources.
|
||||||
*
|
*
|
||||||
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
|
* @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);
|
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
||||||
let activeScene = null;
|
let activeScene = null;
|
||||||
let completedShots = 0;
|
let completedShots = 0;
|
||||||
|
let runFailed = false;
|
||||||
try {
|
try {
|
||||||
if (!queue.length || cancelled()) {
|
if (!queue.length || cancelled()) {
|
||||||
return {
|
return {
|
||||||
@@ -68,8 +70,9 @@ export async function playSceneQueue(
|
|||||||
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
||||||
const { scene, shot } = queue[index];
|
const { scene, shot } = queue[index];
|
||||||
if (activeScene && activeScene.id !== scene.id) {
|
if (activeScene && activeScene.id !== scene.id) {
|
||||||
await adapter.releaseScene(activeScene);
|
const toRelease = activeScene;
|
||||||
activeScene = null;
|
activeScene = null;
|
||||||
|
await adapter.releaseScene(toRelease);
|
||||||
if (cancelled()) break;
|
if (cancelled()) break;
|
||||||
}
|
}
|
||||||
activeScene = scene;
|
activeScene = scene;
|
||||||
@@ -82,8 +85,23 @@ export async function playSceneQueue(
|
|||||||
}
|
}
|
||||||
if (!cancelled()) await adapter.complete?.();
|
if (!cancelled()) await adapter.complete?.();
|
||||||
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
||||||
|
} catch (error) {
|
||||||
|
runFailed = true;
|
||||||
|
throw error;
|
||||||
} finally {
|
} 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,
|
(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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
|
|||||||
let _dataSource = null;
|
let _dataSource = null;
|
||||||
let _count = 0;
|
let _count = 0;
|
||||||
let _lastUpdate = null;
|
let _lastUpdate = null;
|
||||||
|
// The proxy's last snapshot, served while WFIGS is unreachable.
|
||||||
|
let _stale = false;
|
||||||
let _lastError = null;
|
let _lastError = null;
|
||||||
let _enabled = false;
|
let _enabled = false;
|
||||||
let _clickHandler = null;
|
let _clickHandler = null;
|
||||||
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
|
|||||||
_count = 0;
|
_count = 0;
|
||||||
_lastUpdate = null;
|
_lastUpdate = null;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
|
_stale = false;
|
||||||
_enabled = false;
|
_enabled = false;
|
||||||
console.log('[Data:FirePerimeters] Initialized');
|
console.log('[Data:FirePerimeters] Initialized');
|
||||||
},
|
},
|
||||||
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
|
|||||||
_request = request;
|
_request = request;
|
||||||
refreshInciwebIndex(request.signal);
|
refreshInciwebIndex(request.signal);
|
||||||
try {
|
try {
|
||||||
const rows = await source.getSnapshot({ signal: request.signal });
|
// The source contract is getSnapshot; a source that can also say how
|
||||||
|
// fresh its rows are (the WFIGS proxy) offers getSnapshotDetail.
|
||||||
|
const snapshot =
|
||||||
|
typeof source.getSnapshotDetail === 'function'
|
||||||
|
? await source.getSnapshotDetail({ signal: request.signal })
|
||||||
|
: { rows: await source.getSnapshot({ signal: request.signal }) };
|
||||||
if (request.signal.aborted || _request !== request || !_enabled)
|
if (request.signal.aborted || _request !== request || !_enabled)
|
||||||
return false;
|
return false;
|
||||||
|
const { rows } = snapshot;
|
||||||
|
_stale = snapshot.stale === true;
|
||||||
|
// A stale snapshot is as old as its fetch, not this poll.
|
||||||
|
const updatedAt =
|
||||||
|
_stale && Number.isFinite(snapshot.fetchedAt)
|
||||||
|
? snapshot.fetchedAt
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
// Incident facts may change independently of the polygon's source clock.
|
// Incident facts may change independently of the polygon's source clock.
|
||||||
const signature = JSON.stringify(
|
const signature = JSON.stringify(
|
||||||
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
|
|||||||
);
|
);
|
||||||
if (signature === _snapshotSignature) {
|
if (signature === _snapshotSignature) {
|
||||||
if (_selectedId) publishSelectedCard();
|
if (_selectedId) publishSelectedCard();
|
||||||
_lastUpdate = Date.now();
|
_lastUpdate = updatedAt;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
|
|||||||
// Refresh (or drop) the selected incident's card against the new feed.
|
// Refresh (or drop) the selected incident's card against the new feed.
|
||||||
if (_selectedId) publishSelectedCard();
|
if (_selectedId) publishSelectedCard();
|
||||||
_count = rows.length;
|
_count = rows.length;
|
||||||
_lastUpdate = Date.now();
|
_lastUpdate = updatedAt;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
console.log(
|
console.log(
|
||||||
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
||||||
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
|
|||||||
return false;
|
return false;
|
||||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||||
_lastError = e?.message || msg('Perimeter source unavailable');
|
_lastError = e?.message || msg('Perimeter source unavailable');
|
||||||
|
// A failure now outranks the stale snapshot still drawn.
|
||||||
|
_stale = false;
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (_request === request) _request = null;
|
if (_request === request) _request = null;
|
||||||
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
|
|||||||
_count = 0;
|
_count = 0;
|
||||||
_lastUpdate = null;
|
_lastUpdate = null;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
|
_stale = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
||||||
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
|
|||||||
count: _count,
|
count: _count,
|
||||||
lastUpdate: _lastUpdate,
|
lastUpdate: _lastUpdate,
|
||||||
error: _lastError,
|
error: _lastError,
|
||||||
|
stale: _stale,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
|
|||||||
if (action !== 'destroy') h.layer.destroy();
|
if (action !== 'destroy') h.layer.destroy();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => {
|
||||||
|
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
|
||||||
|
let snapshot = { rows: [row], stale: true, fetchedAt };
|
||||||
|
const h = harness({
|
||||||
|
getSnapshot: async () => snapshot.rows,
|
||||||
|
getSnapshotDetail: async () => snapshot,
|
||||||
|
});
|
||||||
|
await h.layer.update(h.viewer);
|
||||||
|
assert.equal(h.layer.getStats().stale, true);
|
||||||
|
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||||
|
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
||||||
|
await h.layer.update(h.viewer);
|
||||||
|
assert.equal(h.layer.getStats().stale, false);
|
||||||
|
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
|
||||||
|
let fail = false;
|
||||||
|
const h = harness({
|
||||||
|
getSnapshot: async () => [row],
|
||||||
|
getSnapshotDetail: async () => {
|
||||||
|
if (fail) throw new Error('WFIGS HTTP 502');
|
||||||
|
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await h.layer.update(h.viewer);
|
||||||
|
assert.equal(h.layer.getStats().stale, true);
|
||||||
|
fail = true;
|
||||||
|
await h.layer.update(h.viewer);
|
||||||
|
assert.equal(h.layer.getStats().stale, false);
|
||||||
|
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
|
||||||
|
});
|
||||||
@@ -8,20 +8,35 @@ const msg = (text) => text;
|
|||||||
export function createWfigsPerimeterSource({
|
export function createWfigsPerimeterSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
/**
|
||||||
|
* The rows with how fresh they are: the proxy answers from its last
|
||||||
|
* snapshot, marked stale, while WFIGS is unreachable.
|
||||||
|
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
|
||||||
|
*/
|
||||||
|
async function getSnapshotDetail({ signal } = {}) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||||
|
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||||
|
const payload = await readResponseJsonCapped(
|
||||||
|
response,
|
||||||
|
80 * 1024 * 1024,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (!Array.isArray(payload?.rows))
|
||||||
|
throw new Error(msg('Malformed perimeter snapshot'));
|
||||||
|
return {
|
||||||
|
rows: payload.rows,
|
||||||
|
stale: payload.stale === true,
|
||||||
|
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async getSnapshot({ signal } = {}) {
|
/** The perimeter rows (see getSnapshotDetail for staleness). */
|
||||||
signal?.throwIfAborted();
|
async getSnapshot(options = {}) {
|
||||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
return (await getSnapshotDetail(options)).rows;
|
||||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
|
||||||
const payload = await readResponseJsonCapped(
|
|
||||||
response,
|
|
||||||
80 * 1024 * 1024,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
signal?.throwIfAborted();
|
|
||||||
if (!Array.isArray(payload?.rows))
|
|
||||||
throw new Error(msg('Malformed perimeter snapshot'));
|
|
||||||
return payload.rows;
|
|
||||||
},
|
},
|
||||||
|
getSnapshotDetail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
|
|||||||
name: 'AbortError',
|
name: 'AbortError',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
|
||||||
|
let clock = 1_000_000_000;
|
||||||
|
let up = true;
|
||||||
|
const plugin = firePerimetersProxy({
|
||||||
|
now: () => clock,
|
||||||
|
fetchImpl: async () => {
|
||||||
|
if (!up) throw new Error('WFIGS unreachable');
|
||||||
|
return Response.json(validPayload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let handler;
|
||||||
|
plugin.configureServer({
|
||||||
|
middlewares: { use: (_path, callback) => (handler = callback) },
|
||||||
|
});
|
||||||
|
const ask = async () => {
|
||||||
|
const reply = {};
|
||||||
|
await handler(
|
||||||
|
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
|
||||||
|
{
|
||||||
|
writeHead(status, headers) {
|
||||||
|
reply.status = status;
|
||||||
|
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
|
||||||
|
},
|
||||||
|
end(body) {
|
||||||
|
reply.body = JSON.parse(body);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return reply;
|
||||||
|
};
|
||||||
|
const fresh = await ask();
|
||||||
|
assert.equal(fresh.status, 200);
|
||||||
|
assert.equal(fresh.body.stale, undefined);
|
||||||
|
up = false;
|
||||||
|
clock += 23 * 60 * 60 * 1000;
|
||||||
|
const stale = await ask();
|
||||||
|
assert.equal(stale.status, 200);
|
||||||
|
assert.equal(stale.staleHeader, true);
|
||||||
|
assert.equal(stale.body.stale, true);
|
||||||
|
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
|
||||||
|
clock += 2 * 60 * 60 * 1000;
|
||||||
|
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
|
||||||
|
const fetchedAt = Date.UTC(2026, 9, 25, 12);
|
||||||
|
const source = createWfigsPerimeterSource({
|
||||||
|
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(await source.getSnapshotDetail(), {
|
||||||
|
rows: [],
|
||||||
|
stale: true,
|
||||||
|
fetchedAt,
|
||||||
|
});
|
||||||
|
assert.deepEqual(await source.getSnapshot(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSnapshot works when taken off its source object', async () => {
|
||||||
|
const { getSnapshot } = createWfigsPerimeterSource({
|
||||||
|
fetchImpl: async () => Response.json({ rows: [] }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(await getSnapshot(), []);
|
||||||
|
});
|
||||||
+114
-17
@@ -153,6 +153,8 @@ export class SceneDirector {
|
|||||||
this._runAbort = null;
|
this._runAbort = null;
|
||||||
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
|
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
|
||||||
this._loadAbort = null;
|
this._loadAbort = null;
|
||||||
|
/** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */
|
||||||
|
this._seekAbort = null;
|
||||||
this._clock = createPlaybackClock({
|
this._clock = createPlaybackClock({
|
||||||
isRunning: () => this._running,
|
isRunning: () => this._running,
|
||||||
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
|
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
|
||||||
@@ -234,7 +236,7 @@ export class SceneDirector {
|
|||||||
destroy() {
|
destroy() {
|
||||||
if (this._destroyPromise) return this._destroyPromise;
|
if (this._destroyPromise) return this._destroyPromise;
|
||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
this._sceneSeekGeneration++;
|
this._bumpSceneSeekGeneration();
|
||||||
this._visibilityUnsubscribe?.();
|
this._visibilityUnsubscribe?.();
|
||||||
this._cameraHandoffUnsubscribe?.();
|
this._cameraHandoffUnsubscribe?.();
|
||||||
this._removeCameraInput?.();
|
this._removeCameraInput?.();
|
||||||
@@ -1071,7 +1073,7 @@ export class SceneDirector {
|
|||||||
loadShot(sceneId, shotId, options) {
|
loadShot(sceneId, shotId, options) {
|
||||||
if (this._destroyed)
|
if (this._destroyed)
|
||||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||||
this._sceneSeekGeneration++;
|
this._bumpSceneSeekGeneration();
|
||||||
this._interactionTransitions = 0;
|
this._interactionTransitions = 0;
|
||||||
const previousGeneration = this._loadGeneration;
|
const previousGeneration = this._loadGeneration;
|
||||||
const work = this._loadShot(sceneId, shotId, options);
|
const work = this._loadShot(sceneId, shotId, options);
|
||||||
@@ -1516,9 +1518,30 @@ export class SceneDirector {
|
|||||||
return new Promise((resolve) => this._runIdleResolvers.add(resolve));
|
return new Promise((resolve) => this._runIdleResolvers.add(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a new time inside the already-loaded shot without rebuilding its stack. */
|
/**
|
||||||
_seekLoadedShot(scene, seekState) {
|
* Supersede any pending seek. Only a seek's own layer reconcile is aborted:
|
||||||
if (this._destroyed) return false;
|
* a request that is later rejected must not cancel an in-flight LOAD.
|
||||||
|
*/
|
||||||
|
_bumpSceneSeekGeneration() {
|
||||||
|
if (this._seekAbort) {
|
||||||
|
this._seekAbort.abort();
|
||||||
|
if (this._loadAbort === this._seekAbort) this._loadAbort = null;
|
||||||
|
this._seekAbort = null;
|
||||||
|
}
|
||||||
|
return ++this._sceneSeekGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a new time inside the already-loaded shot without rebuilding its stack.
|
||||||
|
* @returns {Promise<boolean|null>} Null when the shortcut does not apply and
|
||||||
|
* LOAD must rebuild the shot; otherwise whether the seek settled.
|
||||||
|
*/
|
||||||
|
async _seekLoadedShot(
|
||||||
|
scene,
|
||||||
|
seekState,
|
||||||
|
seekGeneration = this._sceneSeekGeneration,
|
||||||
|
) {
|
||||||
|
if (this._destroyed) return null;
|
||||||
const shot = seekState?.shot;
|
const shot = seekState?.shot;
|
||||||
if (
|
if (
|
||||||
!scene ||
|
!scene ||
|
||||||
@@ -1528,17 +1551,77 @@ export class SceneDirector {
|
|||||||
this._loadedSceneId !== scene.id ||
|
this._loadedSceneId !== scene.id ||
|
||||||
this._selectedShotId !== shot.id
|
this._selectedShotId !== shot.id
|
||||||
)
|
)
|
||||||
|
return null;
|
||||||
|
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
|
||||||
return false;
|
return false;
|
||||||
if (!this._claimCameraOwnership()) return false;
|
if (!this._claimCameraOwnership()) return false;
|
||||||
this._cancelActiveSceneTravel();
|
this._cancelActiveSceneTravel();
|
||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
this._loadAbort = null;
|
this._loadAbort = null;
|
||||||
this._loadGeneration += 1;
|
const generation = ++this._loadGeneration;
|
||||||
this._clock.stopShot();
|
this._clock.stopShot();
|
||||||
const states = this._layerStatesForShot(scene, shot, {
|
const states = this._layerStatesForShot(scene, shot, {
|
||||||
cameraSettled: seekState.cameraProgress >= 1,
|
cameraSettled: seekState.cameraProgress >= 1,
|
||||||
sceneSeek: seekState,
|
sceneSeek: seekState,
|
||||||
});
|
});
|
||||||
|
// A manual toggle, or a superseded LOAD transition whose rollback is still
|
||||||
|
// settling, leaves declared layers off the shot's stack. Reconcile only
|
||||||
|
// those through the cancellable admission path.
|
||||||
|
const staleStates = Object.fromEntries(
|
||||||
|
Object.entries(states)
|
||||||
|
.filter(([id, state]) => {
|
||||||
|
const wantEnabled = !!state?.enabled;
|
||||||
|
const lifecycle = this.dataManager.getLayerLifecycleState(id);
|
||||||
|
return (
|
||||||
|
lifecycle &&
|
||||||
|
(lifecycle.enabled !== wantEnabled ||
|
||||||
|
lifecycle.lifecycleState === 'enabling' ||
|
||||||
|
lifecycle.lifecycleState === 'disabling' ||
|
||||||
|
lifecycle.uncertain ||
|
||||||
|
this.dataManager.isEffectivelyEnabled(id) !== wantEnabled)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(([id, state]) => [id, { enabled: !!state?.enabled }]),
|
||||||
|
);
|
||||||
|
if (Object.keys(staleStates).length) {
|
||||||
|
const previousEnabled = new Map(
|
||||||
|
Object.keys(staleStates).map((id) => [
|
||||||
|
id,
|
||||||
|
this.dataManager.isEffectivelyEnabled(id),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
this._loadAbort = controller;
|
||||||
|
this._seekAbort = controller;
|
||||||
|
const layerResult = await this._applyLayerStates(
|
||||||
|
staleStates,
|
||||||
|
this._loadToken(generation, controller.signal, seekGeneration),
|
||||||
|
{ stopOnRefusal: true, exitContextMode: false },
|
||||||
|
);
|
||||||
|
if (this._loadAbort === controller) this._loadAbort = null;
|
||||||
|
if (this._seekAbort === controller) this._seekAbort = null;
|
||||||
|
const superseded = () =>
|
||||||
|
this._destroyed ||
|
||||||
|
this._loadAbort ||
|
||||||
|
this._running ||
|
||||||
|
seekGeneration !== this._sceneSeekGeneration;
|
||||||
|
if (layerResult.cancelled || superseded()) return false;
|
||||||
|
if (layerResult.refused.length) {
|
||||||
|
// Only a refusal rolls back. A cancelled reconcile leaves layer state
|
||||||
|
// to whatever superseded it, as a cancelled LOAD does.
|
||||||
|
for (const id of [...layerResult.applied].reverse()) {
|
||||||
|
if (superseded()) break;
|
||||||
|
await this.dataManager.setEnabled(
|
||||||
|
id,
|
||||||
|
previousEnabled.get(id) ?? false,
|
||||||
|
{ origin: 'scene' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
|
||||||
|
return false;
|
||||||
let applied = true;
|
let applied = true;
|
||||||
for (const [layerId, state] of Object.entries(states)) {
|
for (const [layerId, state] of Object.entries(states)) {
|
||||||
if (!state?.enabled || !state.params) continue;
|
if (!state?.enabled || !state.params) continue;
|
||||||
@@ -1550,7 +1633,9 @@ export class SceneDirector {
|
|||||||
applied = false;
|
applied = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!applied) return false;
|
if (!applied) return null;
|
||||||
|
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
|
||||||
|
return false;
|
||||||
this._setCameraView(
|
this._setCameraView(
|
||||||
seekState.camera || resolveCameraPose(scene, shot.camera),
|
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||||
);
|
);
|
||||||
@@ -1572,7 +1657,7 @@ export class SceneDirector {
|
|||||||
seekScene(sceneId, progress) {
|
seekScene(sceneId, progress) {
|
||||||
if (this._destroyed) return Promise.resolve(false);
|
if (this._destroyed) return Promise.resolve(false);
|
||||||
this.stopScene(msg('Seeking scene clock'));
|
this.stopScene(msg('Seeking scene clock'));
|
||||||
const generation = ++this._sceneSeekGeneration;
|
const generation = this._bumpSceneSeekGeneration();
|
||||||
return this._trackWork(this._seekScene(sceneId, progress, generation));
|
return this._trackWork(this._seekScene(sceneId, progress, generation));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,7 +1671,10 @@ export class SceneDirector {
|
|||||||
return false;
|
return false;
|
||||||
const seekState = this._sceneSeekState(scene, progress);
|
const seekState = this._sceneSeekState(scene, progress);
|
||||||
if (!seekState) return false;
|
if (!seekState) return false;
|
||||||
if (this._seekLoadedShot(scene, seekState)) return true;
|
const seeked = await this._seekLoadedShot(scene, seekState, generation);
|
||||||
|
if (this._destroyed || generation !== this._sceneSeekGeneration)
|
||||||
|
return false;
|
||||||
|
if (seeked !== null) return seeked;
|
||||||
const result = await this._loadShot(scene.id, seekState.shot.id, {
|
const result = await this._loadShot(scene.id, seekState.shot.id, {
|
||||||
sceneSeek: seekState,
|
sceneSeek: seekState,
|
||||||
});
|
});
|
||||||
@@ -1606,15 +1694,17 @@ export class SceneDirector {
|
|||||||
* @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls
|
* @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls
|
||||||
* @returns {{ cancelled: boolean, signal: AbortSignal|undefined }}
|
* @returns {{ cancelled: boolean, signal: AbortSignal|undefined }}
|
||||||
*/
|
*/
|
||||||
_loadToken(generation, signal = undefined) {
|
_loadToken(generation, signal = undefined, seekGeneration = null) {
|
||||||
const director = this;
|
const director = this;
|
||||||
return {
|
return {
|
||||||
signal,
|
signal,
|
||||||
get cancelled() {
|
get cancelled() {
|
||||||
return (
|
return (
|
||||||
director._destroyed ||
|
director._destroyed ||
|
||||||
signal?.aborted ||
|
Boolean(signal?.aborted) ||
|
||||||
director._loadGeneration !== generation
|
director._loadGeneration !== generation ||
|
||||||
|
(seekGeneration !== null &&
|
||||||
|
director._sceneSeekGeneration !== seekGeneration)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1753,7 +1843,7 @@ export class SceneDirector {
|
|||||||
startScene(sceneId, options) {
|
startScene(sceneId, options) {
|
||||||
if (this._destroyed)
|
if (this._destroyed)
|
||||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||||
this._sceneSeekGeneration++;
|
this._bumpSceneSeekGeneration();
|
||||||
return this._trackWork(this._startScene(sceneId, options));
|
return this._trackWork(this._startScene(sceneId, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1919,7 +2009,7 @@ export class SceneDirector {
|
|||||||
this._setSceneMediaPlayback();
|
this._setSceneMediaPlayback();
|
||||||
this._interactions?.clear();
|
this._interactions?.clear();
|
||||||
this._dataPacks?.clear();
|
this._dataPacks?.clear();
|
||||||
this._sceneSeekGeneration++;
|
this._bumpSceneSeekGeneration();
|
||||||
this._loadAbort?.abort();
|
this._loadAbort?.abort();
|
||||||
this._loadAbort = null;
|
this._loadAbort = null;
|
||||||
this._loadGeneration++;
|
this._loadGeneration++;
|
||||||
@@ -2165,7 +2255,11 @@ export class SceneDirector {
|
|||||||
* the transition in flight.
|
* the transition in flight.
|
||||||
* @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>}
|
* @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>}
|
||||||
*/
|
*/
|
||||||
async _applyLayerStates(targetStates, token = null) {
|
async _applyLayerStates(
|
||||||
|
targetStates,
|
||||||
|
token = null,
|
||||||
|
{ stopOnRefusal = false, exitContextMode = true } = {},
|
||||||
|
) {
|
||||||
const applied = [];
|
const applied = [];
|
||||||
const refused = [];
|
const refused = [];
|
||||||
const abort = () => ({ applied, refused, cancelled: true });
|
const abort = () => ({ applied, refused, cancelled: true });
|
||||||
@@ -2175,8 +2269,10 @@ export class SceneDirector {
|
|||||||
// the operator's pre-mode state, which is exactly where a stopped scene
|
// the operator's pre-mode state, which is exactly where a stopped scene
|
||||||
// should come to rest. Tearing that transaction in half would strand
|
// should come to rest. Tearing that transaction in half would strand
|
||||||
// Context, so it completes and cancellation is honoured immediately after.
|
// Context, so it completes and cancellation is honoured immediately after.
|
||||||
await this._exitIsolatingContextMode();
|
if (exitContextMode) {
|
||||||
if (token?.cancelled) return abort();
|
await this._exitIsolatingContextMode();
|
||||||
|
if (token?.cancelled) return abort();
|
||||||
|
}
|
||||||
|
|
||||||
const signal = token?.signal;
|
const signal = token?.signal;
|
||||||
const registered = new Set(
|
const registered = new Set(
|
||||||
@@ -2206,6 +2302,7 @@ export class SceneDirector {
|
|||||||
console.warn(
|
console.warn(
|
||||||
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
|
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
|
||||||
);
|
);
|
||||||
|
if (stopOnRefusal) break;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (params && typeof this.dataManager.restoreLayerState !== 'function') {
|
if (params && typeof this.dataManager.restoreLayerState !== 'function') {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||||
import { SceneDirector } from './director.js';
|
import { SceneDirector } from './director.js';
|
||||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||||
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
||||||
@@ -174,6 +175,348 @@ test('scene clock seek resolves the exact shot phase and camera in both directio
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking restores declared layer visibility after manual changes', async () => {
|
||||||
|
const { director, dataManager, styleManager, restore } = makeDirector();
|
||||||
|
try {
|
||||||
|
const shot = director._project.scenes[0].shots[0];
|
||||||
|
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
await dataManager.setEnabled('flights', false);
|
||||||
|
await dataManager.setEnabled('traffic', true);
|
||||||
|
await dataManager.setEnabled('satellites', true);
|
||||||
|
dataManager.setEnabledCalls.length = 0;
|
||||||
|
const visualCalls = styleManager.visualCalls.length;
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||||
|
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
||||||
|
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
|
||||||
|
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
|
||||||
|
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
|
||||||
|
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
|
||||||
|
assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers');
|
||||||
|
assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites'));
|
||||||
|
} finally {
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
|
||||||
|
let refuse = false;
|
||||||
|
const { director, dataManager, styleManager, restore } = makeDirector({
|
||||||
|
data: { refuse: () => refuse },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||||
|
await dataManager.setEnabled('flights', false);
|
||||||
|
refuse = true;
|
||||||
|
const visualCalls = styleManager.visualCalls.length;
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||||
|
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
|
||||||
|
const timing = director.getPlaybackTimingState();
|
||||||
|
assert.equal(timing.activeTimers, 0);
|
||||||
|
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
|
||||||
|
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
||||||
|
} finally {
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
|
||||||
|
const { director, dataManager, restore } = makeDirector();
|
||||||
|
try {
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
const disabling = dataManager.setEnabled('flights', false);
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||||
|
await disabling;
|
||||||
|
await settle();
|
||||||
|
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
|
||||||
|
} finally {
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
|
||||||
|
const { director, dataManager, styleManager, viewer, restore } = makeDirector();
|
||||||
|
const lifecycle = new LayerLifecycle(viewer);
|
||||||
|
for (const id of ['flights', 'traffic', 'satellites']) {
|
||||||
|
lifecycle.register({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
update() {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const realDirector = new SceneDirector(viewer, styleManager, lifecycle);
|
||||||
|
try {
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
await dataManager.setEnabled('flights', false);
|
||||||
|
dataManager.setEnabledCalls.length = 0;
|
||||||
|
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
|
||||||
|
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||||
|
await reload;
|
||||||
|
await settle();
|
||||||
|
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
|
||||||
|
|
||||||
|
await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
await lifecycle.setEnabled('flights', false);
|
||||||
|
const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), {
|
||||||
|
enabled: false,
|
||||||
|
lifecycleState: 'disabled',
|
||||||
|
uncertain: false,
|
||||||
|
});
|
||||||
|
assert.equal(await realDirector.seekScene('scene-1', 0.25), true);
|
||||||
|
await queuedReload;
|
||||||
|
await settle();
|
||||||
|
assert.equal(lifecycle.isEnabled('flights'), true);
|
||||||
|
|
||||||
|
lifecycle.layers.get('flights').lifecycleUncertain = true;
|
||||||
|
assert.equal(await realDirector.seekScene('scene-1', 0.3), true);
|
||||||
|
assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false);
|
||||||
|
|
||||||
|
let navClaims = 0;
|
||||||
|
styleManager.runImmediateNavigation = () => {
|
||||||
|
navClaims += 1;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
assert.equal(await realDirector.seekScene('scene-1', 0.4), false);
|
||||||
|
assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD');
|
||||||
|
} finally {
|
||||||
|
await realDirector.destroy();
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => {
|
||||||
|
let refuse = () => false;
|
||||||
|
const { director, dataManager, styleManager, viewer, restore } = makeDirector({
|
||||||
|
data: { refuse: (id, on) => refuse(id, on) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const shot = director._project.scenes[0].shots[0];
|
||||||
|
shot.layers = {
|
||||||
|
flights: { enabled: true },
|
||||||
|
traffic: { enabled: true },
|
||||||
|
satellites: { enabled: true },
|
||||||
|
};
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||||
|
await dataManager.setEnabled('flights', false);
|
||||||
|
await dataManager.setEnabled('traffic', false);
|
||||||
|
await dataManager.setEnabled('satellites', false);
|
||||||
|
styleManager.contextMode = 'space-missions';
|
||||||
|
styleManager.contextExits.length = 0;
|
||||||
|
refuse = (id) => id === 'traffic';
|
||||||
|
dataManager.setEnabledCalls.length = 0;
|
||||||
|
const viewsBefore = viewer.views.length;
|
||||||
|
const visualCalls = styleManager.visualCalls.length;
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||||
|
assert.equal(
|
||||||
|
dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'),
|
||||||
|
false,
|
||||||
|
'stop the plan before later ids are written',
|
||||||
|
);
|
||||||
|
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
|
||||||
|
assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled');
|
||||||
|
assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled');
|
||||||
|
assert.equal(enabled.get('satellites'), false, 'later stale layer is never written');
|
||||||
|
assert.equal(styleManager.contextMode, 'space-missions');
|
||||||
|
assert.deepEqual(styleManager.contextExits, []);
|
||||||
|
assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose');
|
||||||
|
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
|
||||||
|
'clock stays at the last settled seek position',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => {
|
||||||
|
const { styleManager, viewer, restore } = makeDirector();
|
||||||
|
let holdEnable = false;
|
||||||
|
let releaseEnable;
|
||||||
|
const enableGate = new Promise((resolve) => {
|
||||||
|
releaseEnable = resolve;
|
||||||
|
});
|
||||||
|
const lifecycle = new LayerLifecycle(viewer);
|
||||||
|
lifecycle.register({
|
||||||
|
id: 'flights',
|
||||||
|
name: 'flights',
|
||||||
|
init() {},
|
||||||
|
async enable() {
|
||||||
|
if (holdEnable) await enableGate;
|
||||||
|
},
|
||||||
|
disable() {},
|
||||||
|
update() {},
|
||||||
|
});
|
||||||
|
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
||||||
|
try {
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||||
|
await lifecycle.setEnabled('flights', false);
|
||||||
|
holdEnable = true;
|
||||||
|
const viewsBefore = viewer.views.length;
|
||||||
|
|
||||||
|
const seek = director.seekScene('scene-1', 0.25);
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||||
|
const admissionSignal = director._loadAbort?.signal;
|
||||||
|
assert.ok(admissionSignal, 'same-shot seek installs an admission controller');
|
||||||
|
|
||||||
|
const missingLoad = director.loadShot('missing', 'nope');
|
||||||
|
assert.equal(
|
||||||
|
admissionSignal.aborted,
|
||||||
|
true,
|
||||||
|
'missing load aborts the admission controller before returning',
|
||||||
|
);
|
||||||
|
assert.equal((await missingLoad).reason, 'shot-not-found');
|
||||||
|
assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort');
|
||||||
|
|
||||||
|
releaseEnable();
|
||||||
|
assert.equal(await seek, false);
|
||||||
|
await settle();
|
||||||
|
assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored');
|
||||||
|
assert.equal(viewer.views.length, viewsBefore, 'setView is not called');
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
|
||||||
|
'sceneProgress stays at the last settled value',
|
||||||
|
);
|
||||||
|
|
||||||
|
let loadFallbackCalls = 0;
|
||||||
|
const origLoadShot = director._loadShot.bind(director);
|
||||||
|
director._loadShot = (...args) => {
|
||||||
|
loadFallbackCalls += 1;
|
||||||
|
return origLoadShot(...args);
|
||||||
|
};
|
||||||
|
const origSeekLoadedShot = director._seekLoadedShot.bind(director);
|
||||||
|
director._seekLoadedShot = async () => {
|
||||||
|
await director.startScene('missing');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||||
|
assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot');
|
||||||
|
director._seekLoadedShot = origSeekLoadedShot;
|
||||||
|
} finally {
|
||||||
|
releaseEnable?.();
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
|
||||||
|
const { styleManager, viewer, restore } = makeDirector();
|
||||||
|
let holdEnable = true;
|
||||||
|
let releaseEnable;
|
||||||
|
const enableGate = new Promise((resolve) => {
|
||||||
|
releaseEnable = resolve;
|
||||||
|
});
|
||||||
|
const lifecycle = new LayerLifecycle(viewer);
|
||||||
|
lifecycle.register({
|
||||||
|
id: 'flights',
|
||||||
|
name: 'flights',
|
||||||
|
init() {},
|
||||||
|
async enable() {
|
||||||
|
if (holdEnable) await enableGate;
|
||||||
|
},
|
||||||
|
disable() {},
|
||||||
|
update() {},
|
||||||
|
});
|
||||||
|
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
||||||
|
try {
|
||||||
|
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||||
|
|
||||||
|
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
|
||||||
|
holdEnable = false;
|
||||||
|
releaseEnable();
|
||||||
|
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
|
||||||
|
assert.equal(lifecycle.isEnabled('flights'), true);
|
||||||
|
} finally {
|
||||||
|
releaseEnable?.();
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
|
||||||
|
let refuse = () => false;
|
||||||
|
const { director, dataManager, restore } = makeDirector({
|
||||||
|
data: { refuse: (id, on) => refuse(id, on) },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const shot = director._project.scenes[0].shots[0];
|
||||||
|
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||||
|
await dataManager.setEnabled('flights', false);
|
||||||
|
await dataManager.setEnabled('traffic', false);
|
||||||
|
refuse = (id) => id === 'traffic';
|
||||||
|
const manualEnable = dataManager.setEnabled('flights', true);
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||||
|
await manualEnable;
|
||||||
|
await settle();
|
||||||
|
assert.equal(
|
||||||
|
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
|
||||||
|
true,
|
||||||
|
'rollback restores the operator\'s intended state, not the stale committed one',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
|
||||||
|
const { director, dataManager, styleManager, restore } = makeDirector();
|
||||||
|
try {
|
||||||
|
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||||
|
await dataManager.setEnabled('satellites', true);
|
||||||
|
dataManager.setEnabledCalls.length = 0;
|
||||||
|
const visualCalls = styleManager.visualCalls.length;
|
||||||
|
|
||||||
|
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||||
|
assert.deepEqual(dataManager.setEnabledCalls, []);
|
||||||
|
assert.equal(styleManager.visualCalls.length, visualCalls);
|
||||||
|
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
|
||||||
|
} finally {
|
||||||
|
await director.destroy();
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
|
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
|
||||||
const { director, restore } = makeDirector();
|
const { director, restore } = makeDirector();
|
||||||
const snapshots = [];
|
const snapshots = [];
|
||||||
@@ -647,6 +990,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
|
|||||||
*/
|
*/
|
||||||
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
|
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
|
||||||
const enabled = new Map();
|
const enabled = new Map();
|
||||||
|
const pending = new Map();
|
||||||
const setEnabledCalls = [];
|
const setEnabledCalls = [];
|
||||||
const setParamsCalls = [];
|
const setParamsCalls = [];
|
||||||
const committed = [];
|
const committed = [];
|
||||||
@@ -659,12 +1003,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
|
|||||||
setParamsCalls,
|
setParamsCalls,
|
||||||
committed,
|
committed,
|
||||||
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
||||||
|
getLayerLifecycleState: (id) => {
|
||||||
|
if (!registered.includes(id)) return null;
|
||||||
|
const on = !!enabled.get(id);
|
||||||
|
const target = pending.get(id);
|
||||||
|
return {
|
||||||
|
enabled: on,
|
||||||
|
lifecycleState:
|
||||||
|
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
|
||||||
getLayerParams: () => null,
|
getLayerParams: () => null,
|
||||||
async setEnabled(id, shouldEnable, { signal } = {}) {
|
async setEnabled(id, shouldEnable, { signal } = {}) {
|
||||||
setEnabledCalls.push({ id, enabled: shouldEnable, signal });
|
setEnabledCalls.push({ id, enabled: shouldEnable, signal });
|
||||||
if (refuse(id, shouldEnable)) return false;
|
if (refuse(id, shouldEnable)) return false;
|
||||||
// Yield once so a stop landing during the transition is observable.
|
// Yield once so a stop landing during the transition is observable.
|
||||||
|
pending.set(id, shouldEnable);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
if (pending.get(id) === shouldEnable) pending.delete(id);
|
||||||
if (signal?.aborted) return false;
|
if (signal?.aborted) return false;
|
||||||
enabled.set(id, shouldEnable);
|
enabled.set(id, shouldEnable);
|
||||||
committed.push({ id, enabled: shouldEnable });
|
committed.push({ id, enabled: shouldEnable });
|
||||||
@@ -707,15 +1064,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) {
|
|||||||
/** Cesium viewer double whose flights complete on the next microtask turn. */
|
/** Cesium viewer double whose flights complete on the next microtask turn. */
|
||||||
function fakeViewer() {
|
function fakeViewer() {
|
||||||
const flights = [];
|
const flights = [];
|
||||||
|
const views = [];
|
||||||
let cancelled = 0;
|
let cancelled = 0;
|
||||||
return {
|
return {
|
||||||
flights,
|
flights,
|
||||||
|
views,
|
||||||
get cancelledFlights() { return cancelled; },
|
get cancelledFlights() { return cancelled; },
|
||||||
camera: {
|
camera: {
|
||||||
flyTo(options) {
|
flyTo(options) {
|
||||||
flights.push(options);
|
flights.push(options);
|
||||||
Promise.resolve().then(() => options.complete?.());
|
Promise.resolve().then(() => options.complete?.());
|
||||||
},
|
},
|
||||||
|
setView(options) {
|
||||||
|
views.push(options);
|
||||||
|
},
|
||||||
cancelFlight() { cancelled++; },
|
cancelFlight() { cancelled++; },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
@@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({
|
|||||||
requires: ['perimeters'],
|
requires: ['perimeters'],
|
||||||
async run(args, { services, signal }) {
|
async run(args, { services, signal }) {
|
||||||
const area = await resolveArea(args.area, { services, signal });
|
const area = await resolveArea(args.area, { services, signal });
|
||||||
const rows = (await services.perimeters.getSnapshot({ signal }))
|
const snapshot =
|
||||||
|
typeof services.perimeters.getSnapshotDetail === 'function'
|
||||||
|
? await services.perimeters.getSnapshotDetail({ signal })
|
||||||
|
: { rows: await services.perimeters.getSnapshot({ signal }) };
|
||||||
|
// The proxy answers from its last snapshot, marked stale, while WFIGS is
|
||||||
|
// unreachable.
|
||||||
|
const stale = snapshot.stale === true;
|
||||||
|
const rows = snapshot.rows
|
||||||
.filter((fire) => polygonsTouchArea(fire.polygons, area))
|
.filter((fire) => polygonsTouchArea(fire.polygons, area))
|
||||||
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||||
.filter(({ point }) => point)
|
.filter(({ point }) => point)
|
||||||
@@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
||||||
return {
|
return {
|
||||||
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
|
summary:
|
||||||
|
`${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` +
|
||||||
|
(stale ? ' (data may be stale).' : '.'),
|
||||||
data: {
|
data: {
|
||||||
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
|
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
|
||||||
|
stale,
|
||||||
|
retrieved_at: isoTime(snapshot.fetchedAt),
|
||||||
...capRows(rows, args.limit),
|
...capRows(rows, args.limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => {
|
|||||||
assert.equal(slow.data.names_pending, true);
|
assert.equal(slow.data.names_pending, true);
|
||||||
assert.match(slow.summary, /site names are still loading\)\.$/);
|
assert.match(slow.summary, /site names are still loading\)\.$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stale fire perimeters say so, with when they were fetched', async () => {
|
||||||
|
const fetchedAt = Date.UTC(2026, 9, 22, 6);
|
||||||
|
const ring = [
|
||||||
|
[
|
||||||
|
[-118.5, 35.2],
|
||||||
|
[-118.4, 35.2],
|
||||||
|
[-118.4, 35.3],
|
||||||
|
[-118.5, 35.2],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const perimeters = {
|
||||||
|
getSnapshot: async () => [],
|
||||||
|
getSnapshotDetail: async () => ({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
stableId: 'held',
|
||||||
|
name: 'Held Fire',
|
||||||
|
acres: 300,
|
||||||
|
polygons: [ring],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stale: true,
|
||||||
|
fetchedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||||
|
area: { bbox: [-120, 34, -117, 36] },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'1 mapped wildfire perimeter in the requested box (data may be stale).',
|
||||||
|
);
|
||||||
|
assert.equal(result.data.stale, true);
|
||||||
|
assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString());
|
||||||
|
});
|
||||||
+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