diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 25443f57f..b831b9ec9 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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', diff --git a/src/app/layers/lifecycleRehearsal.test.mjs b/src/app/layers/lifecycleRehearsal.test.mjs new file mode 100644 index 000000000..2d6024437 --- /dev/null +++ b/src/app/layers/lifecycleRehearsal.test.mjs @@ -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', + ); +}); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index fdb8584b7..389ce033e 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -150,6 +150,7 @@ export function createPickRegistry() { return { resolvePickId, + owners: () => [..._owners.keys()], registerPickOwner, unregisterPickOwner, findPickOwner, diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 1cc37487c..2c237adbc 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -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() { diff --git a/src/layers/wind/inspectionMarker.js b/src/layers/wind/inspectionMarker.js index b08be5fba..7291158bb 100644 --- a/src/layers/wind/inspectionMarker.js +++ b/src/layers/wind/inspectionMarker.js @@ -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) { diff --git a/src/testSupport/lifecycleEnvironment.mjs b/src/testSupport/lifecycleEnvironment.mjs new file mode 100644 index 000000000..6db57f3a5 --- /dev/null +++ b/src/testSupport/lifecycleEnvironment.mjs @@ -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(); +}