mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'german/refactor-e2-layer-lifecycle' into german/navara-refactor
This commit is contained in:
commit
233c4def5b
6 files changed
+621
-15
No files matched your search
+54
-13
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -150,6 +150,7 @@ export function createPickRegistry() {
|
||||
|
||||
return {
|
||||
resolvePickId,
|
||||
owners: () => [..._owners.keys()],
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
findPickOwner,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user