Preserve radio playback and imagery comparisons when switching planets

This commit is contained in:
German Khokhlov committed 2026-11-03 04:16:00 +04:00
1 parent b98cbeebf2
commit b01f1ee7dc
9 files changed
+246 -18

No files matched your search

+70
View File
@@ -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);
}
+95
View File
@@ -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();
}
+22
View File
@@ -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));
+3 -3
View File
@@ -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],
+24 -4
View File
@@ -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. */
+8 -2
View File
@@ -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,
};
}
+23 -4
View File
@@ -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();
+1 -4
View File
@@ -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. */
-1
View File
@@ -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));