Rehearse every catalog layer across two engines in the unit tests

lifecycleRehearsal.test.mjs takes each of the 38 catalog layers from
attach on one fake engine through enable, detach and that engine's
destruction to attach on a second engine, resume and disable. It checks
that the engine left keeps no drawn sets, listeners, credits, input
suspensions, continuous renders or follows; that nothing writes to it
afterwards; that a layer waiting for the camera restore reads no source
before resumeViewport; that a disabled layer stays hidden after attach;
and which layers still read the viewer argument, a list that may only
shrink.

The catalog takes the displays and scene services a test supplies, and
the pick registry lists its owners. Two defects it found are fixed:
Active Fires loaded its heatmap before the camera restore it was told to
wait for, and the wind inspection marker asked Cesium's scene for a
render on any engine instead of the engine's render request.
This commit is contained in:
German Khokhlov committed 2026-11-10 15:01:59 +04:00
1 parent 9c2a2d6951
commit 20ad9b41b3
6 files changed
+621 -15

No files matched your search

+54 -13
View File
@@ -73,6 +73,8 @@ export function createApplicationCatalog({
nepalBoundaryResolver,
streetLevelProviders,
engineKind = 'cesium',
// Supply display options and scene services without replacing layer models.
createDisplay = rendererFor,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -80,6 +82,13 @@ export function createApplicationCatalog({
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
const renderer = (id) => ({
create: ({ engine, ...options }) => createDisplay(id, engine, options),
});
const rendererFactory =
(id) =>
({ engine, ...options }) =>
createDisplay(id, engine, options);
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry =
@@ -122,6 +131,7 @@ export function createApplicationCatalog({
};
const flights = sessionModel('flights', () =>
createApplicationFlights({
renderer: renderer('flights'),
surface,
source: sources.flights,
militaryRegistry,
@@ -130,6 +140,7 @@ export function createApplicationCatalog({
);
const military = sessionModel('military', () =>
createApplicationMilitary({
renderer: renderer('military'),
surface,
source: sources.military,
militaryRegistry,
@@ -138,6 +149,7 @@ export function createApplicationCatalog({
);
const vessels = sessionModel('ais-live-vessels', () =>
createApplicationVessels({
renderer: renderer('ais-live-vessels'),
source: sources.vessels,
options: vesselOptions,
}),
@@ -150,6 +162,7 @@ export function createApplicationCatalog({
);
const satellites = sessionModel('satellites', () =>
createApplicationSatellites({
renderer: renderer('satellites'),
source: sources.satellites,
}),
);
@@ -157,20 +170,21 @@ export function createApplicationCatalog({
sessionModel('bhote-koshi-2026', () =>
createBhoteKoshiEventLayer({
createNativeRenderer: (engine) =>
rendererFor('bhote-koshi-2026', engine),
createDisplay('bhote-koshi-2026', engine),
}),
),
sessionModel('bhote-koshi-locator', () =>
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
createNativeRenderer: (engine, data) =>
rendererFor('bhote-koshi-locator', engine, { data }),
createDisplay('bhote-koshi-locator', engine, { data }),
}),
),
flights,
military,
sessionModel('local-adsb', () =>
createApplicationLocalAdsb({
renderer: renderer('local-adsb'),
surface,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}),
@@ -180,18 +194,26 @@ export function createApplicationCatalog({
}),
),
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationEarthquakes({
rendererFactory: rendererFactory('earthquakes'),
source: sources.earthquakes,
}),
),
sessionModel('air-quality', () =>
createApplicationAirQuality({ source: sources['air-quality'] }),
createApplicationAirQuality({
rendererFactory: rendererFactory('air-quality'),
source: sources['air-quality'],
}),
),
sessionModel('fire-perimeters', () =>
createApplicationFirePerimeters({
createRenderer: rendererFactory('fire-perimeters'),
source: sources['fire-perimeters'],
}),
),
sessionModel('gnss-integrity', () =>
createApplicationGnssIntegrity({
rendererFactory: rendererFactory('gnss-integrity'),
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
@@ -201,6 +223,7 @@ export function createApplicationCatalog({
),
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
rendererFactory: rendererFactory('drone-airspace'),
source: sources['drone-airspace'],
}),
),
@@ -211,12 +234,16 @@ export function createApplicationCatalog({
}),
),
sessionModel('alpr-cameras', () =>
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationAlpr({
rendererFactory: rendererFactory('alpr-cameras'),
surface,
source: sources.alpr,
}),
),
sessionModel('street-level', () =>
createApplicationStreetLevel({
surface,
nativeRenderer: (engine) => rendererFor('street-level', engine),
nativeRenderer: (engine) => createDisplay('street-level', engine),
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
@@ -224,6 +251,7 @@ export function createApplicationCatalog({
satellites,
sessionModel('rocket-launches', () =>
createApplicationLaunches({
renderer: renderer('rocket-launches'),
source: sources.launches,
satellites: satellites,
}),
@@ -235,13 +263,25 @@ export function createApplicationCatalog({
}),
),
sessionModel('cctv', () =>
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationCctv({
rendererFactory: rendererFactory('cctv'),
surface,
source: sources.cctv,
}),
),
sessionModel('radio', () =>
createApplicationRadio({ surface, source: sources.radio }),
createApplicationRadio({
rendererFactory: rendererFactory('radio'),
surface,
source: sources.radio,
}),
),
sessionModel('transit', () =>
createApplicationTransit({ surface, source: sources.transit }),
createApplicationTransit({
rendererFactory: rendererFactory('transit'),
surface,
source: sources.transit,
}),
),
sessionModel('bikeshare', () =>
createApplicationBikeshare({ source: sources.bikeshare }),
@@ -273,7 +313,7 @@ export function createApplicationCatalog({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
createDisplay('wind', engine, options),
}),
),
...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) =>
@@ -283,7 +323,7 @@ export function createApplicationCatalog({
id,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor(id, engine, options),
createDisplay(id, engine, options),
}),
),
),
@@ -291,7 +331,7 @@ export function createApplicationCatalog({
createApplicationCyclones({
feed: sources.cyclones,
createNativeRendering: (engine, options) =>
rendererFor('weather-cyclones', engine, options),
createDisplay('weather-cyclones', engine, options),
}),
),
...sessionModel('osm-overlays', () => createOsmOverlayLayers()),
@@ -301,7 +341,7 @@ export function createApplicationCatalog({
config,
services,
(engine, descriptor, ops) =>
rendererFor(config.id, engine, {
createDisplay(config.id, engine, {
config: descriptor,
services: ops,
}),
@@ -313,6 +353,7 @@ export function createApplicationCatalog({
),
sessionModel('local-firms', () =>
createApplicationFirms({
renderer: renderer('local-firms'),
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
+433
View File
@@ -0,0 +1,433 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationCatalog } from '../constructCatalog.js';
import { createSurfaceServices } from '../surfaceServices.js';
import { CATALOG_SOURCE_CONTRACTS } from '../sourceComposition.js';
import { loadLayerRenderers, rendererFor } from './renderers.js';
import {
BHOTE_KOSHI_CREDIT,
BHOTE_KOSHI_LOCATOR_CREDIT,
transitFeedCredit,
} from '../../data/dataCredits.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { sceneServicesFor } from '../../data/sceneServices.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { aircraftAtlasLayout } from '../../layers/aircraft/render/display.js';
import { vesselAtlasLayout } from '../../layers/vessels/render/display.js';
import { transitTestAtlas } from '../../layers/transit/render/testAtlas.mjs';
import {
installLifecycleEnvironment,
flushLifecycleTasks,
} from '../../testSupport/lifecycleEnvironment.mjs';
const retainedCredits = {
'bhote-koshi-2026': BHOTE_KOSHI_CREDIT, // Conditional attribution is permanent in the scene's credit popover.
transit: transitFeedCredit(
TRANSIT_ENABLED_FEEDS.find((feed) => feed.id === 'capmetro-austin'),
), // Transit feed attribution is permanent in the scene's credit popover.
'bhote-koshi-locator': BHOTE_KOSHI_LOCATOR_CREDIT, // Conditional attribution is permanent in the scene's credit popover.
};
const primitives = ['movers', 'lines', 'ground', 'imagery', 'meshes'];
const expectedReaders = {
'bhote-koshi-2026': ['scene'], // The imagery split control still reads the native scene.
'ais-live-vessels': ['isDestroyed'], // Sprite ordering still accepts the native viewer.
};
function sourceFixtures(calls) {
return Object.fromEntries(
Object.entries(CATALOG_SOURCE_CONTRACTS).map(([id, { methods }]) => [
id,
{
label: id,
...Object.fromEntries(
methods.map((method) => [
method,
() => {
calls.push({ id, method });
if (method === 'getFlowSessionStats') return {};
if (method === 'getStatus')
return Promise.resolve({ hasKey: false });
if (id === 'wildlife-sightings') return Promise.resolve([]);
if (method === 'requestSnapshot')
return Promise.resolve({
ok: true,
headers: new Headers(),
json: async () => ({ vehicles: [], fetchedAt: Date.now() }),
});
if (method === 'requestRoads')
return Promise.resolve({
ok: true,
headers: new Headers(),
json: async () => ({ roads: [], roadMode: 'osm' }),
});
if (method === 'getStations')
return Promise.resolve({ data: { stations: [] } });
if (method === 'resetFlowTileCache') return;
if (method === 'fetch') return Promise.resolve([]);
if (method === 'getSnapshot')
return Promise.resolve({
records: [],
features: [],
rows: [],
unavailable: true,
});
return Promise.resolve([]);
},
]),
),
},
]),
);
}
function strictEngine() {
const engine = createFakeEngine({
kind: 'rehearsal',
longitude: (-97.74 * Math.PI) / 180,
latitude: (30.27 * Math.PI) / 180,
altitude: 5000,
container: document.createElement('div'),
canvas: document.createElement('canvas'),
});
engine.extensions = {};
engine.fake.script('sampleHeight', 0);
engine.fake.script('terrainHeight', 0);
const violations = [];
const inputs = new Set();
const suspensions = new Set();
const suspend = engine.input.suspendCameraInputs;
engine.input.suspendCameraInputs = () => {
const resume = suspend();
const token = {};
suspensions.add(token);
return () => {
suspensions.delete(token);
resume();
};
};
const on = engine.input.on;
engine.input.on = (...args) => {
const off = on(...args);
const token = {};
inputs.add(token);
return () => {
inputs.delete(token);
off();
};
};
for (const family of primitives) {
for (const [name, create] of Object.entries(engine[family])) {
if (!name.startsWith('create')) continue;
engine[family][name] = (...args) => {
if (engine.isDestroyed()) {
const message = `${family}.${name} on destroyed engine`;
violations.push(message);
throw new Error(message);
}
const handle = create(...args);
let removed = false;
const destroy = handle.destroy;
handle.destroy = () => {
removed = true;
return destroy();
};
for (const method of Object.keys(handle).filter((name) =>
/^(set|update|replace|commit|reveal|show)/.test(name),
)) {
const write = handle[method];
handle[method] = (...values) => {
if (
(removed || engine.isDestroyed()) &&
(method !== 'show' || values.length)
) {
const message = `${family} ${handle.id}.${method} after removal`;
violations.push(message);
throw new Error(message);
}
return write(...values);
};
}
return handle;
};
}
}
const services = sceneServicesFor(engine);
services.render.install();
const resources = () => ({
lists: Object.fromEntries(
primitives.map((name) => [name, engine[name].list()]),
),
listeners: [engine.camera, engine.frames, engine.clock].flatMap((part) =>
Object.values(part)
.filter((value) => typeof value?.addEventListener === 'function')
.map((event) => event.numberOfListeners),
),
inputs: inputs.size,
credits: [...engine.fake.credits],
cameraInputs: engine.input.cameraInputsEnabled(),
suspensions: suspensions.size,
continuous: engine.render.isContinuous(),
holds: services.render.getRenderGovernorDiagnostics().holds,
following: engine.camera.following(),
owners: services.picking.owners(),
});
const snapshot = () => ({
calls: [...engine.fake.calls],
renderRequests: engine.fake.renderRequests,
resources: resources(),
violations: [...violations],
});
return { engine, services, resources, snapshot, violations };
}
function assertHidden(engine) {
for (const { id } of engine.movers.list()) {
const { store } = engine.fake.moverSet(id);
assert.ok(
[...store.shows.slice(0, store.count)].every((shown) => !shown),
`disabled movers ${id} must be hidden`,
);
}
for (const family of ['lines', 'ground', 'imagery']) {
for (const item of engine[family].list()) {
if (item.count === 0) continue;
assert.equal(
item.shown,
false,
`disabled ${family} ${item.id} must be hidden`,
);
}
}
for (const id of engine.meshes.list())
assert.equal(
engine.fake.mesh(id).show,
false,
`disabled mesh ${id} must be hidden`,
);
}
test('every catalog layer releases its scene and attaches to another engine', async (t) => {
await loadLayerRenderers('rehearsal');
const buildCatalog = (lifetime, calls, visibility) =>
createApplicationCatalog({
signal: lifetime.signal,
sources: sourceFixtures(calls),
surface: createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal: lifetime.signal,
eventTarget: null,
}),
streetLevelProviders: [
createMapillaryProvider({
source: {
hasToken: () => true,
getStatus: async () => {
calls.push({ id: 'mapillary', method: 'getStatus' });
return { configured: true };
},
getTile: async () => {
calls.push({ id: 'mapillary', method: 'getTile' });
return new Uint8Array(0);
},
getSequenceImages: async () => {
calls.push({ id: 'mapillary', method: 'getSequenceImages' });
return [];
},
},
}),
],
nepalBoundaryResolver: async () => [],
createDisplay(id, engine, options = {}) {
const services = sceneServicesFor(engine);
const layout =
id === 'ais-live-vessels'
? vesselAtlasLayout()
: aircraftAtlasLayout();
const display = rendererFor(id, engine, {
...options,
services: {
...options.services,
picking: services.picking,
render: services.render,
},
drawAtlas:
id === 'transit'
? transitTestAtlas
: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
});
if (id === 'cctv') {
visibility.set(engine, true);
const setVisible = display.setMarkersVisible;
display.setMarkersVisible = (value) => {
visibility.set(engine, value);
return setVisible(value);
};
}
return display;
},
});
const discoveryLifetime = new AbortController();
const discovery = buildCatalog(discoveryLifetime, [], new Map());
t.after(() => discoveryLifetime.abort());
const seenReaders = [];
const seenCredits = new Set();
for (const { id } of discovery.layers) {
await t.test(id, async (t) => {
const environment = installLifecycleEnvironment(t);
const lifetime = new AbortController();
const calls = [];
const visibility = new Map();
const catalog = buildCatalog(lifetime, calls, visibility);
const layer = catalog.get(id);
const a = strictEngine();
const b = strictEngine();
const beforeA = a.resources();
const beforeB = b.resources();
const reads = new Set();
const poisoned = new Proxy(
{},
{
get(_target, property) {
reads.add(String(property));
throw new Error(`viewer read: ${String(property)}`);
},
},
);
const drive = async (scene) => {
scene.engine.fake.emit('camera.moveStart');
scene.engine.fake.emit('camera.changed');
scene.engine.fake.frame();
scene.engine.fake.emit('camera.moveEnd');
window.dispatchEvent(new CustomEvent('gev:map-stack-changed'));
await flushLifecycleTasks(t, 1000);
};
try {
if (
[
'flights',
'military',
'earthquakes',
'air-quality',
'fire-perimeters',
'gnss-integrity',
'drone-airspace',
'osm-places',
].includes(layer.id)
)
await layer.init(null);
const attach = async (engine, options = {}) => {
try {
await layer.attachRenderer(poisoned, { engine, ...options });
} catch (error) {
if (
!expectedReaders[layer.id]?.includes([...reads].at(-1)) ||
!error.message.startsWith('viewer read:')
)
throw error;
await layer.detachRenderer();
await layer.attachRenderer(null, { engine, ...options });
}
};
await attach(a.engine);
await flushLifecycleTasks(t);
if (layer.id === 'cctv')
assert.equal(
visibility.get(a.engine),
false,
'disabled CCTV markers must be hidden after attach',
);
assertHidden(a.engine);
await layer.enable();
await drive(a);
await layer.detachRenderer();
if (retainedCredits[id]) {
beforeA.credits = [
{ html: retainedCredits[id].html, onScreen: false },
];
seenCredits.add(id);
}
assert.deepEqual(
a.resources(),
beforeA,
'A resources released before engine destruction',
);
const detached = a.snapshot();
await flushLifecycleTasks(t, 60_000);
assert.deepEqual(a.snapshot(), detached, 'no writes to detached A');
a.engine.destroy();
const destroyed = a.snapshot();
const sourceCount = calls.length;
const networkCount = environment.network.length;
await attach(b.engine, { waitForCameraRestore: true });
await drive(b);
if (layer.resumeViewport) {
assert.deepEqual(
calls.slice(sourceCount),
[],
'no source requests before resumeViewport',
);
assert.deepEqual(
environment.network.slice(networkCount),
[],
'no transport requests before resumeViewport',
);
}
await layer.resumeViewport?.();
await drive(b);
if (id === 'local-firms')
assert.ok(
calls.slice(sourceCount).some((call) => call.id === 'firms'),
'empty FIRMS cache is fetched after camera restoration',
);
await layer.disable();
await drive(b);
assertHidden(b.engine);
await layer.detachRenderer();
if (retainedCredits[id])
beforeB.credits = [
{ html: retainedCredits[id].html, onScreen: false },
];
assert.deepEqual(b.resources(), beforeB, 'B resources released');
const detachedB = b.snapshot();
await flushLifecycleTasks(t, 60_000);
assert.deepEqual(b.snapshot(), detachedB, 'no writes to detached B');
await layer.destroy?.();
assert.deepEqual(
a.snapshot(),
destroyed,
'no writes to destroyed A while driving B',
);
assert.deepEqual(a.violations, []);
assert.deepEqual(b.violations, []);
assert.deepEqual(
[...reads].sort(),
expectedReaders[layer.id] ?? [],
'viewer reader table must only shrink',
);
if (reads.size) seenReaders.push(id);
} finally {
lifetime.abort();
for (const entry of catalog.layers) await entry.destroy?.();
a.services.destroy();
b.services.destroy();
if (!a.engine.isDestroyed()) a.engine.destroy();
b.engine.destroy();
}
});
}
assert.deepEqual(
seenReaders.sort(),
Object.keys(expectedReaders).sort(),
'remove stale viewer reader entries',
);
assert.deepEqual(
[...seenCredits].sort(),
Object.keys(retainedCredits).sort(),
'remove stale retained-credit entries',
);
});
+1
View File
@@ -150,6 +150,7 @@ export function createPickRegistry() {
return {
resolvePickId,
owners: () => [..._owners.keys()],
registerPickOwner,
unregisterPickOwner,
findPickOwner,
+6 -1
View File
@@ -46,6 +46,7 @@ export function createLifecycle({
// Enabled before a renderer: the load enable() could not start.
if (
layerState._enabled &&
!waitForCameraRestore &&
!layerState._fires.length &&
!layerState._loading
)
@@ -55,7 +56,11 @@ export function createLifecycle({
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._renderer) this.showRenderer();
if (layerState._enabled && layerState._renderer) {
this.showRenderer();
if (!layerState._fires.length && !layerState._loading)
void components.ingestion.loadHeatmap();
}
},
detachRenderer() {
+2 -1
View File
@@ -28,7 +28,8 @@ export function createWindInspectionMarker({
removeRender = null;
if (shown()) store.publish(null);
marker = null;
viewer?.scene?.requestRender?.();
if (engine) engine.render.request('wind-inspection');
else viewer?.scene?.requestRender?.();
};
return {
show(position) {
+125
View File
@@ -0,0 +1,125 @@
import { readFile } from 'node:fs/promises';
import { Window } from 'happy-dom';
export function installLifecycleEnvironment(t) {
const window = new Window({ url: 'http://localhost/' });
const network = [];
for (const name of [
'window',
'document',
'localStorage',
'CustomEvent',
'ResizeObserver',
'Image',
'Audio',
'createImageBitmap',
]) {
const previous = Object.getOwnPropertyDescriptor(globalThis, name);
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value: name === 'window' ? window : window[name],
});
t.after(() => {
if (previous) Object.defineProperty(globalThis, name, previous);
else delete globalThis[name];
});
}
t.mock.timers.enable({
apis: ['Date', 'setTimeout', 'setInterval'],
now: Date.UTC(2026, 0, 1),
});
t.mock.method(globalThis, 'fetch', async (url) => {
network.push(String(url));
const body = String(url).endsWith('/event.json')
? await readFile(
new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
),
'utf8',
)
: JSON.stringify({ type: 'FeatureCollection', features: [] });
return new Response(body, {
headers: { 'content-type': 'application/json' },
});
});
globalThis.Image = class {
width = 1;
height = 1;
set src(value) {
queueMicrotask(() => this.onload?.());
}
};
globalThis.createImageBitmap = async () => ({
width: 1,
height: 1,
close() {},
});
for (const name of [
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
])
window[name] = globalThis[name];
const raf = globalThis.requestAnimationFrame;
const caf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) =>
setTimeout(() => callback(Date.now()), 16);
globalThis.cancelAnimationFrame = clearTimeout;
t.after(() => {
if (raf) globalThis.requestAnimationFrame = raf;
else delete globalThis.requestAnimationFrame;
if (caf) globalThis.cancelAnimationFrame = caf;
else delete globalThis.cancelAnimationFrame;
window.happyDOM.abort();
});
const context = {
measureText: (text) => ({ width: String(text).length * 8 }),
createLinearGradient: () => ({ addColorStop() {} }),
createRadialGradient: () => ({ addColorStop() {} }),
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
};
for (const name of [
'clearRect',
'fillRect',
'strokeRect',
'beginPath',
'closePath',
'moveTo',
'lineTo',
'arc',
'ellipse',
'fill',
'stroke',
'save',
'restore',
'translate',
'rotate',
'scale',
'setTransform',
'drawImage',
'fillText',
'strokeText',
'roundRect',
'quadraticCurveTo',
'bezierCurveTo',
'clip',
'setLineDash',
])
context[name] = () => {};
t.mock.method(
window.HTMLCanvasElement.prototype,
'getContext',
() => context,
);
return { window, network };
}
export async function flushLifecycleTasks(t, ms = 0) {
// Drain promise chains before running timers, including atlas preparation.
for (let i = 0; i < 20; i++) await Promise.resolve();
t.mock.timers.tick(ms);
for (let i = 0; i < 20; i++) await Promise.resolve();
}