From b01f1ee7dc51c20aa18624874bcbe4e8bfe2e87f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:16:00 +0400 Subject: [PATCH] Preserve radio playback and imagery comparisons when switching planets --- scripts/fixtures/w2/radio.mjs | 70 +++++++++++++++++++++ scripts/qa-radio-engine-switch.mjs | 95 +++++++++++++++++++++++++++++ scripts/qa-w2-imagery.mjs | 22 +++++++ src/app/constructCatalog.js | 6 +- src/layers/radio/lifecycle.js | 28 +++++++-- src/layers/radio/rendering.js | 10 ++- src/layers/recentImagery/index.js | 27 ++++++-- src/voice/gevActions.js | 5 +- src/voice/gevActionsEngine.test.mjs | 1 - 9 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 scripts/qa-radio-engine-switch.mjs diff --git a/scripts/fixtures/w2/radio.mjs b/scripts/fixtures/w2/radio.mjs index 41eef6e2e..aa0499398 100644 --- a/scripts/fixtures/w2/radio.mjs +++ b/scripts/fixtures/w2/radio.mjs @@ -36,3 +36,73 @@ export const stations = Array.from({ length: 750 }, (_, index) => { bitrate: 128, }; }); + +export async function installRadioFixture(page) { +await page.evaluateOnNewDocument((stations) => { + localStorage.clear(); + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + window.__qaRadio = { + stations, + generation: 1, + plays: [], + delay: 0, + response: 'normal', + pending: null, + }; + HTMLMediaElement.prototype.play = function () { + window.qaPlayer = this; + window.__qaRadio.plays.push(this.src); + if (window.__qaRadio.delayPlay) { + window.__qaRadio.delayPlay = false; + return new Promise((resolve) => { + window.__qaRadio.releasePlay = () => { + this.dispatchEvent(new Event('playing')); + resolve(); + }; + }); + } + queueMicrotask(() => this.dispatchEvent(new Event('playing'))); + return Promise.resolve(); + }; + const fetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const address = String( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + if (address.includes('/api/radio/stations')) { + const qa = window.__qaRadio; + qa.reads = (qa.reads || 0) + 1; + const response = { + stations: qa.response === 'empty' ? [] : qa.stations, + updatedAt: new Date().toISOString(), + stale: false, + degraded: false, + acceptedGeneration: qa.generation, + catalogInstance: 'w2-radio-fixture', + }; + const mode = qa.response; + const delay = qa.delay; + qa.delay = 0; + if (delay) + await new Promise((resolve) => { + qa.pending = resolve; + }); + init?.signal?.throwIfAborted(); + return new Response( + mode === 'failure' ? '{}' : JSON.stringify(response), + { + status: mode === 'failure' ? 503 : 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + if (address.includes('/api/radio/click/')) + return new Response(null, { status: 204 }); + if (address.includes('/api/flights')) return new Response('{"states":[]}'); + if (address.includes('/api/military')) return new Response('{"ac":[]}'); + if (address.includes('/api/vessels')) + return new Response('{"connected":false,"vessels":[]}'); + return fetch(input, init); + }; +}, stations); +} diff --git a/scripts/qa-radio-engine-switch.mjs b/scripts/qa-radio-engine-switch.mjs new file mode 100644 index 000000000..32b8b288a --- /dev/null +++ b/scripts/qa-radio-engine-switch.mjs @@ -0,0 +1,95 @@ +import puppeteer from 'puppeteer'; +import assert from 'node:assert/strict'; +import { stations, installRadioFixture } from './fixtures/w2/radio.mjs'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { switchGlobe } from './qa-switch-globe.mjs'; +const url = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw Error('Local keyless preview required'); +url.search = '?engine=navara&welcome=0'; +url.hash = 'v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=sentinel2&l='; +const browser = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const page = await browser.newPage(); + await blockPaidQaMaps(page); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await installRadioFixture(page); + await page.goto(url.href); + await page.waitForFunction( + () => + window.__godsEyeView?.engine?.kind === 'navara' && + document.querySelector('#loading-screen.hidden'), + ); + await page.evaluate(async (id) => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + await styleManager.initialRestorePromise; + if (window.__GOOGLE_MAPS_API_KEY__ || tileset) + throw Error('Expected keyless preview'); + window.qaLayer = dataManager.layers.get('radio').module; + await dataManager.setEnabled('radio', true, { origin: 'user' }); + }, stations[0].id); + await page.waitForFunction(() => window.qaLayer?.getStats().count === 750); + await page.evaluate((id) => { + window.qaLayer.selectStation(id, { + autoplay: true, + focus: false, + origin: 'user', + }); + window.qaLayer.setRadioVolume(0.36); + }, stations[10].id); + await page.waitForFunction( + () => + window.__qaRadio.plays.length === 1 && + window.qaLayer.getUIState().audioState === 'playing', + ); + const first = await page.evaluate(() => { + window.qaAudio = window.qaPlayer; + window.qaSnapshot = window.qaLayer.getAcceptedCatalogSnapshot(); + return window.__qaRadio.reads; + }); + for (const kind of ['cesium', 'navara']) { + await switchGlobe(page, kind); + const state = await page.evaluate(() => ({ + same: + window.qaLayer === + window.__godsEyeView.dataManager.layers.get('radio').module, + player: window.qaAudio === window.qaPlayer, + snapshot: + window.qaSnapshot === window.qaLayer.getAcceptedCatalogSnapshot(), + plays: window.__qaRadio.plays.length, + reads: window.__qaRadio.reads, + state: window.qaLayer.getUIState(), + count: window.qaLayer.getStats().count, + })); + console.log( + kind, + state.count, + state.state.selected?.id, + state.state.audioState, + state.plays, + state.reads, + ); + assert.ok(state.same && state.player && state.snapshot); + assert.equal(state.plays, 1); + assert.equal(state.reads, first); + assert.equal(state.count, 750); + assert.equal(state.state.audioState, 'playing'); + assert.equal(state.state.selected.id, stations[10].id); + assert.equal(state.state.volume, 0.36); + } + assert.deepEqual(errors, []); + console.log( + 'PASS one live radio player, one station directory and the same selection', + ); +} finally { + await browser.close(); +} diff --git a/scripts/qa-w2-imagery.mjs b/scripts/qa-w2-imagery.mjs index 55bf486c5..313c0814b 100644 --- a/scripts/qa-w2-imagery.mjs +++ b/scripts/qa-w2-imagery.mjs @@ -1,3 +1,4 @@ +import {switchGlobe} from './qa-switch-globe.mjs'; import fs from 'node:fs/promises'; import puppeteer from 'puppeteer'; import sharp from 'sharp'; @@ -220,6 +221,27 @@ try { await sleep(2500); image = await capture('ab'); check('A/B dates on opposite sides', red(image.left) && green(image.right)); + if (args.includes('--switch')) { + const first = await page.evaluate(() => { + window.qaImageryOwner = window.__gevRecentImagery.layer; + return window.qaImageryOwner.diagnostics().pins; + }); + const reads = () => report.requests.filter(r => r.host === 'cmr.earthdata.nasa.gov').length; + const expectedReads = reads(); + const initial = await page.evaluate(() => window.__godsEyeView.engine.kind); + for (const kind of [initial === 'navara' ? 'cesium' : 'navara', initial]) { + await switchGlobe(page, kind); + const state = await page.evaluate(() => ({ + same: window.__gevRecentImagery.layer === window.qaImageryOwner, + ...window.qaImageryOwner.diagnostics(), + })); + check(kind+' holds both original pins',state.same && state.mode === 'ab' && state.pins.a === first.a && state.pins.b === first.b); + await sleep(1800); + const pixels=await capture('switch-'+kind); + check(kind+' renders both pinned dates',red(pixels.left) && green(pixels.right)); + check(kind+' reuses the STAC search', reads() === expectedReads); + } + } await page.evaluate(() => window.__gevRecentImagery.layer.swapSides()); image = await capture('swap'); check('swap reverses dates', green(image.left) && red(image.right)); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 24a0baad5..ca7843be4 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -249,7 +249,7 @@ export function createApplicationCatalog({ ['cctv', () => createApplicationCctv({ surface, source: sources.cctv })], [ 'radio', - () => createApplicationRadio({ surface, source: sources.radio }), + () => sessionModel('radio', () => createApplicationRadio({ surface, source: sources.radio })), ], [ 'transit', @@ -266,9 +266,9 @@ export function createApplicationCatalog({ [ 'recent-imagery', () => - createApplicationRecentImagery({ + sessionModel('recent-imagery', () => createApplicationRecentImagery({ source: sources['recent-imagery'], - }), + })), ], ['ais-live-vessels', vessels], ['military-installations', installations], diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 4493a82c7..073e58c87 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -17,6 +17,15 @@ export function createLifecycle({ const methods = { /** Initialize scene resources and the single audio element. */ init(viewer, options = {}) { + this.attachRenderer(viewer, options); + layerState._sessionGeneration += 1; + parts.clustering.resetRadioClusterOverlayIdentities(); + parts.playback.installAudio(); + clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); + setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); + }, + + attachRenderer(viewer, options = {}) { if (parts.renderer?.ready) throw new Error('Radio layer is already initialized'); if (typeof rendererFactory !== 'function') @@ -29,11 +38,22 @@ export function createLifecycle({ engine: options.engine, }); parts.renderer.init(viewer, options); - layerState._sessionGeneration += 1; - parts.clustering.resetRadioClusterOverlayIdentities(); - parts.playback.installAudio(); + if (layerState._enabled) { + parts.rendering.drawStations(layerState._stations); + parts.interaction.syncRadioLifecyclePresentation(); + } + }, + + detachRenderer() { + parts.interaction.removeInteraction(); + clearTimeout(layerState._overlayPublishTimer); + layerState._overlayPublishTimer = null; + parts.renderer?.destroy(); + parts.renderer = null; + layerState._renderById.clear(); + layerState._selectedPosition = null; + layerState._lastHorizonCameraPosition = null; clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); - setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); }, /** Show stations. Enabling or preset restoration never starts audio. */ diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 4d5ec1148..e5b06b091 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -46,7 +46,7 @@ export function createRendering({ function publishRadioOverlayEntries() { layerState._overlayPublishTimer = null; - if (!parts.interaction.radioPresentationAllowed()) { + if (!parts.renderer?.ready || !parts.interaction.radioPresentationAllowed()) { parts.clustering.resetRadioClusterOverlayIdentities(); layerState._overlayDiagnostics = parts.clustering.emptyRadioOverlayDiagnostics(); @@ -257,7 +257,12 @@ export function createRendering({ layerState._selectedId = null; } - parts.renderer?.clearStations(); + drawStations(stations); + } + + function drawStations(stations) { + if (!parts.renderer?.ready) return; + parts.renderer.clearStations(); layerState._renderById.clear(); for (const station of stations) { const position = markerPosition(station); @@ -278,5 +283,6 @@ export function createRendering({ scheduleRadioOverlayPublish, updateRenderVisibility, reconcileStations, + drawStations, }; } diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index f1f8ddc96..3e7949588 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -808,15 +808,34 @@ export function createRecentImageryLayer({ source: 'NASA GIBS ยท HLS + VIIRS', updateInterval: 0, - init(viewer, { engine } = {}) { + init(viewer, options) { + return this.attachRenderer(viewer, options); + }, + + attachRenderer(viewer, { engine } = {}) { _viewer = viewer || null; _engine = engine || null; if (rendererFactory && !renderer) renderer = rendererFactory({ viewer, engine }); if (!renderer) throw new TypeError('Recent imagery requires a renderer'); + if (_enabled) syncRender(); return true; }, + async detachRenderer() { + _unsubscribeMap?.(); + _unsubscribeMap = null; + _mapStackController = null; + const lease = _lease; + forgetLease(); + renderer?.destroy(); + renderer = null; + _hostInfo = {collection: null, kind: 'none'}; + _viewer = _engine = _tileset = null; + _keptGeneration = null; + await Promise.all([lease?.release(), _releasing]); + }, + enable(viewer) { if (_destroyed) return false; if (viewer) _viewer = viewer; @@ -836,7 +855,7 @@ export function createRecentImageryLayer({ applyPreview(_followKey); // A pinned or previewed day that was still being probed drapes now. const shown = plan(); - const owned = renderer.getOwned?.() || {}; + const owned = renderer?.getOwned?.() || {}; const drapesChanged = SLOT_IDS.some( (slotId) => (owned[slotId]?.key || null) !== shown[slotId], ); @@ -1225,7 +1244,7 @@ export function createRecentImageryLayer({ const next = clampUnit(value, _alpha); if (next === _alpha) return; _alpha = next; - for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, next); + for (const slotId of SLOT_IDS) renderer?.setAlpha(slotId, next); notify('alpha'); }, @@ -1253,7 +1272,7 @@ export function createRecentImageryLayer({ _swapped = false; if (_alpha !== 1) { _alpha = 1; - for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1); + for (const slotId of SLOT_IDS) renderer?.setAlpha(slotId, 1); } _notice = msg('Box and images cleared'); syncRender(); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9dc2543c4..d999018cd 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -228,9 +228,7 @@ const FRAME_TARGETS = new Map([ /** * The tools that still need the Cesium viewer or a subsystem built on it: - * the scene director, annotations and routes over them, the camera verbs - * (orbit, pan, tilt), Contacts (the context mode), Radio and - * CCTV. Without a viewer (the Navara engine) each answers a structured + * annotations and routes over them, camera verbs, the context mode and CCTV. Without a viewer (the Navara engine) each answers a structured * refusal (UNAVAILABLE_ON_ENGINE) instead of running. */ export const VIEWER_ONLY_ACTIONS = Object.freeze([ @@ -239,7 +237,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([ 'fly_route', 'move_camera', 'set_context_mode', - 'control_radio', 'control_cctv', ]); /** The refusal code of a viewer-only tool on an engine without a viewer. */ diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index 572b13be1..0777bd76c 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -94,7 +94,6 @@ test('the viewer-only actions are one exported list of real tools', () => { 'fly_route', 'move_camera', 'set_context_mode', - 'control_radio', 'control_cctv', ]); assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));