From 39206ef4c3449344aba777c18af6cc334de76aad Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 18:29:03 +0400 Subject: [PATCH] Keep Space Missions and the cockpit through an engine switch Retaining a disabled layer that was never initialised replayed its parameters, and the change notice it sent restored the Context snapshot, which ended the Space Missions mode on every switch. Such layers are retained as they are again. The tracked aircraft's handle is rebuilt only once the view is restored, so the share restore's earlier trackById found nothing to follow and the cockpit did not come back after a failed switch rolled back. The scene switcher now takes the aircraft up again after the camera owners resume and then restores the cockpit and its vision mode. Both are covered by tests on the real session modules. --- src/data/layerStateCoordinator.js | 3 +- src/standalone/sceneSwitcher.js | 5 + src/standalone/sceneSwitcher.test.mjs | 1 + src/standalone/sessionSwitch.test.mjs | 296 ++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 src/standalone/sessionSwitch.test.mjs diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 7fd5f2f7f..842babf39 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -446,10 +446,11 @@ export class LayerStateCoordinator { // The share still owns durable preferences and later history visits: // on a visit, the entry's options that differ from the live layer // apply; a pick keeps the live layer as it is. + // Disabled models are retained before their first initialization too: + // replaying their params would notify and dismantle an active Context. if ( live && retained?.module.attachRenderer && - retained.initialized && !retained.lifecycleUncertain && retained.enabled === targetEnabled ) { diff --git a/src/standalone/sceneSwitcher.js b/src/standalone/sceneSwitcher.js index bbc1d72d4..adc3b7f24 100644 --- a/src/standalone/sceneSwitcher.js +++ b/src/standalone/sceneSwitcher.js @@ -62,12 +62,17 @@ export function createSceneSwitcher({ count('attachments'); await resumeSession(layerSession); // A visited entry or a stale track may replace the outgoing subject. + // Cockpit released Follow; reacquire its rebuilt target after camera owners + // resume, since share restoration may have run before that target existed. const target = style.getAircraftTrackingTarget(); if ( cockpit?.active && target && target.id === cockpit.subject?.id && target.layerId === cockpit.subject?.layerId && + layerSession.manager.layers + .get(target.layerId) + ?.module.trackById(target.id, { origin: 'share-restore' }) && style.controlCockpit('enter').ok ) { style.cockpitView.setVisionMode(cockpit.visionMode); diff --git a/src/standalone/sceneSwitcher.test.mjs b/src/standalone/sceneSwitcher.test.mjs index 73cc05af9..a97d2807e 100644 --- a/src/standalone/sceneSwitcher.test.mjs +++ b/src/standalone/sceneSwitcher.test.mjs @@ -32,6 +32,7 @@ function fixture({ failResume = false, changeTarget = false } = {}) { const entry = { module: { attachRenderer() {}, + trackById: () => true, resumeViewport() { log.push('viewport'); }, diff --git a/src/standalone/sessionSwitch.test.mjs b/src/standalone/sessionSwitch.test.mjs new file mode 100644 index 000000000..72d65173e --- /dev/null +++ b/src/standalone/sessionSwitch.test.mjs @@ -0,0 +1,296 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLayerSession } from '../app/layerSession.js'; +import { createApplicationCatalog } from '../app/constructCatalog.js'; +import { createSurfaceServices } from '../app/surfaceServices.js'; +import { createQaSources } from '../app/layers/qaSources.js'; +import { loadLayerRenderers, rendererFor } from '../app/layers/renderers.js'; +import { aircraftAtlasLayout } from '../layers/aircraft/render/display.js'; +import { SATELLITE_FIXTURE_EPOCH } from '../bench/satelliteFixture.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; +import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; +import { sceneServicesFor } from '../data/sceneServices.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { ContextControls } from '../ui/contextControls.js'; +import { CockpitCoordinator } from '../ui/cockpitCoordinator.js'; +import { + installLifecycleEnvironment, + flushLifecycleTasks, +} from '../testSupport/lifecycleEnvironment.mjs'; +import { createEngineAddress } from './engineSwitch.js'; +import { createSceneSwitcher } from './sceneSwitcher.js'; + +async function fixture(t, { aircraft = null, failOpen = false } = {}) { + let cleanup = async () => {}; + t.after(() => cleanup()); + const { window } = installLifecycleEnvironment(t); + t.mock.timers.setTime(SATELLITE_FIXTURE_EPOCH); + const previous = { + HTMLElement: globalThis.HTMLElement, + EventTarget: globalThis.EventTarget, + }; + globalThis.HTMLElement = window.HTMLElement; + globalThis.EventTarget = window.EventTarget; + t.after(() => { + Object.assign(globalThis, previous); + }); + await loadLayerRenderers('navara'); + await loadLayerRenderers('cesium'); + const session = createLayerSession(); + const fullCatalog = createApplicationCatalog({ + layerSession: session, + signal: session.lifetime.signal, + sources: createQaSources([ + { layer: 'launches' }, + ...(aircraft ? [{ layer: aircraft, count: 1 }] : []), + ]), + surface: createSurfaceServices({ + terrainSource: { getHeights: async () => [] }, + signal: session.lifetime.signal, + eventTarget: null, + }), + createDisplay(id, engine, options) { + const layout = aircraftAtlasLayout(); + return rendererFor(id, engine, { + ...options, + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + }); + }, + }); + const ids = [ + 'flights', + 'military', + 'satellites', + 'rocket-launches', + 'military-awareness', + ]; + const catalog = { + ...fullCatalog, + layers: fullCatalog.layers.filter(({ id }) => ids.includes(id)), + metadata: fullCatalog.metadata.filter(({ id }) => ids.includes(id)), + }; + const engines = []; + const newEngine = (kind) => { + const engine = createFakeEngine({ + kind, + container: document.createElement('div'), + canvas: document.createElement('canvas'), + }); + engines.push(engine); + return engine; + }; + let engine = newEngine('navara'); + const manager = new LayerLifecycle(null); + session.manager = manager; + await manager.bindCatalog(engine, catalog); + for (const layer of catalog.layers) layer.attachDataManager?.(manager); + const context = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => cockpit?.cockpitView, + claimVisualAuthority() {}, + showToast() {}, + setClearBusy() {}, + syncDetection() {}, + scheduleLayout() {}, + refreshRadio() {}, + setPanelCollapsed() {}, + }, + }); + let cockpit = null; + context.connect(manager); + const buildCockpit = () => + new CockpitCoordinator({ + engine, + services: { + ...sceneServicesFor(engine).render, + flightsLayer: catalog.get('flights'), + militaryFlightsLayer: catalog.get('military'), + isTr3b: () => false, + regionalDistanceM: () => 0, + fetchRegionalBrief: async () => ({}), + }, + elements: {}, + operations: { + getAircraftTrackingTarget: () => { + const info = aircraft && catalog.get(aircraft).getTrackedInfo(); + return info ? { id: info.icao24, layerId: aircraft } : null; + }, + _stampNavigation() {}, + _setCockpitVision() {}, + }, + readDataManager: () => manager, + readContext: () => context.getContextModeState(), + readActiveStyle: () => 'normal', + enterPanels() {}, + exitPanels() {}, + }); + cockpit = buildCockpit(); + let addressState = { enabledLayerIds: [], options: {} }; + const style = { + initialRestorePromise: Promise.resolve(), + getCockpitState: () => cockpit.getCockpitState(), + getAircraftTrackingTarget: () => cockpit.getAircraftTrackingTarget(), + controlCockpit: (...args) => cockpit.controlCockpit(...args), + get cockpitView() { + return cockpit.cockpitView; + }, + shareLinkManager: { currentLayerState: () => addressState }, + }; + let coordinator; + const app = { + getState: () => ({ status: 'ready' }), + getComponents: () => ({ + scene: { engine }, + controls: { styleManager: style }, + }), + async detachScene() { + cockpit.stop(); + cockpit.destroy(); + coordinator?.destroy(); + await manager.detachScene(); + engine = null; + }, + async attachScene(kind, { resume }) { + if (failOpen && kind === 'cesium') + throw new Error('WebGL context refused'); + engine = newEngine(kind); + await manager.bindCatalog(engine, catalog, { + viewRestored: session.viewRestored, + }); + cockpit = buildCockpit(); + coordinator = new LayerStateCoordinator(manager, null, { storage: null }); + style.initialRestorePromise = coordinator + .start({ + shareLayerState: addressState, + handoff: session.handoff, + }) + .then(() => { + // Share restoration requests Follow before releasing the view gate. + const target = coordinator.retainedTrackingTarget(); + if (target) coordinator.resumeRetainedTracking(target); + }); + await resume(); + }, + }; + window.history.replaceState(null, '', '?engine=navara'); + const switcher = createSceneSwitcher({ + kind: 'navara', + requested: () => new URL(window.location.href).searchParams.get('engine'), + layerSession: session, + getApplication: () => app, + renderers: { retain() {} }, + address: createEngineAddress({ + location: window.location, + history: window.history, + }), + }); + cleanup = async () => { + context.stop(); + context.disconnect(); + cockpit.destroy(); + coordinator?.destroy(); + session.lifetime.abort(); + await manager.destroyAll(); + for (const engine of engines) { + sceneServicesFor(engine).destroy(); + engine.destroy(); + } + }; + return { + session, + manager, + catalog, + context, + style, + switcher, + window, + engine: () => engine, + address: (state) => { + addressState = state; + }, + }; +} + +test('Space Missions keeps its context, selection and paused replay across engines', async (t) => { + const f = await fixture(t); + assert.equal((await f.context.setContextMode('space-missions')).ok, true); + const launches = f.catalog.get('rocket-launches'); + launches.selectMissionAt(0); + launches.setReplaySpeed(4); + launches.startSelectedReplay(); + f.engine().camera.completeFlight(); + await flushLifecycleTasks(t, 20_000); + f.engine().fake.frame(); + launches.toggleReplayPause(); + const before = launches.qaDrawn(); + const stamp = launches.getStats().lastUpdate; + assert.ok(before.paused && before.replay.active); + assert.ok(before.replay.phaseProgress > 0); + f.address({ + enabledLayerIds: ['rocket-launches', 'satellites'], + options: {}, + }); + const snapshot = f.context.captureScene().snapshot; + for (const kind of ['cesium', 'navara']) { + await f.switcher.select(kind); + await f.context._waitForContextLayerSettlement(); + assert.equal(f.context.getContextModeState().mode, 'space-missions'); + assert.equal(f.context.captureScene().snapshot, snapshot); + const after = launches.qaDrawn(); + assert.equal(after.selected, before.selected); + assert.ok(after.paused && after.replay.active); + assert.equal(after.speed, 4); + assert.equal(after.replay.phaseProgress, before.replay.phaseProgress); + assert.equal(launches.getStats().lastUpdate, stamp); + } +}); + +for (const aircraft of ['flights', 'military']) { + test(`failed engine startup restores the ${aircraft} cockpit and session without history`, async (t) => { + const f = await fixture(t, { aircraft, failOpen: true }); + const layer = f.catalog.get(aircraft); + await f.manager.setEnabled(aircraft, true); + await flushLifecycleTasks(t); + const id = layer.getAnalystRecords()[0].icao24; + assert.equal(layer.trackById(id), true); + const entered = f.style.controlCockpit('enter'); + assert.equal(entered.ok, true, entered.error); + f.style.cockpitView.setVisionMode('thermal'); + f.address({ + enabledLayerIds: [aircraft], + options: { + flights: { + [aircraft === 'flights' + ? 'selectedFlightsTrackingId' + : 'selectedMilitaryTrackingId']: id, + }, + }, + }); + const manager = f.manager; + const entry = manager.layers.get(aircraft); + const length = f.window.history.length; + await f.switcher.select('cesium'); + assert.equal(f.engine().kind, 'navara'); + assert.equal( + new URL(f.window.location.href).searchParams.get('engine'), + 'navara', + ); + assert.equal(f.window.history.length, length); + assert.equal(f.session.manager, manager); + assert.equal(manager.layers.get(aircraft), entry); + assert.equal(f.style.getAircraftTrackingTarget()?.id, id); + assert.equal(f.style.getCockpitState().active, true); + assert.equal(f.style.getCockpitState().visionMode, 'thermal'); + assert.equal(f.style.getCockpitState().subject.id, id); + assert.equal( + f.style.cockpitView.trackedEntity.gevSelectionOrigin, + 'share-restore', + ); + assert.equal(f.session.handoff, null); + }); +}