From 4d2a71c1b279307747928ab53c7d36a3909e30a1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 01/24] fix(layers): report Directions errors as English data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Directions row errors (pointer blocked, no route, routing timed out, no response from the routing service) and the route request failures ("Routing failed: {error}", "{error} — try again in a moment", "Routing unavailable (HTTP {status})", the rate-limit and fallback texts) were translated inside getStats(), so the layer snapshot that voice tools and the analyst read carried Spanish or Russian text. Fixed texts are marked with msg() and the interpolated ones are built in English. The Layers panel translates them where it shows them, through a ROUTING_ERROR_PATTERNS block in statusText.js that the proxy's refusal texts move into. English reads as before. In other locales two things differ slightly: a proxy text inside "Routing failed: X" that is not one of the known refusals is now looked up in the catalog instead of shown as given, and the "— try again in a moment" shape is recognised on every layer's error, not only on Directions. --- src/layers/directions/index.js | 51 +++------ src/layers/directions/statusLocale.test.mjs | 121 ++++++++++++++++++++ src/ui/statusText.js | 34 +++++- src/ui/untranslatedErrors.test.mjs | 32 ++++-- 4 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 src/layers/directions/statusLocale.test.mjs diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..70f49bfb9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({ export const DEFAULT_DIRECTIONS_MODE = 'car'; /** - * The route proxy's refusal texts (server/providers/places/routes.js). They - * stay English on the wire and in the AI tools; the row shows them translated. + * The row error for a failed route request (createDirectionsSource errors), + * as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS + * in src/ui/statusText.js). */ -const ROUTING_REFUSALS = new Set([ - msg('rate limited'), - msg('routing service is rate limited'), - msg('route leg too long'), - msg('route too long'), -]); - -/** A refusal text from the route proxy in the language; unknown ones as given. */ -export function presentRoutingRefusal(text) { - return ROUTING_REFUSALS.has(text) ? t(text) : text; -} - -/** The row text for a failed route request (createDirectionsSource errors). */ export function presentRoutingFailure(error) { if (error?.status === 429) { return error.refusal - ? t('{error} — try again in a moment', { - error: presentRoutingRefusal(error.refusal), - }) - : t('Routing is rate limited — try again in a moment'); + ? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + `${error.refusal} — try again in a moment` + : msg('Routing is rate limited — try again in a moment'); } if (Number.isFinite(error?.status) && error.status > 0) - return t('Routing unavailable (HTTP {status})', { status: error.status }); - return error?.message || t('Routing unavailable'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + return `Routing unavailable (HTTP ${error.status})`; + return error?.message || msg('Routing unavailable'); } /** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ @@ -643,7 +631,9 @@ export function directionsStats(state) { return { count: route?.steps.length || 0, lastUpdate, - error: t('Another map tool is using clicks — close it, then SET A again'), + error: msg( + 'Another map tool is using clicks — close it, then SET A again', + ), status: 'empty', source, }; @@ -662,7 +652,7 @@ export function directionsStats(state) { return { count: 0, lastUpdate, - error: error || t('No route found'), + error: error || msg('No route found'), status: 'empty', source, }; @@ -1144,13 +1134,10 @@ export function createDirectionsLayer({ services, source }) { clearRouteGraphics(); _status = 'error'; _error = - payload?.error === 'no route found' - ? t('No route found between A and B') - : payload?.error - ? t('Routing failed: {error}', { - error: presentRoutingRefusal(payload.error), - }) - : t('No route found between A and B'); + // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS + payload?.error && payload.error !== 'no route found' + ? `Routing failed: ${payload.error}` + : msg('No route found between A and B'); return; } _route = route; @@ -1167,9 +1154,9 @@ export function createDirectionsLayer({ services, source }) { // happened instead. _error = error?.name === 'AbortError' - ? t('Routing timed out') + ? msg('Routing timed out') : error?.name === 'TypeError' - ? t('Routing unavailable — no response from the routing service') + ? msg('Routing unavailable — no response from the routing service') : presentRoutingFailure(error); } finally { clearTimeout(timer); diff --git a/src/layers/directions/statusLocale.test.mjs b/src/layers/directions/statusLocale.test.mjs new file mode 100644 index 000000000..d76605d88 --- /dev/null +++ b/src/layers/directions/statusLocale.test.mjs @@ -0,0 +1,121 @@ +// Directions reports its fixed errors as English data (getStats(), and so the +// layer snapshot the voice tools and the analyst read); the Layers panel row +// shows them in the UI language. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale, t as translate } from '../../i18n/index.js'; +import { LayerPanel } from '../../ui/layerPanel.js'; +import { directionsStats, presentRoutingFailure } from './index.js'; + +const idle = { + enabled: true, + mode: 'car', + armed: null, + a: null, + b: null, + status: 'idle', + error: null, + route: null, + lastUpdate: null, +}; + +const cases = [ + [ + { ...idle, pointerBlocked: true }, + 'Another map tool is using clicks — close it, then SET A again', + ], + [{ ...idle, status: 'error' }, 'No route found'], + [ + { ...idle, status: 'error', error: 'Routing timed out' }, + 'Routing timed out', + ], +]; + +function rowFor(stats) { + return LayerPanel.prototype._buildMetaText({ + id: 'directions', + enabled: true, + stats, + }); +} + +// Route request failures with the proxy's refusal or an HTTP status in them: +// [the request error, its English row error, the row text in the language] +const failures = () => [ + [ + { status: 429, refusal: 'route leg too long' }, + 'route leg too long — try again in a moment', + translate('{error} — try again in a moment', { + error: translate('route leg too long'), + }), + ], + [ + { status: 429, refusal: null }, + 'Routing is rate limited — try again in a moment', + translate('Routing is rate limited — try again in a moment'), + ], + [ + { status: 503 }, + 'Routing unavailable (HTTP 503)', + translate('Routing unavailable (HTTP {status})', { status: 503 }), + ], + [ + { status: 0, message: '' }, + 'Routing unavailable', + translate('Routing unavailable'), + ], +]; + +for (const locale of ['es', 'ru']) { + test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [state, english] of cases) { + const stats = directionsStats(state); + assert.equal(stats.error, english); + const row = rowFor(stats); + assert.ok(row.includes(translate(english)), `${locale}: ${row}`); + assert.ok(!row.includes(english), `${locale}: ${row}`); + } + }); +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + for (const [failure, english, shown] of failures()) { + const error = presentRoutingFailure(failure); + assert.equal(error, english); + const stats = directionsStats({ ...idle, status: 'error', error }); + assert.equal(stats.error, english); + assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`); + assert.notEqual(shown, english); + } + // The proxy's refusal after "Routing failed:", and a text it never sends. + for (const [refusal, shownRefusal] of [ + ['route too long', translate('route too long')], + ['something else', 'something else'], + ]) { + const row = rowFor( + directionsStats({ + ...idle, + status: 'error', + error: `Routing failed: ${refusal}`, + }), + ); + assert.ok( + row.includes( + translate('Routing failed: {error}', { error: shownRefusal }), + ), + `${locale}: ${row}`, + ); + } + }); +} + +test('en: the Directions row shows the English error', () => { + setLocale('en'); + const row = rowFor(directionsStats({ ...idle, status: 'error' })); + assert.match(row, /No route found/); +}); diff --git a/src/ui/statusText.js b/src/ui/statusText.js index b0899d55e..dcd614bca 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -112,12 +112,44 @@ const FEED_ERROR_PATTERNS = [ ], ]; +// Directions route failures (layers/directions/index.js), English data with +// the route proxy's refusal or an HTTP status in them. Ahead of the feed +// patterns, which would take "Routing failed: rate limited" for a source. +// The route proxy's refusal texts (server/providers/places/routes.js) show +// translated; any other text in {error} shows its catalog entry, or as given. +const ROUTING_REFUSALS = new Set([ + msg('rate limited'), + msg('routing service is rate limited'), + msg('route leg too long'), + msg('route too long'), +]); +const routingRefusal = (text) => + ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); +const ROUTING_ERROR_PATTERNS = [ + [ + /^Routing unavailable \(HTTP (\d+)\)$/, + ([, status]) => t('Routing unavailable (HTTP {status})', { status }), + ], + [ + /^Routing failed: (.+)$/, + ([, error]) => + t('Routing failed: {error}', { error: routingRefusal(error) }), + ], + [ + /^(.+) — try again in a moment$/, + ([, error]) => + t('{error} — try again in a moment', { error: routingRefusal(error) }), + ], +]; + +const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS]; + /** One error text in the UI language, or null when nothing matches. */ function feedErrorText(text) { if (FEED_ERRORS.has(text)) return t(text); const known = translateKnown(text); if (known !== text) return known; - for (const [pattern, format] of FEED_ERROR_PATTERNS) { + for (const [pattern, format] of ERROR_PATTERNS) { const match = pattern.exec(text); if (match) return format(match); } diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index 40a8e2848..d8d05d6f0 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js'; import { presentStreetLevelPanel } from './streetLevelPresentation.js'; import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; import { createWeatherSource } from '../layers/weather/source.js'; -import { - presentRoutingFailure, - presentRoutingRefusal, -} from '../layers/directions/index.js'; +import { presentRoutingFailure } from '../layers/directions/index.js'; +import { translateFeedError } from './statusText.js'; import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js'; const NO_IMAGERY = 'No street-level imagery within 50 m of that point'; @@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) { test(`${locale}: the route proxy's refusals are shown translated`, () => { setLocale(locale); try { - const shown = t('Routing failed: {error}', { - error: presentRoutingRefusal('route leg too long'), - }); + // Directions stores these in English; the Layers panel shows them + // through translateFeedError. + const shown = translateFeedError('Routing failed: route leg too long'); + assert.equal( + shown, + t('Routing failed: {error}', { error: t('route leg too long') }), + ); assert.doesNotMatch(shown, /route leg too long/); const limited = presentRoutingFailure( Object.assign(new Error('rate limited — try again in a moment'), { @@ -63,16 +65,24 @@ for (const locale of ['es', 'ru']) { refusal: 'rate limited', }), ); - assert.doesNotMatch(limited, /rate limited|try again in a moment/); + assert.equal(limited, 'rate limited — try again in a moment'); + assert.doesNotMatch( + translateFeedError(limited), + /rate limited|try again in a moment/, + ); const http = presentRoutingFailure( Object.assign(new Error('Routing unavailable (HTTP 503)'), { status: 503, }), ); - assert.notEqual(http, 'Routing unavailable (HTTP 503)'); - assert.match(http, /503/); + assert.equal(http, 'Routing unavailable (HTTP 503)'); + assert.notEqual(translateFeedError(http), http); + assert.match(translateFeedError(http), /503/); // A text the proxy never sends is shown as given. - assert.equal(presentRoutingRefusal('something else'), 'something else'); + assert.equal( + translateFeedError('Routing failed: something else'), + t('Routing failed: {error}', { error: 'something else' }), + ); } finally { setLocale('en'); } From 3d64a54a219942404123e6d16456187a5a9182fe Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 02/24] fix(layers): keep the Local ADS-B receiver error in English The Local ADS-B layer copied the SDR controller's `message`, which is Radio card text in the UI language, into its `error` stat. The controller now publishes an English `errorText` next to the translated `message` for every receiver error, and the layer reports that. A receiver is any object with getState(); one that does not publish errorText still shows its error from `message`, as before. The Radio card text is unchanged, and the Layers panel still shows the error translated. --- src/layers/localAdsb/index.js | 8 +-- src/layers/localAdsb/status.js | 2 +- src/layers/localAdsb/statusLocale.test.mjs | 41 +++++++++++++- src/sdr/controller.js | 62 ++++++++++------------ 4 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..1028450b5 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -66,10 +66,12 @@ const DEFAULT_DISPLAY = Object.freeze({ * Local ADS-B layer: draws aircraft heard by the user's own receivers. * * Two inputs feed it. The receiver is any object with `getState()` returning - * `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`, + * `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`, * `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from - * `src/sources/adsbRecords.js`. The browser WebUSB session is one such - * receiver. The optional `feeds` session (`./feeds.js`) supplies records read + * `src/sources/adsbRecords.js`, and the optional `errorText` is the English + * text of an `error` status (`message`, the UI-language one, stands in for a + * receiver without it). The browser WebUSB session is one such receiver. + * The optional `feeds` session (`./feeds.js`) supplies records read * by the server from local 1090/978 MHz decoders; it polls only while this * layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`). * Aircraft heard only on 978 MHz UAT carry a thin ring around the marker. diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..7e19997b6 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -51,7 +51,7 @@ export function feedsConfigured(feedState) { function webUsbStatus(receiver, heard, feedState) { if (receiver.status === 'error') - return { status: 'error', error: receiver.message }; + return { status: 'error', error: receiver.errorText ?? receiver.message }; if (receiver.status === 'connecting' || receiver.status === 'tuning') return { loading: true, loadingLabel: msg('opening receiver') }; if ( diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index 8fe8245db..d6ac821a4 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -2,7 +2,8 @@ // "USB error", feed problems, even as the whole error) read in the UI language. import assert from 'node:assert/strict'; import test from 'node:test'; -import { setLocale } from '../../i18n/index.js'; +import { setLocale, t } from '../../i18n/index.js'; +import { SdrController } from '../../sdr/controller.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { localAdsbStatus } from './status.js'; @@ -66,3 +67,41 @@ for (const locale of ['es', 'ru']) { }); } } + +// A receiver error is English data for the layer (stats, and what the voice +// tools and the analyst read); the Radio card and the row show it translated. +for (const locale of ['es', 'ru']) { + test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => { + const receiver = new SdrController({ + storage: null, + webUsb: {}, + providerFactory: () => ({ + get: async () => { + throw new Error('No device was selected.'); + }, + }), + }); + setLocale(locale); + try { + assert.equal(await receiver.connect('adsb'), false); + const state = receiver.getState(); + assert.equal(state.status, 'error'); + assert.equal(state.errorText, 'No RTL-SDR device selected'); + assert.equal(state.message, t('No RTL-SDR device selected')); + assert.notEqual(state.message, state.errorText); + + const status = localAdsbStatus({ + receiver: state, + feedState: null, + heard: 0, + }); + assert.equal(status.error, 'No RTL-SDR device selected'); + const row = rowFor(status, 0); + assert.ok(row.includes(state.message), row); + assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + } finally { + setLocale('en'); + await receiver.destroy(); + } + }); +} diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..9a8923339 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -283,22 +283,32 @@ function clampFmFrequency(value) { ); } +/** A failed connection in English; errorState() shows it translated. */ function describeConnectionError(error) { const message = String(error?.message || error || '').trim(); if (/No device was selected|NotFoundError/i.test(message)) - return t('No RTL-SDR device selected'); + return msg('No RTL-SDR device selected'); if ( /does not support the WebUSB|usb/i.test(message) && /support/i.test(message) ) { - return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); + return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS'); } if (/access|permission|claim|busy/i.test(message)) { - return t( + return msg( 'RTL-SDR access failed; close other SDR apps and check USB permissions', ); } - return message ? translateKnown(message) : t('RTL-SDR connection failed'); + return message || msg('RTL-SDR connection failed'); +} + +/** + * State fields for a receiver error: `message` in the UI language for the + * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * @param {string} text English error text (ours or the browser's). + */ +function errorState(text) { + return { status: 'error', message: translateKnown(text), errorText: text }; } function streamingMessage(mode) { @@ -555,10 +565,7 @@ export class SdrController { // A failed worker (e.g. its module did not load) never recovers: drop // it so the next session builds and configures a fresh one. this._discardWorker(); - this._setState({ - status: 'error', - message: t('SDR decoder worker failed'), - }); + this._setState(errorState(msg('SDR decoder worker failed'))); if (this._device) void this.stop({ preserveMessage: true }); }; return worker; @@ -779,16 +786,16 @@ export class SdrController { // Published before stopping; stop() preserves it, and nothing is set // afterwards, so a reconnect started during teardown keeps its own state. this._setState({ - status: 'error', + ...errorState( + reason === 'startup' + ? msg('SDR decoder did not start; receiver stopped') + : reason === 'stalled' + ? msg('SDR decoder stopped responding; receiver stopped') + : msg('SDR decoder fell behind; receiver stopped'), + ), connected: false, decoderOverload: reason, decoderOverloadSnapshot: snapshot, - message: - reason === 'startup' - ? t('SDR decoder did not start; receiver stopped') - : reason === 'stalled' - ? t('SDR decoder stopped responding; receiver stopped') - : t('SDR decoder fell behind; receiver stopped'), }); void this.stop({ preserveMessage: true }); } @@ -1085,7 +1092,7 @@ export class SdrController { // consumes the click activation. if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; press Connect again to resume it'), + msg('Browser audio is suspended; press Connect again to resume it'), ); } if (superseded()) return false; @@ -1153,8 +1160,7 @@ export class SdrController { if (superseded()) return false; this._setState({ connected: false, - status: 'error', - message: describeConnectionError(error), + ...errorState(describeConnectionError(error)), }); return false; } @@ -1507,8 +1513,7 @@ export class SdrController { console.warn('[SDR] Sample read failed:', error); this._setState({ connected: false, - status: 'error', - message: t('RTL-SDR sample stream stopped'), + ...errorState(msg('RTL-SDR sample stream stopped')), }); await this.stop({ preserveMessage: true }); break; @@ -1553,7 +1558,7 @@ export class SdrController { try { if (nextMode === 'fm' && !(await this._ensureAudio())) { throw new Error( - t('Browser audio is suspended; select FM again to resume it'), + msg('Browser audio is suspended; select FM again to resume it'), ); } // A stall teardown, stop() or reconnect during the switch supersedes @@ -1578,10 +1583,7 @@ export class SdrController { } catch (error) { if (!this._owns(token, device)) return false; console.warn('[SDR] Mode switch failed:', error); - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } @@ -1622,10 +1624,7 @@ export class SdrController { if (targetMode === 'adsb') this._setState(this._resetAdsbCounters()); } catch (error) { console.warn('[SDR] Gain change failed:', error); - this._setState({ - status: 'error', - message: t('RTL-SDR gain change failed'), - }); + this._setState(errorState(msg('RTL-SDR gain change failed'))); return null; } return setting; @@ -1679,10 +1678,7 @@ export class SdrController { }); return true; } catch (error) { - this._setState({ - status: 'error', - message: describeConnectionError(error), - }); + this._setState(errorState(describeConnectionError(error))); return false; } } From e82f6d5c9c23091b6c7f62fa3a049b4fa1c476e0 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:21 +0700 Subject: [PATCH 03/24] fix(awareness): keep cohort reasons English in data summarizeAwarenessCohort() translated `reason`, which is retained in the awareness context snapshot. Mark the four reasons with msg() instead; the awareness panel already shows them through translateKnown(). --- src/data/militaryAwarenessEngine.js | 13 +++++++------ src/data/militaryAwarenessEngine.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..5f359b757 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,6 +1,6 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js'; const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); @@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) { /** * Summarize sorted nearby results without promoting missing/stale feeds to a * negative finding. `UNKNOWN` is an input-data state, never an out-of-range - * claim in this MVP. + * claim in this MVP. `reason` is English data; the awareness panel shows it + * in the UI language. */ export function summarizeAwarenessCohort( items, @@ -95,14 +96,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed unavailable'), + reason: msg('feed unavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('feed stale'), + reason: msg('feed stale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -119,8 +120,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? t('observed or mapped nearby context') - : t('no observed or mapped objects in current feeds'), + ? msg('observed or mapped nearby context') + : msg('no observed or mapped objects in current feeds'), }; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..8b8771f14 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale, translateKnown } from '../i18n/index.js'; import { AWARENESS_RELATIONSHIP, findByDoublingRadius, @@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => { assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale'); }); +test('cohort reasons stay English in data and translate where the panel shows them', (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const reasons = [ + summarizeAwarenessCohort([], { available: false }).reason, + summarizeAwarenessCohort([], { stale: true }).reason, + summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason, + summarizeAwarenessCohort([]).reason, + ]; + assert.deepEqual(reasons, [ + 'feed unavailable', + 'feed stale', + 'observed or mapped nearby context', + 'no observed or mapped objects in current feeds', + ]); + // The awareness panel shows a reason through translateKnown(). + for (const reason of reasons) assert.notEqual(translateKnown(reason), reason); +}); + test('navigation prioritizes nearby targets from the selected cohort', () => { const cohorts = [ { id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } }, From d16620a29ff3d8e696c0bada22d32608ca3a21a2 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 11 Oct 2026 17:51:36 +0530 Subject: [PATCH 04/24] fix: guard scene director callbacks --- src/director/clock.js | 8 ++- src/director/clock.test.mjs | 29 ++++++++++ .../interactions/interactions.test.mjs | 55 +++++++++++++++++++ src/director/interactions/session.js | 15 +++-- src/director/playback.js | 17 +++++- src/director/playback.test.mjs | 32 +++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/director/clock.js b/src/director/clock.js index fa83047a3..48b1c19c5 100644 --- a/src/director/clock.js +++ b/src/director/clock.js @@ -124,7 +124,13 @@ class PlaybackClock { subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); - if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + if (this._sceneClockSnapshot) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } return () => this._sceneClockListeners.delete(listener); } diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs index c259c10ec..479808fc9 100644 --- a/src/director/clock.test.mjs +++ b/src/director/clock.test.mjs @@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a }); assert.equal(f.timers.size, 0); }); + +test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => { + const f = fixture(); + f.clock.publish(scene, shot, 1); + let calls = 0; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const unsubscribe = f.clock.subscribe(() => { + calls++; + throw new Error('listener failure'); + }); + assert.equal(typeof unsubscribe, 'function'); + assert.equal(calls, 1); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:'); + f.clock.publish(scene, shot, 2); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + unsubscribe(); + f.clock.publish(scene, shot, 3); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + f.clock.destroy(); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..496616516 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -178,6 +178,61 @@ test('synchronous stop before execution prevents any side effect; rejection unlo assert.equal(calls, 2); }); +test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => { + let executed = 0; + let throwOnBusy = true; + let throwOnEnd = true; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => { + executed++; + return true; + }, + changed: (state) => { + if (throwOnBusy && state.busy) throw new Error('start failed'); + if (throwOnEnd && !state.busy && state.selected === 'a') { + throw new Error('end failed'); + } + }, + }); + session.activate([{ id: 'a' }]); + const result = await session.dispatch('a'); + assert.equal(result, true); + assert.equal(executed, 1); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: 'a', + count: 1, + }); + assert.equal(warnings.length, 2); + assert.equal( + warnings[0][0], + '[Scenes] Interaction session observer failed:', + ); + assert.equal( + warnings[1][0], + '[Scenes] Interaction session observer failed:', + ); + throwOnBusy = false; + throwOnEnd = false; + assert.equal(await session.dispatch('a'), true); + assert.equal(executed, 2); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js index 2f305b675..f731492e2 100644 --- a/src/director/interactions/session.js +++ b/src/director/interactions/session.js @@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) { selected = null; let actions = new Map(); const state = () => ({ active, busy, selected, count: actions.size }); + function notify() { + try { + changed(state()); + } catch (error) { + console.warn('[Scenes] Interaction session observer failed:', error); + } + } function clear() { active = false; busy = false; @@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { actions.clear(); controller?.abort(); controller = null; - changed(state()); + notify(); } return { clear, @@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { clear(); actions = new Map(items.map((item) => [item.id, item])); active = !!actions.size; - changed(state()); + notify(); }, async dispatch(id) { const item = actions.get(id); @@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { controller = current; busy = true; selected = id; - changed(state()); + notify(); let abort; try { const cancelled = new Promise((resolve) => { @@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { if (controller === current) { busy = false; controller = null; - changed(state()); + notify(); } } }, diff --git a/src/director/playback.js b/src/director/playback.js index 4fa97df1a..29759871e 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -53,6 +53,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; + let runError = null; try { if (!queue.length || cancelled()) { return { @@ -68,8 +69,9 @@ export async function playSceneQueue( for (let index = 0; index < queue.length && !cancelled(); index++) { const { scene, shot } = queue[index]; if (activeScene && activeScene.id !== scene.id) { - await adapter.releaseScene(activeScene); + const toRelease = activeScene; activeScene = null; + await adapter.releaseScene(toRelease); if (cancelled()) break; } activeScene = scene; @@ -82,8 +84,19 @@ export async function playSceneQueue( } if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; + } catch (error) { + runError = error; + throw error; } finally { - if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene); + if (releaseOnFinish && activeScene) { + const toRelease = activeScene; + activeScene = null; + try { + await adapter.releaseScene(toRelease); + } catch (error) { + if (!runError) throw error; + } + } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index f1d8f0921..d6c0b6451 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -196,3 +196,35 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy (error) => error === failure, ); }); + +test('a phase failure is preserved when final scene release also throws', async () => { + const phaseError = new Error('travel failed'); + const releaseError = new Error('release failed'); + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); +}); +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']); +}); \ No newline at end of file From d2de6258ae6b5776b11c074c3666d444e3b3cae7 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sat, 17 Oct 2026 14:50:47 +0530 Subject: [PATCH 05/24] docs: document director playback error handling --- changelog.d/1014-director-callback-failures.md | 1 + docs/CURRENT-STATE.md | 4 ++++ src/director/playback.js | 1 + src/director/playback.test.mjs | 1 + 4 files changed, 7 insertions(+) create mode 100644 changelog.d/1014-director-callback-failures.md diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md new file mode 100644 index 000000000..65118b7a9 --- /dev/null +++ b/changelog.d/1014-director-callback-failures.md @@ -0,0 +1 @@ +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 (deepeshbind, #1014). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..f40ae5afb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +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. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing diff --git a/src/director/playback.js b/src/director/playback.js index 29759871e..175534434 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -35,6 +35,7 @@ export function buildPlaybackQueue( * releaseScene(scene, token?) leaves a previously loaded scene before playback * (false refuses that initial handoff). Between scenes and during final cleanup * it receives no cancelled token, so resource release can finish after Stop. + * The first error wins when the final release also fails. * An empty/already-cancelled queue acquires and releases no resources. * * @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue. diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index d6c0b6451..73de9b444 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -213,6 +213,7 @@ test('a phase failure is preserved when final scene release also throws', async (error) => error === phaseError, ); }); + test('a failing inter-scene release releases the active scene only once', async () => { const releaseError = new Error('release failed'); const released = []; From abf2ce1c9a6fe7422c02469c069b6a539597f835 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sat, 17 Oct 2026 19:40:12 +0530 Subject: [PATCH 06/24] fix: guard scene director callbacks --- src/director/playback.js | 6 +++++- src/director/playback.test.mjs | 34 ++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/director/playback.js b/src/director/playback.js index 175534434..2e243d748 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -95,7 +95,11 @@ export async function playSceneQueue( try { await adapter.releaseScene(toRelease); } catch (error) { - if (!runError) throw error; + if (runError) { + console.warn('[Scenes] Scene release failed:', error); + } else { + throw error; + } } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index 73de9b444..be56079e2 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -200,18 +200,28 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy 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 f = fixture({ - travel: async () => { - throw phaseError; - }, - releaseScene: async () => { - throw releaseError; - }, - }); - await assert.rejects( - playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), - (error) => error === phaseError, - ); + 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 () => { From c0356341b0b86f13e172e583083e47e5faf64504 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 07/24] docs(changelog): add the #1209 entry --- changelog.d/1209-layer-errors-english-in-data.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1209-layer-errors-english-in-data.md diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md new file mode 100644 index 000000000..7731e9020 --- /dev/null +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -0,0 +1,4 @@ +- Keep Directions route errors, the Local ADS-B receiver error and Global + Context cohort reasons English in layer data; the Layers panel, Radio card + and awareness panel still show them in the interface language + (daikaginza, #1209). From 3109ddabb80a5e9c28a9a889b45c5f39cf00ad4f Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 18 Oct 2026 13:37:27 +0530 Subject: [PATCH 08/24] add trailing newline to playback test --- src/director/playback.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index be56079e2..0141f2b01 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -238,4 +238,4 @@ test('a failing inter-scene release releases the active scene only once', async (error) => error === releaseError, ); assert.deepEqual(released, ['a']); -}); \ No newline at end of file +}); From 38d16f0a8180b9626579aa0e9fb5e20a9e4d04f3 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:52 +0700 Subject: [PATCH 09/24] docs(sdr): record the message / errorText split for receiver errors --- docs/CURRENT-STATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..3fad1d23c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5552,6 +5552,11 @@ reception does not interrupt internet radio. Changes apply to the open receiver without reconnecting. - **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard, aircraft with a fresh position and the IQ level. +- **Error text.** A receiver error sets two fields (`errorState()` in + `src/sdr/controller.js`): `message` is the UI language for the Radio card, + `errorText` is the English text the Local ADS-B layer reports as its stats + `error` (`localAdsbStatus()`, falling back to `message` when a receiver sets + none); the Layers panel translates it where it shows it. - **Devices.** An already-authorized receiver opens without the WebUSB picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the 1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels. From 76199c07eeda0f88e8cb46474f57453cd3dd5e38 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:27:25 +0100 Subject: [PATCH 10/24] fix(director): keep a falsy phase error over a release failure runError doubled as the failure flag, so a phase that threw a falsy value was still masked by a final release error (ianblenke's nit). Track failure with a boolean and cover it with a test. Make the changelog fragment a wrapped bullet like the other fragments. Co-Authored-By: Claude Opus 5.5 --- .../1014-director-callback-failures.md | 5 ++++- src/director/playback.js | 6 ++--- src/director/playback.test.mjs | 22 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md index 65118b7a9..f3877eca4 100644 --- a/changelog.d/1014-director-callback-failures.md +++ b/changelog.d/1014-director-callback-failures.md @@ -1 +1,4 @@ -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 (deepeshbind, #1014). +- 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). diff --git a/src/director/playback.js b/src/director/playback.js index 2e243d748..3c060b2bb 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -54,7 +54,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; - let runError = null; + let runFailed = false; try { if (!queue.length || cancelled()) { return { @@ -86,7 +86,7 @@ export async function playSceneQueue( if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; } catch (error) { - runError = error; + runFailed = true; throw error; } finally { if (releaseOnFinish && activeScene) { @@ -95,7 +95,7 @@ export async function playSceneQueue( try { await adapter.releaseScene(toRelease); } catch (error) { - if (runError) { + if (runFailed) { console.warn('[Scenes] Scene release failed:', error); } else { throw error; diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index 0141f2b01..820e8186e 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -239,3 +239,25 @@ test('a failing inter-scene release releases the active scene only once', async ); 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; + } +}); From 6a5374435fec4940a780d98960d0a501c630db53 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:52:46 +0100 Subject: [PATCH 11/24] docs(current-state): move the #1014 note into its topical section Four open PRs prepended their notes at the top of CURRENT-STATE.md, so merging any one of them made the others conflict. Place this note in its own section instead. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2fd0aaa0c..055e5f7d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,9 +1,5 @@ # God's Eye View Current State -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. - Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, @@ -986,6 +982,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; 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 presentation is separated from its camera/controller behavior. Existing From 1644acd2a39459f749424283b384650132e1c1e0 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sat, 24 Oct 2026 23:05:56 +0100 Subject: [PATCH 12/24] fix(director): restore layer visibility on same-shot seeks --- changelog.d/0-director-seek-visibility.md | 3 ++ docs/CURRENT-STATE.md | 4 ++ scripts/qa-director-timing.mjs | 51 +++++++++++++++++++ src/scenes/director.js | 19 +++++-- src/scenes/director.test.mjs | 60 +++++++++++++++++++++++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 changelog.d/0-director-seek-visibility.md diff --git a/changelog.d/0-director-seek-visibility.md b/changelog.d/0-director-seek-visibility.md new file mode 100644 index 000000000..35e98cfb5 --- /dev/null +++ b/changelog.d/0-director-seek-visibility.md @@ -0,0 +1,3 @@ +- Restore a shot's declared layer visibility when seeking within it after manual + layer changes. Refused restoration reports a failed seek, while undeclared + operator layers and the unchanged-stack shortcut are preserved (@oneill9). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dbd591b17..38736c32e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -506,6 +506,10 @@ dateline while preserving both authored endpoint longitudes, and turns heading 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 the end of the previous one. +Same-shot seeking restores declared layer visibility after a manual toggle, +using the cancellable layer admission path and reporting refused restoration +as a failed seek. Undeclared operator layers remain untouched, and unchanged +declared visibility retains the lightweight seek path. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 1361a8dcb..fb96dd0bb 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -88,6 +88,57 @@ try { }; }); for (const [name, passed] of Object.entries(result)) check(name, passed); + const visibilityRestored = await page.evaluate(async () => { + 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, + }); + await dataManager.setEnabled(layerId, false, { origin: 'user' }); + const sought = await director.seekScene(scene.id, 0.5); + return ( + loaded?.started && 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); + } + }); + check( + 'same-shot seek restores manually changed layer visibility', + visibilityRestored, + ); const content = await page.evaluate(async () => { const d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find(({ title }) => diff --git a/src/scenes/director.js b/src/scenes/director.js index 000c02681..4ad02c8ac 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1529,16 +1529,27 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return false; + const states = this._layerStatesForShot(scene, shot, { + cameraSettled: seekState.cameraProgress >= 1, + sceneSeek: seekState, + }); + const layers = new Map( + this.dataManager.getAll().map((layer) => [layer.id, layer.enabled]), + ); + // A manual toggle invalidates the loaded stack. Let LOAD reconcile it + // through the normal cancellable admission path before reporting success. + if ( + sceneLayerPlan(states, new Set(layers.keys())).some( + ({ id, enabled }) => Boolean(layers.get(id)) !== enabled, + ) + ) + return false; if (!this._claimCameraOwnership()) return false; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); this._loadAbort = null; this._loadGeneration += 1; this._clock.stopShot(); - const states = this._layerStatesForShot(scene, shot, { - cameraSettled: seekState.cameraProgress >= 1, - sceneSeek: seekState, - }); let applied = true; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 1077b0658..ccbd5e38d 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -174,6 +174,66 @@ 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, 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; + + assert.equal(await director.seekScene('scene-1', 0.25), true); + 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, restore } = makeDirector({ + data: { refuse: () => refuse }, + }); + try { + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + await dataManager.setEnabled('flights', false); + refuse = true; + + assert.equal(await director.seekScene('scene-1', 0.25), false); + assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false); + assert.equal(director.getPlaybackTimingState().activeTimers, 0); + } 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', () => { const { director, restore } = makeDirector(); const snapshots = []; From 8553b3d5f79e84b5478a4267281ec77e39342de9 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sat, 24 Oct 2026 23:14:15 +0100 Subject: [PATCH 13/24] docs: number changelog fragment for PR 1374 --- ...ctor-seek-visibility.md => 1374-director-seek-visibility.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-director-seek-visibility.md => 1374-director-seek-visibility.md} (91%) diff --git a/changelog.d/0-director-seek-visibility.md b/changelog.d/1374-director-seek-visibility.md similarity index 91% rename from changelog.d/0-director-seek-visibility.md rename to changelog.d/1374-director-seek-visibility.md index 35e98cfb5..f7c366de9 100644 --- a/changelog.d/0-director-seek-visibility.md +++ b/changelog.d/1374-director-seek-visibility.md @@ -1,3 +1,3 @@ - Restore a shot's declared layer visibility when seeking within it after manual layer changes. Refused restoration reports a failed seek, while undeclared - operator layers and the unchanged-stack shortcut are preserved (@oneill9). + operator layers and the unchanged-stack shortcut are preserved (@oneill9, #1374). From b900fb84105d449fe2a40703d6c7a6a7d148f693 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sun, 25 Oct 2026 19:54:51 +0000 Subject: [PATCH 14/24] fix(director): reconcile only stale layers on same-shot seeks Compare declared visibility with the manager's effective intent, and treat in-flight transitions as stale so a toggle or superseded LOAD rollback that is still settling cannot slip past the check. Reconcile only those layers inside the lightweight seek instead of rebuilding the shot, and report a refusal without resetting the seek position or camera. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1374-director-seek-visibility.md | 5 +- docs/CURRENT-STATE.md | 7 +- scripts/qa-director-timing.mjs | 9 ++- .../interactions/interactions.test.mjs | 2 +- src/scenes/director.js | 57 +++++++++++------ src/scenes/director.test.mjs | 64 ++++++++++++++++++- 6 files changed, 115 insertions(+), 29 deletions(-) diff --git a/changelog.d/1374-director-seek-visibility.md b/changelog.d/1374-director-seek-visibility.md index f7c366de9..d4f93a1bd 100644 --- a/changelog.d/1374-director-seek-visibility.md +++ b/changelog.d/1374-director-seek-visibility.md @@ -1,3 +1,4 @@ - Restore a shot's declared layer visibility when seeking within it after manual - layer changes. Refused restoration reports a failed seek, while undeclared - operator layers and the unchanged-stack shortcut are preserved (@oneill9, #1374). + 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 38736c32e..9eb26d911 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -507,9 +507,10 @@ 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 the end of the previous one. Same-shot seeking restores declared layer visibility after a manual toggle, -using the cancellable layer admission path and reporting refused restoration -as a failed seek. Undeclared operator layers remain untouched, and unchanged -declared visibility retains the lightweight seek path. +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). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index fb96dd0bb..63934140e 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -122,10 +122,15 @@ try { const loaded = await director.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.2, }); - await dataManager.setEnabled(layerId, false, { origin: 'user' }); + 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 && sought && dataManager.layers.get(layerId).enabled + loaded?.started && + disabled && + sought && + dataManager.layers.get(layerId).enabled ); } finally { director.stopScene(); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..3e80c3121 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -188,7 +188,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele _claimCameraOwnership: () => 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 () => { diff --git a/src/scenes/director.js b/src/scenes/director.js index 4ad02c8ac..7a78ef6b2 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1516,9 +1516,13 @@ export class SceneDirector { return new Promise((resolve) => this._runIdleResolvers.add(resolve)); } - /** Apply a new time inside the already-loaded shot without rebuilding its stack. */ - _seekLoadedShot(scene, seekState) { - if (this._destroyed) return false; + /** + * Apply a new time inside the already-loaded shot without rebuilding its stack. + * @returns {Promise} Null when the shortcut does not apply and + * LOAD must rebuild the shot; otherwise whether the seek settled. + */ + async _seekLoadedShot(scene, seekState) { + if (this._destroyed) return null; const shot = seekState?.shot; if ( !scene || @@ -1528,28 +1532,41 @@ export class SceneDirector { this._loadedSceneId !== scene.id || this._selectedShotId !== shot.id ) - return false; + return null; const states = this._layerStatesForShot(scene, shot, { cameraSettled: seekState.cameraProgress >= 1, sceneSeek: seekState, }); - const layers = new Map( - this.dataManager.getAll().map((layer) => [layer.id, layer.enabled]), + // 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 lifecycle = this.dataManager.getLayerLifecycleState(id); + return ( + lifecycle && + (lifecycle.lifecycleState === 'enabling' || + lifecycle.lifecycleState === 'disabling' || + this.dataManager.isEffectivelyEnabled(id) !== !!state?.enabled) + ); + }), ); - // A manual toggle invalidates the loaded stack. Let LOAD reconcile it - // through the normal cancellable admission path before reporting success. - if ( - sceneLayerPlan(states, new Set(layers.keys())).some( - ({ id, enabled }) => Boolean(layers.get(id)) !== enabled, - ) - ) - return false; - if (!this._claimCameraOwnership()) return false; + if (!this._claimCameraOwnership()) return null; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); this._loadAbort = null; - this._loadGeneration += 1; + const generation = ++this._loadGeneration; this._clock.stopShot(); + if (Object.keys(staleStates).length) { + const controller = new AbortController(); + this._loadAbort = controller; + const layerResult = await this._applyLayerStates( + staleStates, + this._loadToken(generation, controller.signal), + ); + if (this._loadAbort === controller) this._loadAbort = null; + if (layerResult.cancelled || layerResult.refused.length) return false; + } let applied = true; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; @@ -1561,7 +1578,7 @@ export class SceneDirector { applied = false; } } - if (!applied) return false; + if (!applied) return null; this._setCameraView( seekState.camera || resolveCameraPose(scene, shot.camera), ); @@ -1597,7 +1614,11 @@ export class SceneDirector { return false; const seekState = this._sceneSeekState(scene, progress); if (!seekState) return false; - if (this._seekLoadedShot(scene, seekState)) return true; + const seeked = await this._seekLoadedShot(scene, seekState); + if (seeked !== null) + return ( + seeked && !this._destroyed && generation === this._sceneSeekGeneration + ); const result = await this._loadShot(scene.id, seekState.shot.id, { sceneSeek: seekState, }); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index ccbd5e38d..968ffa1ed 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -175,7 +175,7 @@ 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, restore } = makeDirector(); + const { director, dataManager, styleManager, restore } = makeDirector(); try { const shot = director._project.scenes[0].shots[0]; shot.layers = { flights: { enabled: true }, traffic: { enabled: false } }; @@ -184,8 +184,11 @@ test('same-shot seeking restores declared layer visibility after manual changes' 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'); @@ -199,17 +202,58 @@ test('same-shot seeking restores declared layer visibility after manual changes' test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => { let refuse = false; - const { director, dataManager, restore } = makeDirector({ + 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); - assert.equal(director.getPlaybackTimingState().activeTimers, 0); + 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, restore } = makeDirector(); + 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); } finally { await director.destroy(); restore(); @@ -707,6 +751,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) { */ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) { const enabled = new Map(); + const pending = new Map(); const setEnabledCalls = []; const setParamsCalls = []; const committed = []; @@ -719,12 +764,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) setParamsCalls, committed, 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, async setEnabled(id, shouldEnable, { signal } = {}) { setEnabledCalls.push({ id, enabled: shouldEnable, signal }); if (refuse(id, shouldEnable)) return false; // Yield once so a stop landing during the transition is observable. + pending.set(id, shouldEnable); await Promise.resolve(); + if (pending.get(id) === shouldEnable) pending.delete(id); if (signal?.aborted) return false; enabled.set(id, shouldEnable); committed.push({ id, enabled: shouldEnable }); From 458def08a5a8a9b9a0b3dbaa826a1d644a178580 Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sun, 25 Oct 2026 20:20:09 +0000 Subject: [PATCH 15/24] fix(director): cover queued and uncertain layer states on same-shot seeks --- src/scenes/director.js | 19 +++++++++------- src/scenes/director.test.mjs | 44 +++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/scenes/director.js b/src/scenes/director.js index 7a78ef6b2..cb9c0b55c 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -1533,6 +1533,12 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return null; + if (!this._claimCameraOwnership()) return false; + this._cancelActiveSceneTravel(); + this._loadAbort?.abort(); + this._loadAbort = null; + const generation = ++this._loadGeneration; + this._clock.stopShot(); const states = this._layerStatesForShot(scene, shot, { cameraSettled: seekState.cameraProgress >= 1, sceneSeek: seekState, @@ -1542,21 +1548,18 @@ export class SceneDirector { // 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.lifecycleState === 'enabling' || + (lifecycle.enabled !== wantEnabled || + lifecycle.lifecycleState === 'enabling' || lifecycle.lifecycleState === 'disabling' || - this.dataManager.isEffectivelyEnabled(id) !== !!state?.enabled) + lifecycle.uncertain || + this.dataManager.isEffectivelyEnabled(id) !== wantEnabled) ); }), ); - if (!this._claimCameraOwnership()) return null; - this._cancelActiveSceneTravel(); - this._loadAbort?.abort(); - this._loadAbort = null; - const generation = ++this._loadGeneration; - this._clock.stopShot(); if (Object.keys(staleStates).length) { const controller = new AbortController(); this._loadAbort = controller; diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 968ffa1ed..b874612ae 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; +import { LayerLifecycle } from '../data/lifecycle.js'; import { SceneDirector } from './director.js'; import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js'; import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js'; @@ -241,7 +242,19 @@ test('same-shot seeking restores visibility a manual toggle is still changing', }); test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => { - const { director, dataManager, restore } = makeDirector(); + 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); @@ -254,7 +267,36 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls 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(); } From fdda5b7a1ab77ff8fbc992b91f3ba4dc38b4d25f Mon Sep 17 00:00:00 2001 From: Alex O'Neill Date: Sun, 25 Oct 2026 20:52:42 +0000 Subject: [PATCH 16/24] Abort superseded same-shot seeks and roll back partial layer restores --- src/scenes/director.js | 114 +++++++++++++++++++++--------- src/scenes/director.test.mjs | 131 +++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/scenes/director.js b/src/scenes/director.js index cb9c0b55c..98364dfff 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -234,7 +234,7 @@ export class SceneDirector { destroy() { if (this._destroyPromise) return this._destroyPromise; this._destroyed = true; - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._visibilityUnsubscribe?.(); this._cameraHandoffUnsubscribe?.(); this._removeCameraInput?.(); @@ -1071,7 +1071,7 @@ export class SceneDirector { loadShot(sceneId, shotId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); this._interactionTransitions = 0; const previousGeneration = this._loadGeneration; const work = this._loadShot(sceneId, shotId, options); @@ -1521,7 +1521,17 @@ export class SceneDirector { * @returns {Promise} Null when the shortcut does not apply and * LOAD must rebuild the shot; otherwise whether the seek settled. */ - async _seekLoadedShot(scene, seekState) { + _bumpSceneSeekGeneration() { + this._loadAbort?.abort(); + this._loadAbort = null; + return ++this._sceneSeekGeneration; + } + + async _seekLoadedShot( + scene, + seekState, + seekGeneration = this._sceneSeekGeneration, + ) { if (this._destroyed) return null; const shot = seekState?.shot; if ( @@ -1533,6 +1543,8 @@ export class SceneDirector { this._selectedShotId !== shot.id ) return null; + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; if (!this._claimCameraOwnership()) return false; this._cancelActiveSceneTravel(); this._loadAbort?.abort(); @@ -1547,29 +1559,57 @@ export class SceneDirector { // 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) - ); - }), + 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, + Boolean(this.dataManager.getLayerLifecycleState(id)?.enabled), + ]), + ); const controller = new AbortController(); this._loadAbort = controller; const layerResult = await this._applyLayerStates( staleStates, - this._loadToken(generation, controller.signal), + this._loadToken(generation, controller.signal, seekGeneration), + { stopOnRefusal: true, exitContextMode: false }, ); if (this._loadAbort === controller) this._loadAbort = null; - if (layerResult.cancelled || layerResult.refused.length) return false; + if ( + layerResult.cancelled || + layerResult.refused.length || + this._destroyed || + seekGeneration !== this._sceneSeekGeneration + ) { + if (!this._destroyed && !this._loadAbort && !this._running) { + for (const id of [...layerResult.applied].reverse()) { + if (this._destroyed || this._loadAbort || this._running) 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; for (const [layerId, state] of Object.entries(states)) { if (!state?.enabled || !state.params) continue; @@ -1582,6 +1622,8 @@ export class SceneDirector { } } if (!applied) return null; + if (this._destroyed || seekGeneration !== this._sceneSeekGeneration) + return false; this._setCameraView( seekState.camera || resolveCameraPose(scene, shot.camera), ); @@ -1603,7 +1645,7 @@ export class SceneDirector { seekScene(sceneId, progress) { if (this._destroyed) return Promise.resolve(false); this.stopScene(msg('Seeking scene clock')); - const generation = ++this._sceneSeekGeneration; + const generation = this._bumpSceneSeekGeneration(); return this._trackWork(this._seekScene(sceneId, progress, generation)); } @@ -1617,11 +1659,10 @@ export class SceneDirector { return false; const seekState = this._sceneSeekState(scene, progress); if (!seekState) return false; - const seeked = await this._seekLoadedShot(scene, seekState); - if (seeked !== null) - return ( - seeked && !this._destroyed && generation === this._sceneSeekGeneration - ); + 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, { sceneSeek: seekState, }); @@ -1641,15 +1682,17 @@ export class SceneDirector { * @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls * @returns {{ cancelled: boolean, signal: AbortSignal|undefined }} */ - _loadToken(generation, signal = undefined) { + _loadToken(generation, signal = undefined, seekGeneration = null) { const director = this; return { signal, get cancelled() { return ( director._destroyed || - signal?.aborted || - director._loadGeneration !== generation + Boolean(signal?.aborted) || + director._loadGeneration !== generation || + (seekGeneration !== null && + director._sceneSeekGeneration !== seekGeneration) ); }, }; @@ -1788,7 +1831,7 @@ export class SceneDirector { startScene(sceneId, options) { if (this._destroyed) return Promise.resolve({ started: false, reason: 'destroyed' }); - this._sceneSeekGeneration++; + this._bumpSceneSeekGeneration(); return this._trackWork(this._startScene(sceneId, options)); } @@ -1954,9 +1997,7 @@ export class SceneDirector { this._setSceneMediaPlayback(); this._interactions?.clear(); this._dataPacks?.clear(); - this._sceneSeekGeneration++; - this._loadAbort?.abort(); - this._loadAbort = null; + this._bumpSceneSeekGeneration(); this._loadGeneration++; this._clock.stopShot(); this._cancelActiveSceneTravel(); @@ -2200,7 +2241,11 @@ export class SceneDirector { * the transition in flight. * @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 refused = []; const abort = () => ({ applied, refused, cancelled: true }); @@ -2210,8 +2255,10 @@ export class SceneDirector { // the operator's pre-mode state, which is exactly where a stopped scene // should come to rest. Tearing that transaction in half would strand // Context, so it completes and cancellation is honoured immediately after. - await this._exitIsolatingContextMode(); - if (token?.cancelled) return abort(); + if (exitContextMode) { + await this._exitIsolatingContextMode(); + if (token?.cancelled) return abort(); + } const signal = token?.signal; const registered = new Set( @@ -2241,6 +2288,7 @@ export class SceneDirector { console.warn( `[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`, ); + if (stopOnRefusal) break; continue; } if (params && typeof this.dataManager.restoreLayerState !== 'function') { diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b874612ae..b2981a68b 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -302,6 +302,132 @@ test('same-shot seeking restores a layer whose superseded LOAD transition rolls } }); +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('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => { const { director, dataManager, styleManager, restore } = makeDirector(); try { @@ -867,15 +993,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) { /** Cesium viewer double whose flights complete on the next microtask turn. */ function fakeViewer() { const flights = []; + const views = []; let cancelled = 0; return { flights, + views, get cancelledFlights() { return cancelled; }, camera: { flyTo(options) { flights.push(options); Promise.resolve().then(() => options.complete?.()); }, + setView(options) { + views.push(options); + }, cancelFlight() { cancelled++; }, }, }; From 25973ade2cb6cdc718697aca8766c386e5e6f6be Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:39:34 -0700 Subject: [PATCH 17/24] fix(perimeters): report a stale WFIGS snapshot as stale, and stop serving it after a day When WFIGS was unreachable the proxy served its last snapshot, marked stale (X-Data-Stale, body.stale), with no age limit, and the client source kept only the rows. The layer then read healthy with a fresh update time and get_fire_perimeters reported the perimeters as current; three days into an outage it still said "1 mapped wildfire perimeter in the requested box." The source now offers getSnapshotDetail ({rows, stale, fetchedAt}); the layer reports stale with the snapshot's fetch time, the tool adds stale, retrieved_at and "(data may be stale)", and the proxy serves a stale snapshot for at most 24 hours, as the GNSS proxy caps its own. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- server/providers/firePerimeters.js | 11 ++++- src/layers/perimeters/index.js | 25 +++++++++-- src/layers/perimeters/ownership.test.mjs | 33 ++++++++++++++ src/layers/perimeters/source.js | 20 ++++++++- src/layers/perimeters/source.test.mjs | 57 ++++++++++++++++++++++++ src/tools/queries/environment.js | 15 ++++++- src/tools/queries/environment.test.mjs | 36 +++++++++++++++ 8 files changed, 190 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c53c93a2..0e7bc1fed 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3783,7 +3783,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered 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`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts diff --git a/server/providers/firePerimeters.js b/server/providers/firePerimeters.js index 4e6a933e1..4a364fa6d 100644 --- a/server/providers/firePerimeters.js +++ b/server/providers/firePerimeters.js @@ -39,6 +39,8 @@ const API_URL = // The service caps a single response at its maxRecordCount (2000); a peak // season can exceed that, so follow exceededTransferLimit with offset pages. 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) => payload?.exceededTransferLimit === true || @@ -165,7 +167,14 @@ export function firePerimetersProxy({ }); return { value: await promise, stale: false }; } 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; } } diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 0ef9a9bcc..83826a865 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -55,6 +55,8 @@ export function createFirePerimetersLayer({ let _dataSource = null; let _count = 0; let _lastUpdate = null; + // The proxy's last snapshot, served while WFIGS is unreachable. + let _stale = false; let _lastError = null; let _enabled = false; let _clickHandler = null; @@ -260,6 +262,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; _enabled = false; console.log('[Data:FirePerimeters] Initialized'); }, @@ -287,9 +290,21 @@ export function createFirePerimetersLayer({ _request = request; refreshInciwebIndex(request.signal); 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) 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. const signature = JSON.stringify( @@ -299,7 +314,7 @@ export function createFirePerimetersLayer({ ); if (signature === _snapshotSignature) { if (_selectedId) publishSelectedCard(); - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; return true; } @@ -355,7 +370,7 @@ export function createFirePerimetersLayer({ // Refresh (or drop) the selected incident's card against the new feed. if (_selectedId) publishSelectedCard(); _count = rows.length; - _lastUpdate = Date.now(); + _lastUpdate = updatedAt; _lastError = null; console.log( `[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`, @@ -366,6 +381,8 @@ export function createFirePerimetersLayer({ return false; console.warn('[Data:FirePerimeters] Fetch error:', e); _lastError = e?.message || msg('Perimeter source unavailable'); + // A failure now outranks the stale snapshot still drawn. + _stale = false; return false; } finally { if (_request === request) _request = null; @@ -391,6 +408,7 @@ export function createFirePerimetersLayer({ _count = 0; _lastUpdate = null; _lastError = null; + _stale = false; }, /** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */ @@ -443,6 +461,7 @@ export function createFirePerimetersLayer({ count: _count, lastUpdate: _lastUpdate, error: _lastError, + stale: _stale, }; }, }; diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index debd76aa8..bd5c3fc70 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) { 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'); +}); diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index 133beadb5..b654fc651 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -9,7 +9,17 @@ export function createWfigsPerimeterSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { return { - async getSnapshot({ signal } = {}) { + /** The perimeter rows (see getSnapshotDetail for staleness). */ + async getSnapshot(options = {}) { + return (await this.getSnapshotDetail(options)).rows; + }, + + /** + * 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 getSnapshotDetail({ signal } = {}) { signal?.throwIfAborted(); const response = await fetchImpl('/api/fire-perimeters', { signal }); if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`); @@ -21,7 +31,13 @@ export function createWfigsPerimeterSource({ signal?.throwIfAborted(); if (!Array.isArray(payload?.rows)) throw new Error(msg('Malformed perimeter snapshot')); - return payload.rows; + return { + rows: payload.rows, + stale: payload.stale === true, + fetchedAt: Number.isFinite(payload.fetchedAt) + ? payload.fetchedAt + : null, + }; }, }; } diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs index a9f8fbd4c..3944fe77b 100644 --- a/src/layers/perimeters/source.test.mjs +++ b/src/layers/perimeters/source.test.mjs @@ -115,3 +115,60 @@ test('response-body completion honors cancellation without replacing records', a 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(), []); +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index b1d0c6a86..08db732fc 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({ requires: ['perimeters'], async run(args, { 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)) .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) .filter(({ point }) => point) @@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({ })) .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); 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: { view: suggestView(services, { area, layers: ['fire-perimeters'] }), + stale, + retrieved_at: isoTime(snapshot.fetchedAt), ...capRows(rows, args.limit), }, }; diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 694b9718e..b7107f49e 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => { assert.equal(slow.data.names_pending, true); 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()); +}); From f3faf373f079a9b0081371685109d56e6502fcf4 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:06:07 -0700 Subject: [PATCH 18/24] docs(changelog): note #1404 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1404-perimeters-stale.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1404-perimeters-stale.md diff --git a/changelog.d/1404-perimeters-stale.md b/changelog.d/1404-perimeters-stale.md new file mode 100644 index 000000000..5105aed2b --- /dev/null +++ b/changelog.d/1404-perimeters-stale.md @@ -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). From f6244e8fef4c0f5d107dccbbe5973fd000baf265 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:42:51 -0700 Subject: [PATCH 19/24] refactor(perimeters): share getSnapshotDetail without this Co-Authored-By: Claude Opus 5.5 --- src/layers/perimeters/source.js | 53 +++++++++++++-------------- src/layers/perimeters/source.test.mjs | 7 ++++ 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js index b654fc651..569191781 100644 --- a/src/layers/perimeters/source.js +++ b/src/layers/perimeters/source.js @@ -8,36 +8,35 @@ const msg = (text) => text; export function createWfigsPerimeterSource({ 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 { /** The perimeter rows (see getSnapshotDetail for staleness). */ async getSnapshot(options = {}) { - return (await this.getSnapshotDetail(options)).rows; - }, - - /** - * 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 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 (await getSnapshotDetail(options)).rows; }, + getSnapshotDetail, }; } diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs index 3944fe77b..bb6d6b305 100644 --- a/src/layers/perimeters/source.test.mjs +++ b/src/layers/perimeters/source.test.mjs @@ -172,3 +172,10 @@ test('the snapshot detail carries the proxy staleness and fetch time', async () }); 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(), []); +}); From 811eb7835d2fccb1a30f2fefa333d1f2d25a298b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:08:49 +0000 Subject: [PATCH 20/24] test(director): cover observer failures in session activate and clear --- .../interactions/interactions.test.mjs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index 496616516..6c13cecef 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -233,6 +233,37 @@ test('a throwing changed observer does not break session dispatch, activation, o } }); +test('an observer that always throws cannot stop activate or clear', () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => true, + changed: () => { + throw new Error('observer failed'); + }, + }); + session.activate([{ id: 'a' }, { id: 'b' }]); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: null, + count: 2, + }); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + assert.equal(warnings.length, 3); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; From 8cedd159e3cbbb8d5c09e21d5ae751699e94d78a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:13:39 +0000 Subject: [PATCH 21/24] fix(layers): translate every route proxy refusal and clear stale receiver errorText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route proxy also refuses with 'routing busy — too many routes at once' (429) and 'route proxy error'; both showed in English in every locale. Mark them in ROUTING_REFUSALS and add es, ru, zh-CN and zh-Hant entries. errorText is now cleared on a new connection, a mode switch and stop(), so it never outlives the error it describes. Tests cover the two refusals, 'Routing failed: rate limited' (pattern order) and the stop() reset. --- .../1209-layer-errors-english-in-data.md | 5 +++-- src/i18n/locales/es.json | 4 +++- src/i18n/locales/ru.json | 4 +++- src/i18n/locales/zh-CN.json | 4 +++- src/i18n/locales/zh-Hant.json | 4 +++- src/layers/localAdsb/statusLocale.test.mjs | 4 ++++ src/sdr/controller.js | 6 ++++- src/ui/statusText.js | 2 ++ src/ui/untranslatedErrors.test.mjs | 22 +++++++++++++++++++ 9 files changed, 48 insertions(+), 7 deletions(-) diff --git a/changelog.d/1209-layer-errors-english-in-data.md b/changelog.d/1209-layer-errors-english-in-data.md index 7731e9020..00360147a 100644 --- a/changelog.d/1209-layer-errors-english-in-data.md +++ b/changelog.d/1209-layer-errors-english-in-data.md @@ -1,4 +1,5 @@ - Keep Directions route errors, the Local ADS-B receiver error and Global Context cohort reasons English in layer data; the Layers panel, Radio card - and awareness panel still show them in the interface language - (daikaginza, #1209). + and awareness panel still show them in the interface language. The route + proxy's "routing busy" and "route proxy error" refusals are now translated + too (daikaginza, #1209). diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..e1e404001 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,7 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez", + "route proxy error": "error del proxy de rutas" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..739f80dac 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,7 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", + "route proxy error": "ошибка прокси маршрутов" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..c415e3776 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多", + "route proxy error": "路线代理出错" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d95a18e39..3f649178e 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2863,5 +2863,7 @@ "{count} placemarks": { "other": "{count} 個地標" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。", + "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", + "route proxy error": "路線代理出錯" } diff --git a/src/layers/localAdsb/statusLocale.test.mjs b/src/layers/localAdsb/statusLocale.test.mjs index d6ac821a4..61eb763cf 100644 --- a/src/layers/localAdsb/statusLocale.test.mjs +++ b/src/layers/localAdsb/statusLocale.test.mjs @@ -99,6 +99,10 @@ for (const locale of ['es', 'ru']) { const row = rowFor(status, 0); assert.ok(row.includes(state.message), row); assert.doesNotMatch(row, /No RTL-SDR device selected/, row); + + // The English text goes with the error: stop() clears it. + await receiver.stop(); + assert.equal(receiver.getState().errorText, null); } finally { setLocale('en'); await receiver.destroy(); diff --git a/src/sdr/controller.js b/src/sdr/controller.js index c8377c337..260ad6647 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -305,6 +305,7 @@ function describeConnectionError(error) { /** * State fields for a receiver error: `message` in the UI language for the * Radio card, `errorText` as English data for the Local ADS-B layer's stats. + * A new connection, a mode switch and stop() clear `errorText` again. * @param {string} text English error text (ours or the browser's). */ function errorState(text) { @@ -360,6 +361,7 @@ export class SdrController { gain: this._gainByMode.fm, seeking: false, seekMessage: '', + errorText: null, snr: null, aircraft: [], decodedMessages: 0, @@ -1082,6 +1084,7 @@ export class SdrController { this._setState({ status: 'connecting', message: t('Waiting for an RTL-SDR device…'), + errorText: null, }); let acquisition = null; try { @@ -1554,6 +1557,7 @@ export class SdrController { message: t('Switching to {mode}…', { mode: nextMode === 'adsb' ? 'ADS-B' : 'FM', }), + errorText: null, }); try { if (nextMode === 'fm' && !(await this._ensureAudio())) { @@ -1869,7 +1873,7 @@ export class SdrController { status: preserveMessage ? this.state.status : idleStatus, ...(preserveMessage ? {} - : { message: message || t('RTL-SDR disconnected') }), + : { message: message || t('RTL-SDR disconnected'), errorText: null }), snr: null, samplesPerSecond: 0, workerBlocks: 0, diff --git a/src/ui/statusText.js b/src/ui/statusText.js index e9eafc1d2..1af6b8b2a 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -122,6 +122,8 @@ const ROUTING_REFUSALS = new Set([ msg('routing service is rate limited'), msg('route leg too long'), msg('route too long'), + msg('routing busy — too many routes at once'), + msg('route proxy error'), ]); const routingRefusal = (text) => ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text); diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs index d8d05d6f0..226bf1364 100644 --- a/src/ui/untranslatedErrors.test.mjs +++ b/src/ui/untranslatedErrors.test.mjs @@ -83,6 +83,28 @@ for (const locale of ['es', 'ru']) { translateFeedError('Routing failed: something else'), t('Routing failed: {error}', { error: 'something else' }), ); + // Every refusal the proxy sends (server/providers/places/routes.js), + // and "rate limited" after "Routing failed:", which the feed pattern + // " rate limited" would otherwise take. + for (const [english, shown] of [ + [ + 'routing busy — too many routes at once — try again in a moment', + t('{error} — try again in a moment', { + error: t('routing busy — too many routes at once'), + }), + ], + [ + 'Routing failed: route proxy error', + t('Routing failed: {error}', { error: t('route proxy error') }), + ], + [ + 'Routing failed: rate limited', + t('Routing failed: {error}', { error: t('rate limited') }), + ], + ]) { + assert.equal(translateFeedError(english), shown); + assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/); + } } finally { setLocale('en'); } From 3b57299231c0058ba187d983c8887fe0f3e52f9e Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:28:38 +0000 Subject: [PATCH 22/24] fix(director): keep rejected loads from cancelling LOAD; roll back seek only on refusal - Superseding a seek aborts only the seek's own layer reconcile, so a loadShot that is later rejected (shot-not-found) no longer cancels an in-flight LOAD. Stop still aborts any LOAD. - A refused seek rolls back to each layer's effective intent, so a manual toggle still in flight is kept. - A cancelled seek leaves layers to whatever superseded it, as a cancelled LOAD does; only refusal rolls back. - Move the _seekLoadedShot JSDoc back onto its method. --- src/scenes/director.js | 56 +++++++++++++++++----------- src/scenes/director.test.mjs | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/src/scenes/director.js b/src/scenes/director.js index 98364dfff..a0c61cec1 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -153,6 +153,8 @@ export class SceneDirector { this._runAbort = null; /** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */ this._loadAbort = null; + /** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */ + this._seekAbort = null; this._clock = createPlaybackClock({ isRunning: () => this._running, timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot), @@ -1516,17 +1518,24 @@ export class SceneDirector { return new Promise((resolve) => this._runIdleResolvers.add(resolve)); } + /** + * Supersede any pending seek. Only a seek's own layer reconcile is aborted: + * 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} Null when the shortcut does not apply and * LOAD must rebuild the shot; otherwise whether the seek settled. */ - _bumpSceneSeekGeneration() { - this._loadAbort?.abort(); - this._loadAbort = null; - return ++this._sceneSeekGeneration; - } - async _seekLoadedShot( scene, seekState, @@ -1578,32 +1587,35 @@ export class SceneDirector { const previousEnabled = new Map( Object.keys(staleStates).map((id) => [ id, - Boolean(this.dataManager.getLayerLifecycleState(id)?.enabled), + 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 ( - layerResult.cancelled || - layerResult.refused.length || + if (this._seekAbort === controller) this._seekAbort = null; + const superseded = () => this._destroyed || - seekGeneration !== this._sceneSeekGeneration - ) { - if (!this._destroyed && !this._loadAbort && !this._running) { - for (const id of [...layerResult.applied].reverse()) { - if (this._destroyed || this._loadAbort || this._running) break; - await this.dataManager.setEnabled( - id, - previousEnabled.get(id) ?? false, - { origin: 'scene' }, - ); - } + 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; } @@ -1998,6 +2010,8 @@ export class SceneDirector { this._interactions?.clear(); this._dataPacks?.clear(); this._bumpSceneSeekGeneration(); + this._loadAbort?.abort(); + this._loadAbort = null; this._loadGeneration++; this._clock.stopShot(); this._cancelActiveSceneTravel(); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b2981a68b..fd8db6149 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -428,6 +428,77 @@ test('same-shot seeking aborts in-flight admission when superseded by an early-r } }); +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 { From d57bc0af3c803c0066aaa0995603336fd03476c4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:30:45 +0000 Subject: [PATCH 23/24] test(locator): give the Stop test double the seek-generation helper stopScene now supersedes seeks through _bumpSceneSeekGeneration; the bhoteKoshiLocator Stop test calls stopScene on a plain object, so it needs the method too. Without it four tests throw at the PR head. --- src/data/bhoteKoshiLocator.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 81235275c..605f8eaab 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { viewer, _sceneSeekGeneration: 0, _loadGeneration: 0, + _bumpSceneSeekGeneration: + SceneDirector.prototype._bumpSceneSeekGeneration, _cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel, _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, From 71574179a2fdfd632ae77d639c7b8579f236c1f6 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:35:01 +0000 Subject: [PATCH 24/24] test(qa): skip the seek visibility check outside a Vite dev server __gevQaRegisterLayer exists only in dev builds; against vite preview the check threw and the remaining director checks never ran. --- scripts/qa-director-timing.mjs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/qa-director-timing.mjs b/scripts/qa-director-timing.mjs index 63934140e..27f5705ff 100644 --- a/scripts/qa-director-timing.mjs +++ b/scripts/qa-director-timing.mjs @@ -89,6 +89,8 @@ try { }); 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, { @@ -140,10 +142,16 @@ try { await window.__gevQaUnregisterLayer(dataManager, layerId); } }); - check( - 'same-shot seek restores manually changed layer visibility', - visibilityRestored, - ); + 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 d = window.__godsEyeView.sceneDirector; const scene = d._project.scenes.find(({ title }) =>