From 705b7a5314cca8ecbb7828f011d543ad28dad3b1 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 21:36:38 +0400 Subject: [PATCH] Make the cold-start comparison wait for the same data and attribute it Both pages now get the same deterministic SATCAT, NASA image and wildlife data and advance animation clocks alike; the scenario waits until each display has published and the registries stay unchanged for two seconds, and late-created content is attributed to its registered owner. Known allocation counters are normalised; every slot, count and visibility is still compared. Infrastructure drew on attach before the camera was restored, seeding its level of detail at the incoming engine's default view; it now draws when the view is restored. --- scripts/qa-switch-equals-cold-start.mjs | 73 ++++---- scripts/qa/coldStart.mjs | 117 ++++++++++++- scripts/qa/coldStart.test.mjs | 152 +++++++++++++++++ scripts/qa/coldStartFixtures.mjs | 28 +++- scripts/qa/coldStartFixtures.test.mjs | 184 ++++++++++++++++++++- scripts/qa/ledger.mjs | 15 +- src/layers/infrastructure/index.js | 13 +- src/layers/infrastructure/session.test.mjs | 49 ++++++ 8 files changed, 587 insertions(+), 44 deletions(-) diff --git a/scripts/qa-switch-equals-cold-start.mjs b/scripts/qa-switch-equals-cold-start.mjs index 24414a4f7..f41fa4663 100644 --- a/scripts/qa-switch-equals-cold-start.mjs +++ b/scripts/qa-switch-equals-cold-start.mjs @@ -11,6 +11,7 @@ import { drawLedger } from './qa/ledger.mjs'; import { COLD_START_GROUPS, coldStartUrl, + coldStartReady, compareColdStart, probeSnapshot, sessionExclusion, @@ -51,42 +52,26 @@ const evidence = (strict.extra.coldStart = { async function settle(page, cold = false) { await whenSettled(page, { timeoutMs: 30000 }); + await page.evaluate(() => { + window.__coldStartStable = null; + }); await page.waitForFunction( - (layers, cold) => { - const { dataManager, engine } = window.__godsEyeView; - engine.render.request('qa-cold-start-readiness'); - return layers.every((id) => { - const entry = dataManager.layers.get(id), - module = entry?.module; - if (!entry) return false; - if (cold && module.sessionState?.includes('enabled')) - return !entry.enabled; - if ( - !entry.enabled || - !entry.initialized || - entry.sceneDetached || - entry.refreshing - ) - return false; - const stats = module.getStats?.() ?? {}; - if (stats.loading || stats.refreshing) return false; - // These layers can be enabled with no search, route or selected subject. - if ( - [ - 'directions', - 'military-awareness', - 'osm-places', - 'sea-marks', - 'ski-pistes', - ].includes(id) - ) - return true; - return stats.count > 0; - }); - }, + `(() => { + if (!(${coldStartReady})(${JSON.stringify(layers)}, ${cold})) { + window.__coldStartStable = null; + return false; + } + const { engine } = window.__godsEyeView; + const key = JSON.stringify(['movers', 'lines', 'ground', 'imagery', 'meshes'] + .flatMap((family) => engine[family].list().map(({ id, kind, count, shown }) => + [family, id, kind, count, shown])).sort()); + const now = performance.now(); + const last = window.__coldStartStable; + if (!last || last.key !== key) window.__coldStartStable = { key, at: now }; + // Cover the 450 ms infrastructure walk and deferred feed/geometry publication. + return last?.key === key && now - last.at >= 2000; + })()`, { timeout: 30000, polling: 100 }, - layers, - cold, ); await whenSettled(page, { frames: 5, timeoutMs: 30000 }); } @@ -328,6 +313,26 @@ await strict.run({}, async (browser) => { sample.failureSnapshot = await probeSnapshot(page, layers).catch( () => null, ); + sample.readiness = await page + ?.evaluate( + (layers) => + Object.fromEntries( + layers.map((id) => { + const module = + window.__godsEyeView.dataManager.layers.get(id)?.module; + return [ + id, + { + stats: module?.getStats?.(), + loading: module?.getLoadingDiagnostics?.(), + lod: module?.getLodDiagnostics?.(), + }, + ]; + }), + ), + layers, + ) + .catch(() => null); evidence.differences.push({ layer: layers.join(', '), direction, diff --git a/scripts/qa/coldStart.mjs b/scripts/qa/coldStart.mjs index df02f1ed0..258160863 100644 --- a/scripts/qa/coldStart.mjs +++ b/scripts/qa/coldStart.mjs @@ -68,6 +68,71 @@ export function coldStartUrl(href, engine) { return url.href; } +/** Feed completion alone does not mean its deferred display has been drawn. */ +export function coldStartReady(layers, cold = false) { + const { dataManager, engine } = window.__godsEyeView; + engine.render.request('qa-cold-start-readiness'); + const movers = engine.movers.list(); + return layers.every((id) => { + const entry = dataManager.layers.get(id), + module = entry?.module; + if (!entry) return false; + if (cold && module.sessionState?.includes('enabled')) return !entry.enabled; + if ( + !entry.enabled || + !entry.initialized || + entry.sceneDetached || + entry.refreshing + ) + return false; + const stats = module.getStats?.() ?? {}; + if (stats.loading || stats.refreshing) return false; + if (id === 'local-dams' || id === 'local-datacenters') + return ( + module.getLodDiagnostics().computed && engine.surface.tilesLoaded() + ); + if (id === 'wildlife-sightings') + return ( + movers.some((row) => row.id === id && row.count === 4) && + movers.some((row) => row.id === `${id}-density` && row.count === 3) + ); + if (id === 'bhote-koshi-locator') + return movers.some( + (row) => + row.id === `${id}-bhote-koshi-incident-halo` && + row.count === 1 && + row.shown, + ); + if (id === 'traffic') + return ( + stats.count > 0 && + movers.some( + (row) => + row.id === 'traffic-fill' && row.count === stats.count && row.shown, + ) + ); + if (['flights', 'military', 'transit'].includes(id)) + return ( + stats.count > 0 && + movers.some( + (row) => row.id.startsWith(id + '-') && row.count > 0 && row.shown, + ) + ); + // These layers can be enabled with no search, route or selected subject. + if ( + [ + 'directions', + 'military-awareness', + 'osm-places', + 'sea-marks', + 'ski-pistes', + ].includes(id) + ) + return true; + return stats.count > 0; + }); +} + // Share addresses round the camera to 0.0001 degrees and whole metres/degrees. // World positions must still match within a metre; projected positions allow // two CSS pixels for that camera quantization. Counts and identities are exact. @@ -168,19 +233,69 @@ export async function probeSnapshot(page, layers) { return { ...probe, ledger: await drawLedger(page) }; } +// Display allocations are not content identities. Keep every slot and its +// generation, but number these allocator-local sequences within each family. +// Raw evidence retains the original IDs. A lost or extra slot still differs. +export function normalizeAllocations(snapshot) { + const patterns = [ + ['imagery', /^(weather-)(\d+)$/], + ['imagery', /^(wind-field-)(\d+)$/], + ['imagery', /^(bhote-koshi-imagery-)(\d+)(-(?:left|right))$/], + ['lines', /^(satellites-orbit-)(\d+)$/], + ['movers', /^(satellites-points-)(\d+)(-\d+)$/], + ]; + const ids = new Map(); + for (const [family, pattern] of patterns) { + const rows = snapshot.ledger.filter( + (row) => row.family === family && pattern.test(row.id), + ); + const sequence = [ + ...new Set(rows.map((row) => Number(row.id.match(pattern)[2]))), + ].sort((a, b) => a - b); + for (const row of rows) { + const [, prefix, number, suffix = ''] = row.id.match(pattern); + ids.set( + `${family}:${row.id}`, + `${prefix}${sequence.indexOf(Number(number)) + 1}${suffix}`, + ); + } + } + const rename = (row, family) => ({ + ...row, + id: ids.get(`${family}:${row.id}`) ?? row.id, + }); + return { + ...snapshot, + ledger: snapshot.ledger.map((row) => rename(row, row.family)), + movers: snapshot.movers.map((row) => rename(row, 'movers')), + }; +} + /** Keep raw samples and declared exclusions beside the complete difference list. */ export function compareColdStart( switched, cold, { direction, owners = {}, allow = [], sessionState = true }, ) { + const ownership = normalizeAllocations({ + ledger: Object.entries(owners).map(([key, layer]) => { + const colon = key.indexOf(':'); + return { family: key.slice(0, colon), id: key.slice(colon + 1), layer }; + }), + movers: [], + }); + switched = normalizeAllocations(switched); + cold = normalizeAllocations(cold); const differences = [], skipped = []; const add = (layer, family, id, rows) => differences.push( ...rows.map((row) => ({ layer, direction, family, id, ...row })), ); - const layerOf = (family, id) => owners[`${family}:${id}`] ?? '[page]'; + const layerOf = (family, id) => + [...switched.ledger, ...cold.ledger, ...ownership.ledger].find( + (row) => row.family === family && row.id === id && row.layer, + )?.layer ?? '[page]'; const ledger = compareLedgers(switched.ledger, cold.ledger, allow); for (const [side, rows] of [ ['switch', ledger.onlyBefore], diff --git a/scripts/qa/coldStart.test.mjs b/scripts/qa/coldStart.test.mjs index 5ea274f69..2791e32f5 100644 --- a/scripts/qa/coldStart.test.mjs +++ b/scripts/qa/coldStart.test.mjs @@ -11,6 +11,8 @@ import { compareColdStart, sessionExclusion, probeSnapshot, + normalizeAllocations, + coldStartReady, } from './coldStart.mjs'; const snapshot = () => ({ @@ -256,3 +258,153 @@ test('the production probe captures all requested fields and releases its frame assert.equal(engine.frames.postRender.numberOfListeners, listeners); engine.destroy(); }); + +test('display allocation counters do not become content identities or hide lost slots', () => { + const a = snapshot(), + b = snapshot(); + for (const [family, left, right, layer] of [ + ['imagery', 'weather-4', 'weather-1', 'weather-radar'], + ['imagery', 'wind-field-2', 'wind-field-1', 'wind'], + [ + 'imagery', + 'bhote-koshi-imagery-2-left', + 'bhote-koshi-imagery-1-left', + 'bhote-koshi-2026', + ], + [ + 'imagery', + 'bhote-koshi-imagery-2-right', + 'bhote-koshi-imagery-1-right', + 'bhote-koshi-2026', + ], + ['lines', 'satellites-orbit-5', 'satellites-orbit-4', 'satellites'], + ['movers', 'satellites-points-4-4', 'satellites-points-5-4', 'satellites'], + ]) { + a.ledger.push({ family, id: left, layer, shown: true }); + b.ledger.push({ family, id: right, layer, shown: true }); + if (family === 'movers') { + a.movers.push({ id: left, count: 10, capacity: 8192 }); + b.movers.push({ id: right, count: 10, capacity: 8192 }); + } + } + assert.deepEqual(compareColdStart(a, b, options).differences, []); + assert.equal( + a.ledger[1].id, + 'weather-4', + 'raw samples retain allocation IDs', + ); + b.ledger.find((row) => row.id === 'weather-1').shown = false; + b.ledger = b.ledger.filter((row) => row.id !== 'bhote-koshi-imagery-1-right'); + b.movers.at(-1).count--; + const differences = compareColdStart(a, b, options).differences; + assert.deepEqual(differences.map(({ field }) => field).sort(), [ + 'count', + 'present', + 'shown', + ]); + assert.ok(differences.every(({ layer }) => layer !== '[page]')); + a.ledger.push({ + family: 'lines', + id: 'satellites-orbit-7', + layer: 'satellites', + shown: false, + }); + assert.equal( + compareColdStart(a, b, options).differences.filter( + ({ family }) => family === 'lines', + ).length, + 1, + ); + assert.equal( + normalizeAllocations(a).ledger.filter(({ family }) => family === 'lines') + .length, + 2, + ); +}); + +test('asynchronously created CCTV lines are attributed even when absent at enable', async () => { + const engine = createFakeEngine(); + engine.lines.createLine({ + id: 'cctv-camera-ray-tl', + widthPx: 1, + color: [1, 1, 1, 1], + }); + const context = vm.createContext({ + window: { + __godsEyeView: { + engine, + dataManager: { layers: new Map([['cctv', { module: {} }]]) }, + }, + }, + }); + const page = { + evaluate: async (code) => + vm.runInContext(typeof code === 'string' ? code : `(${code})()`, context), + }; + const capture = await probeSnapshot(page, ['cctv']); + assert.equal(capture.ledger[0].layer, 'cctv'); + engine.destroy(); +}); + +test('readiness waits for fixture publications, LOD and the locator reveal', () => { + const engine = createFakeEngine(); + const dataManager = { layers: new Map() }; + const context = vm.createContext({ + window: { __godsEyeView: { engine, dataManager } }, + }); + const ready = (id) => + vm.runInContext(`(${coldStartReady})([${JSON.stringify(id)}])`, context); + const entry = (id, extra = {}) => + dataManager.layers.set(id, { + enabled: true, + initialized: true, + module: { getStats: () => ({ count: 10 }), ...extra }, + }); + entry('local-dams', { getLodDiagnostics: () => ({ computed: false }) }); + assert.equal(ready('local-dams'), false); + dataManager.layers.get('local-dams').module.getLodDiagnostics = () => ({ + computed: true, + }); + assert.equal(ready('local-dams'), true); + entry('wildlife-sightings'); + const animals = engine.movers.createSet({ + id: 'wildlife-sightings', + kind: 'point', + capacity: 4, + point: { sizePx: 4 }, + }); + animals.setCount(3); + assert.equal(ready('wildlife-sightings'), false); + animals.setCount(4); + const density = engine.movers.createSet({ + id: 'wildlife-sightings-density', + kind: 'point', + capacity: 3, + point: { sizePx: 4 }, + }); + density.setCount(3); + assert.equal(ready('wildlife-sightings'), true); + entry('bhote-koshi-locator'); + const halo = engine.movers.createSet({ + id: 'bhote-koshi-locator-bhote-koshi-incident-halo', + kind: 'point', + capacity: 1, + point: { sizePx: 4 }, + }); + assert.equal(ready('bhote-koshi-locator'), false); + halo.setCount(1); + assert.equal(ready('bhote-koshi-locator'), true); + entry('transit'); + const bus = engine.movers.createSet({ + id: 'transit-1', + kind: 'point', + capacity: 1, + point: { sizePx: 4 }, + }); + bus.set(0, { show: false }); + bus.setCount(1); + assert.equal(ready('transit'), false); + bus.set(0, { show: true }); + assert.equal(ready('transit'), true); + engine.destroy(); +}); diff --git a/scripts/qa/coldStartFixtures.mjs b/scripts/qa/coldStartFixtures.mjs index e7e6094bb..a1d4c3cdf 100644 --- a/scripts/qa/coldStartFixtures.mjs +++ b/scripts/qa/coldStartFixtures.mjs @@ -19,8 +19,8 @@ import { } from '../../src/bench/satelliteFixture.js'; import { launchFixture } from '../../src/bench/launchFixture.js'; -// These are the feeds used by the strict switching scenarios, with motion and -// timestamps held fixed so page startup duration cannot change the sample. +// These are the feeds used by the strict switching scenarios. Feed timestamps +// stay fixed; wall time must advance for visibility throttles and fade-in. export async function coldStartFixtures(origin, group) { const epoch = group === 'space' @@ -41,7 +41,8 @@ export async function coldStartFixtures(origin, group) { const routes = []; const fixtures = []; const route = (pattern, respond) => routes.push({ pattern, respond }); - if (group === 'wildlife') routes.push(...wildlifeRoutes(origin).routes); + if (group === 'wildlife') + routes.push(...wildlifeRoutes(origin, { now: () => epoch }).routes); if (group === 'open') { route('https://tiles.openseamap.org/*', image); route('https://www.opensnowmap.org/*', image); @@ -286,12 +287,15 @@ export async function coldStartFixtures(origin, group) { function installFeeds({ group, epoch, groups, ghost, launches, firms }) { const RealDate = Date; + const started = performance.now(); + const now = () => + group === 'space' ? epoch : epoch + performance.now() - started; globalThis.Date = class extends RealDate { constructor(...args) { - super(...(args.length ? args : [epoch])); + super(...(args.length ? args : [now()])); } static now() { - return epoch; + return Math.floor(now()); } }; localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); @@ -558,6 +562,18 @@ function installFeeds({ group, epoch, groups, ghost, launches, firms }) { return Promise.resolve( new Response(groups[path.split('/').pop()] ?? groups.stations), ); + if (path.startsWith('/api/celestrak-satcat/')) { + const tle = groups[path.split('/').pop()]; + if (tle) { + const lines = tle.trim().split('\n'); + const rows = [ + 'OBJECT_NAME,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER', + ]; + for (let i = 0; i < lines.length; i += 3) + rows.push(`${lines[i]},${Number(lines[i + 1].slice(2, 7))},PAY,+,US`); + return Promise.resolve(new Response(rows.join('\n'))); + } + } if (path === '/api/launches') return json(launches); if (path === '/api/firms') return json(firms); if (path === '/api/route') { @@ -588,7 +604,7 @@ function installFeeds({ group, epoch, groups, ghost, launches, firms }) { }); // Same-origin proxies must not silently turn this into a live-feed test. // Delegate only routes answered by the fixtures above or the strict harness. - if (path.startsWith('/api/')) { + if (url.origin === location.origin && path.startsWith('/api/')) { if (path === '/api/google/nearby-places') return json({ places: [] }); if (path === '/api/setup/status') return json({ ready: true }); if (/^\/api\/(firms|mapillary)\/status$/.test(path) && group !== 'street') diff --git a/scripts/qa/coldStartFixtures.test.mjs b/scripts/qa/coldStartFixtures.test.mjs index 35830807e..8f8dfac06 100644 --- a/scripts/qa/coldStartFixtures.test.mjs +++ b/scripts/qa/coldStartFixtures.test.mjs @@ -3,6 +3,11 @@ import test from 'node:test'; import vm from 'node:vm'; import { coldStartFixtures } from './coldStartFixtures.mjs'; import { COLD_START_GROUPS } from './coldStart.mjs'; +import { createSatelliteSource } from '../../src/layers/satellites/source.js'; +import { parseSatcatCsv } from '../../src/layers/satellites/classes.js'; +import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { createTrafficDisplay } from '../../src/layers/traffic/render/display.js'; +import { createAnimation } from '../../src/layers/traffic/animation.js'; const origin = 'http://127.0.0.1:4309'; test('every group has valid strict fixture routes', async () => { @@ -62,7 +67,8 @@ test('independent pages receive identical stationary aircraft and timestamps thr URL, Response, EventTarget, - location: { href: origin }, + performance: { now: () => 0 }, + location: { href: origin, origin }, localStorage: { setItem() {} }, window: { EventSource: class {}, @@ -95,3 +101,179 @@ test('independent pages receive identical stationary aircraft and timestamps thr '/api/unexpected-feed', ); }); + +async function installedPage(group) { + const fixture = await coldStartFixtures(origin, group); + const scripts = []; + await fixture.install({ + evaluateOnNewDocument: async (fn, args) => scripts.push({ fn, args }), + }); + let elapsed = 0; + const forwarded = []; + const context = vm.createContext({ + URL, + Response, + EventTarget, + performance: { now: () => elapsed }, + location: { href: origin, origin }, + localStorage: { setItem() {} }, + window: { + EventSource: class {}, + async fetch(input) { + const url = new URL(input.url ?? input, origin).href; + forwarded.push(url); + const route = fixture.routes.find(({ pattern }) => + new URLPattern(pattern).test(url), + ); + assert.ok(route, url); + const response = await route.respond({ url }); + return new Response(response.body, { + status: response.status ?? 200, + headers: { 'content-type': response.contentType }, + }); + }, + }, + }); + for (const { fn, args } of scripts) + vm.runInContext(`(${fn})(${JSON.stringify(args)})`, context); + return { + fixture, + context, + forwarded, + advance: (ms) => { + elapsed += ms; + }, + fetch: (url) => + vm.runInContext(`window.fetch(${JSON.stringify(url)})`, context), + }; +} + +test('the page fetch guard delegates NASA snapshot APIs to their image fixture', async () => { + const page = await installedPage('imagery'); + const response = await page.fetch( + 'https://wvs.earthdata.nasa.gov/api/v1/snapshot?WIDTH=256', + ); + assert.equal(response.headers.get('content-type'), 'image/png'); + assert.ok((await response.arrayBuffer()).byteLength > 0); + assert.equal(page.context.window.__coldStartUnexpectedApi.length, 0); + await assert.rejects(page.fetch('/api/v1/snapshot'), /No cold-start fixture/); +}); + +test('every TLE group has matching deterministic SATCAT records on both pages', async () => { + const a = await installedPage('space'), + b = await installedPage('space'); + for (const group of [ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + 'resource', + 'weather', + 'planet', + 'sar', + 'science', + ]) { + const source = createSatelliteSource({ fetchImpl: a.fetch }); + const catalog = await source.readSatcat(group); + assert.equal(catalog.ok, true); + assert.equal( + catalog.text, + await (await b.fetch(`/api/celestrak-satcat/${group}`)).text(), + ); + const tle = (await source.readGroup(group)).text; + assert.equal( + parseSatcatCsv(catalog.text).size, + tle.trim().split('\n').length / 3, + ); + } + assert.equal(a.context.window.__coldStartUnexpectedApi.length, 0); +}); + +test('wall time advances for fades and visibility while the orbit epoch stays fixed', async () => { + for (const group of ['aircraft', 'traffic', 'space']) { + const page = await installedPage(group); + const before = vm.runInContext('Date.now()', page.context); + page.advance(2000); + assert.equal( + vm.runInContext('Date.now()', page.context) - before, + group === 'space' ? 0 : 2000, + ); + assert.equal( + vm.runInContext('+new Date()', page.context), + vm.runInContext('Date.now()', page.context), + ); + } +}); + +test('traffic uses deterministic road and flow tiles', async () => { + const page = await installedPage('traffic'); + assert.equal( + (await (await page.fetch('/api/tomtom/status')).json()).hasKey, + true, + ); + for (const url of [ + 'https://tiles.openfreemap.org/qa-traffic/14/3743/6745.pbf', + '/api/tomtom/flow/12/935/1686.pbf', + ]) + assert.ok((await (await page.fetch(url)).arrayBuffer()).byteLength > 0); +}); + +test('traffic dots finish their birth fade with the fixture clock', async (t) => { + const page = await installedPage('traffic'); + t.mock.method(Date, 'now', () => vm.runInContext('Date.now()', page.context)); + const engine = createFakeEngine(); + const display = createTrafficDisplay(engine); + t.after(() => { + display.destroy(); + engine.destroy(); + }); + const point = { + position: { x: 6378137, y: 0, z: 0 }, + show: true, + pixelSize: 6, + color: { red: 1, green: 1, blue: 1, alpha: 1 }, + outlineWidth: 0, + }; + const dot = { + point, + born: Date.now(), + nominalSize: 6, + direction: 1, + segIdx: 0, + numSegments: 1, + t: 0.5, + mps: 1, + stoppedUntil: 0, + segmentDist: [1000], + waypoints: [point.position, { x: 6378137, y: 1000, z: 0 }], + }; + const animation = createAnimation({ + state: { + _enabled: true, + _renderHeld: true, + _engine: engine, + _display: display, + _dots: [dot], + _lastAnimTime: Date.now(), + _animFrame: 0, + }, + services: {}, + parts: { rendering: {}, retention: { easeHeights() {} } }, + }); + animation.animate(); + animation.drawDots(); + assert.equal( + engine.movers.list().find(({ id }) => id === 'traffic-fill').count, + 0, + ); + page.advance(200); + animation.animate(); + animation.drawDots(); + assert.equal( + engine.movers.list().find(({ id }) => id === 'traffic-fill').count, + 1, + ); +}); diff --git a/scripts/qa/ledger.mjs b/scripts/qa/ledger.mjs index 60e16a148..b71cf8739 100644 --- a/scripts/qa/ledger.mjs +++ b/scripts/qa/ledger.mjs @@ -1,12 +1,25 @@ /** The five public drawing registries, with no SDK objects or volatile fields. */ export function drawLedger(page) { return page.evaluate(() => { - const { engine } = window.__godsEyeView; + const { engine, dataManager } = window.__godsEyeView; + const owners = [...(dataManager?.layers.keys() ?? [])].sort( + (a, b) => b.length - a.length, + ); + const ownerOf = (id) => + owners.find((owner) => id === owner || id.startsWith(owner + '-')) ?? + (id.startsWith('wind-field-') ? 'wind' : undefined) ?? + (id.startsWith('bhote-koshi-imagery-') || + id.startsWith('bhote-koshi-flood-') + ? 'bhote-koshi-2026' + : undefined); return ['movers', 'lines', 'ground', 'imagery', 'meshes'] .flatMap((family) => engine[family].list().map((row) => ({ id: String(row.id), family, + ...(ownerOf(String(row.id)) + ? { layer: ownerOf(String(row.id)) } + : {}), ...(row.kind === undefined ? {} : { kind: row.kind }), ...(row.count === undefined ? {} : { count: row.count }), shown: row.shown ?? true, diff --git a/src/layers/infrastructure/index.js b/src/layers/infrastructure/index.js index 011cea023..d2d66017b 100644 --- a/src/layers/infrastructure/index.js +++ b/src/layers/infrastructure/index.js @@ -78,6 +78,7 @@ export function createInfrastructureSessionLayer( request = null, abort = null, display = null, + sceneContext = null, enabled = false, destroyed = false; let updated = null, @@ -110,6 +111,8 @@ export function createInfrastructureSessionLayer( try { const next = await read(); if (!enabled || destroyed) return; + if (sceneContext?.viewReady === false || sceneContext?.signal?.aborted) + return; const active = display; if (active) { error = null; @@ -138,8 +141,10 @@ export function createInfrastructureSessionLayer( init(_viewer, options = {}) { if (options.engine) return this.attachRenderer(options); }, - attachRenderer({ engine = null, signal = null } = {}) { + attachRenderer(options = {}) { + const { engine = null, signal = null } = options; if (display) throw Error('Infrastructure display already attached'); + sceneContext = options; display = engine ? createDisplay(engine, config, services) : null; // The dataset load belongs to the session and keeps running; only this // scene's wait for it ends when the scene is released. A released @@ -149,6 +154,10 @@ export function createInfrastructureSessionLayer( if (!signal?.aborted) throw error; }); }, + resumeViewport() { + // LOD keeps incumbents, so its first walk must use the restored camera. + if (enabled && display) return show(); + }, detachRenderer() { if (!display) return; const selected = services.getSelectedEntityContext?.(); @@ -158,6 +167,7 @@ export function createInfrastructureSessionLayer( : null; display.destroy(); display = null; + sceneContext = null; }, enable() { enabled = true; @@ -196,6 +206,7 @@ export function createInfrastructureSessionLayer( abort?.abort(); display?.destroy(); display = data = null; + sceneContext = null; }, }; } diff --git a/src/layers/infrastructure/session.test.mjs b/src/layers/infrastructure/session.test.mjs index f380865d3..7f686a0f6 100644 --- a/src/layers/infrastructure/session.test.mjs +++ b/src/layers/infrastructure/session.test.mjs @@ -8,6 +8,55 @@ import { selectEntityContext, } from '../../data/contextStore.js'; import { layerFeedState } from '../../data/feedState.js'; +import { createLayerSceneContext } from '../../data/layerSceneBinding.js'; + +test('reattached infrastructure first computes LOD at the restored camera', async (t) => { + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + text: async () => + JSON.stringify({ + type: 'Feature', + id: 'dam', + properties: {}, + geometry: { type: 'Point', coordinates: [10, 20] }, + }), + })); + const draws = []; + const layer = createInfrastructureSessionLayer( + { id: 'local-dams', url: '/dams' }, + {}, + (engine) => ({ + show: () => draws.push(engine.camera.height()), + hide() {}, + destroy() {}, + }), + ); + const engine = createFakeEngine(); + t.after(() => { + layer.destroy(); + engine.destroy(); + }); + layer.init(null, { engine }); + await layer.enable(); + layer.detachRenderer(); + draws.length = 0; + let restore; + const context = createLayerSceneContext({ + engine, + viewRestored: new Promise((resolve) => { + restore = resolve; + }), + }); + await layer.attachRenderer(context); + engine.fake.frame(); + assert.deepEqual(draws, [], 'the incoming default view never seeds LOD'); + engine.fake.setPose({ height: 20000 }); + restore(); + await context.viewRestored; + await layer.resumeViewport(); + assert.equal(draws.length, 1); + assert.ok(Math.abs(draws[0] - 20000) < 1e-6); +}); test('a bundled file delivered between planets keeps multipart IDs and is read only once', async (t) => { let deliver,