diff --git a/scripts/qa-surface-heights.mjs b/scripts/qa-surface-heights.mjs new file mode 100644 index 000000000..843646221 --- /dev/null +++ b/scripts/qa-surface-heights.mjs @@ -0,0 +1,333 @@ +// Run this checkout's probe against either build; no page-side source imports. +// node scripts/qa-surface-heights.mjs http://localhost:4308 --out=heights.json +// Add --compare=before.json for the 0.1 m gate, or --tiles for the 50 m block. +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import * as geo from '../src/geo/index.js'; +import { + createStrict, + localPreview, + openPage, + waitForEngine, + TILES_FIXTURE_PATH, +} from './qa-strict-harness.mjs'; +import { whenSettled } from './qa/ready.mjs'; +import { + center, + points, + layers, + offsets, + counts, + features, + fixtureRoutes, + observeSurfaceMovers, + settledPositions, +} from './qa/surfaceHeights.mjs'; + +export function compareHeights(before, after, { tiles = false } = {}) { + assert.equal(before.version, 1, 'before snapshot version'); + assert.equal(after.version, 1, 'after snapshot version'); + assert.equal( + before.surface, + tiles ? 'tiles' : 'terrain', + 'before surface (tiles require --tiles)', + ); + assert.equal(after.surface, before.surface, 'matching surfaces'); + assert.equal(before.fixture, after.fixture, 'matching fixtures'); + const reports = []; + for (const engine of new Set([ + 'cesium', + 'navara', + ...Object.keys(before.engines), + ...Object.keys(after.engines), + ])) { + const left = before.engines[engine] ?? {}, + right = after.engines[engine] ?? {}; + for (const layer of new Set([ + ...layers, + ...Object.keys(left), + ...Object.keys(right), + ])) { + const a = left[layer] ?? {}, + b = right[layer] ?? {}; + const missing = Object.keys(a).filter((id) => !Object.hasOwn(b, id)); + const extra = Object.keys(b).filter((id) => !Object.hasOwn(a, id)); + const invalid = []; + let maxAbsM = 0, + point = null; + for (const id of Object.keys(a)) { + if (!Object.hasOwn(b, id)) continue; + if (![a[id]?.heightM, b[id]?.heightM].every(Number.isFinite)) { + invalid.push(id); + continue; + } + const delta = Math.abs(a[id].heightM - b[id].heightM); + if (delta > maxAbsM) { + maxAbsM = delta; + point = id; + } + } + reports.push({ + engine, + layer, + maxAbsM, + point, + missing, + extra, + invalid, + ok: + Object.keys(a).length > 0 && + Object.keys(b).length > 0 && + !missing.length && + !extra.length && + !invalid.length && + maxAbsM <= 0.1 + 1e-9, + }); + } + } + return { ok: reports.every((r) => r.ok), reports }; +} + +function serialize(rows, layer) { + const result = {}; + for (const row of rows) { + const position = geo.cartographic.fromCartesian(row.position); + const lon = geo.math.toDegrees(position.longitude), + lat = geo.math.toDegrees(position.latitude); + let id = row.id; + if (layer === 'import') { + // Imported sprites have no pick ids; match the fixture's unique coordinates. + const point = points.find( + (p) => Math.abs(p.lon - lon) < 1e-7 && Math.abs(p.lat - lat) < 1e-7, + ); + assert.ok(point, `Unknown import pin at ${lon},${lat}`); + id = point.id; + } + if (layer === 'directions') + id = `${row.index === 0 ? 'a' : 'b'}/${row.setId === 'directions-markers' ? 'disc' : 'letter'}`; + assert.ok(!Object.hasOwn(result, id), `Duplicate ${layer} point ${id}`); + assert.ok( + Number.isFinite(position.height), + `Nonfinite ${layer} point ${id}`, + ); + assert.ok( + Math.abs(lon - center.lon) < 0.0004 && + Math.abs(lat - center.lat) < 0.0004, + `${layer}/${id} outside the block fixture`, + ); + result[id] = { heightM: position.height, lon, lat }; + } + return Object.fromEntries( + Object.entries(result).sort(([a], [b]) => a.localeCompare(b)), + ); +} + +async function loadDisplay(page, layer) { + await page.evaluate( + async ({ layer, points, features }) => { + const { dataManager } = window.__godsEyeView; + const enable = async (id) => { + if (!(await dataManager.setEnabled(id, true))) + throw new Error(`Cannot enable ${id}`); + return dataManager.layers.get(id).module; + }; + if (layer === 'import') { + const outcomes = await window.__gevGeoImport.importFiles([ + new File( + [JSON.stringify({ type: 'FeatureCollection', features })], + 'surface-heights.geojson', + { type: 'application/geo+json' }, + ), + ]); + if (outcomes.some((o) => o.error)) + throw new Error(JSON.stringify(outcomes)); + } else if (layer === 'infrastructure') { + await enable('local-datacenters'); + await enable('local-dams'); + } else if (layer === 'directions') { + const mod = await enable(layer); + mod.placeEndpoint('a', points[0]); + mod.placeEndpoint('b', points[2]); + } else if (layer === 'story') { + const mod = dataManager.layers.get('bhote-koshi-2026').module; + mod.setParams({ + presentation: 'scene-beat', + sceneSurface: 'evidence-beat', + progress: 0.5, + autoPlay: false, + }); + await enable('bhote-koshi-2026'); + mod.setParams({ progress: 0.5, autoPlay: false }); + } else await enable(layer === 'alpr' ? 'alpr-cameras' : layer); + }, + { layer, points, features }, + ); +} + +async function unloadDisplay(page, layer) { + await page.evaluate(async (layer) => { + if (layer === 'import') { + for (const file of window.__gevGeoImport.list()) + window.__gevGeoImport.remove(file.id); + } else { + const ids = + layer === 'infrastructure' + ? ['local-datacenters', 'local-dams'] + : [ + layer === 'story' + ? 'bhote-koshi-2026' + : layer === 'alpr' + ? 'alpr-cameras' + : layer, + ]; + for (const id of ids) + await window.__godsEyeView.dataManager.setEnabled(id, false); + } + }, layer); +} + +async function main(argv) { + const option = (name) => + argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); + const base = localPreview( + argv.find((a) => !a.startsWith('--')) ?? + process.env.QA_BASE_URL ?? + 'http://localhost:4308', + ); + const tiles = argv.includes('--tiles'); + const before = option('compare') + ? JSON.parse(readFileSync(option('compare'), 'utf8')) + : null; + const snapshot = { + version: 1, + fixture: 'surface-heights-v1', + surface: tiles ? 'tiles' : 'terrain', + engines: {}, + }; + if (before) compareHeights(before, snapshot, { tiles }); + const routes = fixtureRoutes(base.origin, tiles); + const strict = createStrict({ origin: base.origin, layers, routes }); + base.search = '?engine=navara&welcome=0'; + if (tiles) base.searchParams.set('qaTileset', TILES_FIXTURE_PATH); + base.hash = `v=2&lat=${center.lat}&lon=${center.lon}&alt=1100&heading=0&pitch=-90&roll=0&map=${tiles ? 'photoreal' : 'osm'}&l=`; + await strict.run({}, async (browser) => { + for (const kind of ['navara', 'cesium']) { + const { page, errors } = await openPage(browser); + await page.goto(base.href, { waitUntil: 'domcontentloaded' }); + await waitForEngine(page, 'navara'); + if (kind === 'cesium') await strict.pick(page, kind); + await page.evaluate( + async ({ destination, tiles }) => { + const { engine, styleManager } = window.__godsEyeView; + styleManager.orbitController.stop(); + engine.camera.cancelFlight(); + await engine.maps.setTerrain({ + kind: tiles ? 'ellipsoid' : 'keyless', + }); + engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + engine.render.request('qa-surface-heights'); + }, + { + destination: geo.cartesian3.fromDegrees(center.lon, center.lat, 1100), + tiles, + }, + ); + await whenSettled(page); + await page.waitForFunction( + ({ points, tiles }) => { + const { engine } = window.__godsEyeView; + engine.render.request('qa-surface-heights'); + return ( + engine.surface.tilesLoaded() && + engine.maps.photorealActive() === tiles && + points.every((p) => { + const place = { + longitude: (p.lon * Math.PI) / 180, + latitude: (p.lat * Math.PI) / 180, + height: 0, + }; + const height = tiles + ? engine.surface.sampleHeight(place) + : engine.surface.terrainHeight(place); + return ( + Number.isFinite(height) && + (tiles ? Math.abs(height - 50) < 0.1 : height > 1) + ); + }) + ); + }, + { timeout: 90000 }, + { points, tiles }, + ); + await page.evaluate(observeSurfaceMovers); + snapshot.engines[kind] = {}; + for (const layer of layers) { + await loadDisplay(page, layer); + await page.evaluate( + (destination) => { + const { engine } = window.__godsEyeView; + engine.camera.cancelFlight(); + engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + engine.render.request('qa-surface-heights'); + }, + geo.cartesian3.fromDegrees(center.lon, center.lat, 1100), + ); + await whenSettled(page); + const rows = await settledPositions(page, layer, counts[layer]); + const values = serialize(rows, layer); + snapshot.engines[kind][layer] = values; + if (tiles) + for (const [id, p] of Object.entries(values)) + assert.ok( + Math.abs(p.heightM - (50 + offsets[layer])) <= 0.1, + `${kind}/${layer}/${id}: ${p.heightM} m, expected ${50 + offsets[layer]} m`, + ); + await strict.passed( + page, + layer, + `${counts[layer]} committed surface points`, + values, + ); + console.log( + `PASS ${kind}/${layer}: ${rows.length} points${tiles ? ` at ${50 + offsets[layer]} m (±0.1 m)` : ''}`, + ); + await unloadDisplay(page, layer); + } + await page.evaluate(() => window.qaSurfaceMovers.restore()); + assert.deepEqual(errors, []); + await page.close(); + } + if (option('out')) { + writeFileSync(option('out'), JSON.stringify(snapshot, null, 2) + '\n'); + console.log(`WROTE ${option('out')}`); + } + if (before) { + const comparison = compareHeights(before, snapshot, { tiles }); + for (const r of comparison.reports) + console.log( + `${r.ok ? 'PASS' : 'FAIL'} ${r.engine}/${r.layer}: max |Δ|=${r.maxAbsM.toFixed(6)} m; missing=${r.missing.join(',') || 'none'}; extra=${r.extra.join(',') || 'none'}; invalid=${r.invalid.join(',') || 'none'}`, + ); + assert.ok( + comparison.ok, + 'Surface heights differ by more than 0.1 m or the point sets changed', + ); + } + console.log( + `PASS surface heights: 12 engine/layer measurements (${snapshot.surface})`, + ); + }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main(process.argv.slice(2)).catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/qa-surface-heights.test.mjs b/scripts/qa-surface-heights.test.mjs new file mode 100644 index 000000000..18fec415d --- /dev/null +++ b/scripts/qa-surface-heights.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { compareHeights } from './qa-surface-heights.mjs'; +import { + layers, + observeSurfaceMovers, + fixtureRoutes, + center, +} from './qa/surfaceHeights.mjs'; +import { createFakeEngine } from '../src/globe/testing/fakeEngine.js'; +import { decodeAlprTile } from '../src/layers/alpr/tileRecords.js'; + +const snapshot = (heightM = 50) => ({ + version: 1, + fixture: 'surface-heights-v1', + surface: 'terrain', + engines: Object.fromEntries( + ['cesium', 'navara'].map((engine) => [ + engine, + Object.fromEntries( + layers.map((layer) => [layer, { a: { heightM }, b: { heightM } }]), + ), + ]), + ), +}); + +test('comparison accepts identical heights and the inclusive 0.1 m boundary', () => { + for (const delta of [0, 0.099, 0.1, -0.1]) { + const result = compareHeights(snapshot(), snapshot(50 + delta)); + assert.equal(result.ok, true); + assert.equal(result.reports.length, 12); + assert.ok(Math.abs(result.reports[0].maxAbsM - Math.abs(delta)) < 1e-10); + } +}); + +test('comparison reports the largest absolute difference and fails above tolerance', () => { + const after = snapshot(); + after.engines.navara.import.a.heightM = 50.01; + after.engines.navara.import.b.heightM = 49.8999; + const result = compareHeights(snapshot(), after); + assert.equal(result.ok, false); + const failures = result.reports.filter((r) => !r.ok); + assert.equal(failures.length, 1); + assert.equal(failures[0].point, 'b'); + assert.ok(Math.abs(failures[0].maxAbsM - 0.1001) < 1e-10); +}); + +test('missing and extra points fail even with identical surviving heights', () => { + for (const engine of ['cesium', 'navara']) + for (const layer of layers) + for (const change of ['missing', 'extra']) { + const after = snapshot(); + if (change === 'missing') delete after.engines[engine][layer].a; + else after.engines[engine][layer].extra = { heightM: 50 }; + const result = compareHeights(snapshot(), after); + assert.equal(result.ok, false); + const report = result.reports.find( + (r) => r.engine === engine && r.layer === layer, + ); + assert.deepEqual(report.missing, change === 'missing' ? ['a'] : []); + assert.deepEqual(report.extra, change === 'extra' ? ['extra'] : []); + } +}); + +test('missing engines, layers, empty cohorts and invalid heights cannot pass', () => { + for (const change of [ + (s) => { + delete s.engines.navara; + }, + (s) => { + delete s.engines.cesium.story; + }, + (s) => { + s.engines.navara.alpr = {}; + }, + ...[null, NaN, Infinity, '50'].map((height) => (s) => { + s.engines.cesium.import.a.heightM = height; + }), + ]) { + const after = snapshot(); + change(after); + assert.equal(compareHeights(snapshot(), after).ok, false); + } + const empty = snapshot(); + empty.engines.cesium.story = {}; + assert.equal(compareHeights(empty, empty).ok, false); +}); + +test('tiles require explicit opt-in and cannot be compared with terrain', () => { + const tiles = snapshot(); + tiles.surface = 'tiles'; + assert.throws(() => compareHeights(tiles, tiles), /tiles require --tiles/); + assert.throws(() => compareHeights(snapshot(), tiles), /matching surfaces/); + assert.equal(compareHeights(tiles, tiles, { tiles: true }).ok, true); +}); + +test('the probe reads committed contract positions, snapshots mutable inputs and drops destroyed sets', () => { + const engine = createFakeEngine(); + const original = globalThis.window; + globalThis.window = { __godsEyeView: { engine } }; + try { + observeSurfaceMovers(); + const probe = window.qaSurfaceMovers; + const set = engine.movers.createSet({ + id: 'alpr-cameras', + kind: 'point', + capacity: 1, + point: { sizePx: 5 }, + }); + const position = { x: 1, y: 2, z: 3 }; + set.set(0, { position, show: true, pickId: 'camera' }); + set.setCount(1); + assert.throws(() => probe.read('alpr'), /Uncommitted mover count/); + set.commit(); + position.z = 500; + assert.equal(probe.read('alpr')[0].position.z, 3); + set.set(0, { position }); + assert.equal(probe.read('alpr')[0].position.z, 3); + set.commit(); + assert.equal(probe.read('alpr')[0].position.z, 500); + assert.equal(probe.read('alpr')[0].id, 'camera'); + set.destroy(); + assert.deepEqual(probe.read('alpr'), []); + probe.restore(); + } finally { + engine.destroy(); + if (original === undefined) delete globalThis.window; + else globalThis.window = original; + } +}); + +test('fixture routes match both bundled and dev infrastructure URLs and decode real camera tiles', () => { + const origin = 'http://localhost:4308'; + const routes = fixtureRoutes(origin, true); + const answer = (url) => + routes.find((r) => new URLPattern(r.pattern).test(url))?.respond({ url }); + for (const path of [ + '/assets/datacenters-abc.geojsonl', + '/src/data/local_data/dams/dams.geojsonl', + ]) { + const rows = answer(origin + path) + .body.split('\n') + .map(JSON.parse); + assert.equal(rows.length, 3); + assert.ok( + rows.every( + (r) => Math.abs(r.geometry.coordinates[1] - center.lat) < 0.0004, + ), + ); + } + const z = 12, + scale = 2 ** z; + const x = Math.floor(((center.lon + 180) / 360) * scale); + const y = Math.floor( + ((1 - Math.asinh(Math.tan((center.lat * Math.PI) / 180)) / Math.PI) / 2) * + scale, + ); + const tile = answer( + `https://tiles.dontgetflocked.com/cameras-us-hourly/${z}/${x}/${y}.mvt`, + ); + const cameras = decodeAlprTile(tile.body, z, x, y); + assert.equal(cameras.length, 3); + assert.equal(new Set(cameras.map((r) => r.id)).size, 3); + assert.ok(cameras.every((r) => Math.abs(r.longitude - center.lon) < 0.0004)); + assert.equal(answer(origin + '/api/unexpected-feed').status, 503); +}); diff --git a/scripts/qa/surfaceHeights.mjs b/scripts/qa/surfaceHeights.mjs new file mode 100644 index 000000000..cf298c328 --- /dev/null +++ b/scripts/qa/surfaceHeights.mjs @@ -0,0 +1,360 @@ +import { readFileSync } from 'node:fs'; +import { cameraTile } from '../fixtures/layers/alpr.mjs'; +import { tilesFixtureRoutes } from '../qa-strict-harness.mjs'; + +const { extras } = JSON.parse( + readFileSync(new URL('../fixtures/tiles3d/tileset.json', import.meta.url)), +); +export const center = extras.probes.block50; +export const points = [-1, 0, 1].map((n, i) => ({ + id: `surface-${i}`, + lon: center.lon + n * 0.00018, + lat: center.lat + n * 0.00018, +})); +export const layers = [ + 'import', + 'infrastructure', + 'alpr', + 'directions', + 'bikeshare', + 'story', +]; +export const offsets = { + import: 0, + infrastructure: 0, + alpr: 1.5, + directions: 0, + bikeshare: 2, + story: 0, +}; +export const counts = { + import: 3, + infrastructure: 6, + alpr: 3, + directions: 4, + bikeshare: 3, + story: 1, +}; + +export const features = points.map(({ id, lon, lat }) => ({ + type: 'Feature', + id, + properties: { name: id }, + geometry: { type: 'Point', coordinates: [lon, lat] }, +})); + +export function fixtureRoutes(origin, tiles) { + const json = (value) => ({ + contentType: 'application/json', + body: JSON.stringify(value), + }); + const event = JSON.parse( + readFileSync( + new URL( + '../../public/events/bhote-koshi-2026/event.json', + import.meta.url, + ), + ), + ); + event.evidenceSpine = []; + event.fieldReports = []; + event.reconstruction.corridor = points.map((p, i) => ({ + ...p, + chainageM: i * 30, + elevationM: 0, + })); + event.reconstruction.storyFloodWindow = { start: 0, end: 1 }; + event.imagery.rectangle = { + west: center.lon - 0.0004, + east: center.lon + 0.0004, + south: center.lat - 0.0004, + north: center.lat + 0.0004, + }; + const cameras = points.map((p, i) => ({ + latitude: p.lat, + longitude: p.lon, + osmId: 9100000 + i, + operator: 'Surface fixture', + brand: 'Fixture', + cameraType: 'fixed', + direction: '', + })); + return [ + ...(tiles ? tilesFixtureRoutes(origin) : []), + ...['datacenters', 'dams'].map((name) => ({ + pattern: `${origin}/*${name}*.geojsonl*`, + respond: () => ({ + contentType: 'application/json', + body: features + .map((f) => + JSON.stringify( + name === 'dams' + ? { + ...f, + geometry: { + ...f.geometry, + coordinates: [ + f.geometry.coordinates[0], + f.geometry.coordinates[1] + 0.0001, + ], + }, + } + : f, + ), + ) + .join('\n'), + }), + })), + { + pattern: `${origin}/events/bhote-koshi-2026/event.json*`, + respond: () => json(event), + }, + { + pattern: `${origin}/api/terrain/heights*`, + respond: ({ url }) => + json({ + results: new URL(url).searchParams + .get('points') + .split(';') + .map(() => ({ ellipsoid: tiles ? 50 : 150 })), + }), + }, + { + pattern: `${origin}/api/route*`, + respond: () => + json({ + ok: true, + geometry: points.map((p) => [p.lon, p.lat]), + distanceM: 60, + durationS: 30, + steps: [], + }), + }, + { + pattern: `${origin}/api/gbfs/*`, + respond: ({ url }) => { + const upstream = decodeURIComponent( + new URL(url).pathname.slice('/api/gbfs/'.length), + ); + const information = upstream.includes('station_information'); + return json({ + last_updated: 1785153600, + ttl: 3600, + version: '2.3', + data: { + stations: points.map((p) => + information + ? { + station_id: p.id, + name: p.id, + lat: p.lat, + lon: p.lon, + capacity: 20, + } + : { + station_id: p.id, + num_bikes_available: 10, + num_docks_available: 10, + is_installed: 1, + is_renting: 1, + is_returning: 1, + }, + ), + }, + }); + }, + }, + { + pattern: 'https://tiles.dontgetflocked.com/*', + respond: ({ url }) => { + const path = new URL(url).pathname; + if (path.endsWith('.json')) + return json({ + tiles: [ + 'https://tiles.dontgetflocked.com/cameras-us-hourly/{z}/{x}/{y}.mvt', + ], + bounds: [-160, 17, -64, 59], + }); + const match = path.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/); + if (!match) + throw new Error(`Unexpected camera fixture request: ${path}`); + return { + contentType: 'application/x-protobuf', + body: cameraTile(...match.slice(1).map(Number), cameras), + }; + }, + }, + { + // Auxiliary startup services must never turn this fixture run into live feeds. + pattern: `${origin}/api/*`, + respond: ({ url }) => { + const path = new URL(url).pathname; + if ( + ['/api/google/tiles-token', '/api/realtime/debug-log'].includes(path) + ) + return { status: 204 }; + if (path.endsWith('/status')) return json({ configured: false }); + return { + status: 503, + ...json({ error: 'Unavailable in surface fixture' }), + }; + }, + }, + ]; +} + +/** Observe committed positions: neither revision's mover registry exposes instances. */ +export function observeSurfaceMovers() { + const { engine } = window.__godsEyeView; + const records = new Map(); + let frames = 0; + const offFrame = engine.frames.postRender.addEventListener(() => { + frames++; + }); + const create = engine.movers.createSet; + const layerOf = (id) => { + if (/^geo-import-.*-pins-/.test(id)) return 'import'; + if (/^local-(datacenters|dams)-pins$/.test(id)) return 'infrastructure'; + if (id === 'alpr-cameras') return 'alpr'; + if (/^directions-(markers|marker-letters)$/.test(id)) return 'directions'; + if (id === 'bikeshare') return 'bikeshare'; + if (id === 'bhote-koshi-flood-bhote-koshi-surge-front') return 'story'; + return null; + }; + engine.movers.createSet = function (options) { + const set = create.call(this, options); + const layer = layerOf(options.id); + if (!layer) return set; + const record = { layer, set, items: [], committed: [] }; + records.set(options.id, record); + const write = set.set, + commit = set.commit, + destroy = set.destroy; + set.set = function (index, fields) { + const result = write.call(this, index, fields); + record.items[index] = { + ...record.items[index], + ...fields, + ...(fields.position ? { position: { ...fields.position } } : {}), + ...(fields.pickId + ? { pickId: String(fields.pickId.id ?? fields.pickId) } + : {}), + }; + return result; + }; + set.commit = function () { + const result = commit.call(this); + record.committed = record.items + .slice(0, set.count()) + .map((p) => ({ ...p })); + return result; + }; + set.destroy = function () { + const result = destroy.call(this); + records.delete(options.id); + return result; + }; + return set; + }; + window.qaSurfaceMovers = { + frames: () => frames, + read(layer) { + const registry = engine.movers.list(); + return [...records] + .filter(([, r]) => r.layer === layer) + .flatMap(([id, r]) => { + const live = registry.find((s) => s.id === id); + if (!live || live.count !== r.committed.length) + throw new Error(`Uncommitted mover count: ${id}`); + return r.committed.map((p, index) => ({ + setId: id, + index, + id: p.pickId ?? String(index), + position: p.position, + shown: live.shown && p.show !== false, + ready: r.set.ready(index), + })); + }); + }, + restore() { + offFrame(); + engine.movers.createSet = create; + }, + }; +} + +/** A settled camera alone does not mean asynchronous placement has committed. */ +export async function settledPositions(page, layer, count) { + await page.evaluate(() => { + window.qaSurfaceStable = null; + }); + const handle = await page + .waitForFunction( + (layer, count) => { + const { engine, dataManager, surfaceServices } = window.__godsEyeView; + engine.render.request('qa-surface-heights'); + const rows = window.qaSurfaceMovers.read(layer); + let ready = + engine.surface.tilesLoaded() && + rows.length === count && + rows.every( + (p) => + p.shown && + p.ready && + ['x', 'y', 'z'].every((axis) => + Number.isFinite(p.position?.[axis]), + ), + ); + if (layer === 'alpr') { + const mod = dataManager.layers.get('alpr-cameras').module; + ready &&= + !mod.getStats().loading && + mod + .getAnalystRecords() + .every((p) => + Number.isFinite( + surfaceServices.groundFloor.cachedGroundFloor(p.lat, p.lon), + ), + ); + } + if (layer === 'bikeshare') + ready &&= !dataManager.layers.get('bikeshare').module.getStats() + .loading; + const signature = JSON.stringify( + rows.map((p) => [ + p.setId, + p.id, + p.position && + ['x', 'y', 'z'].map((axis) => + Math.round(p.position[axis] * 1000), + ), + ]), + ); + const previous = window.qaSurfaceStable; + const drawn = window.qaSurfaceMovers.frames(); + const stable = + ready && previous?.signature === signature + ? previous.frames + (drawn > previous.drawn ? 1 : 0) + : 0; + window.qaSurfaceStable = { signature, frames: stable, drawn, rows }; + return stable >= 10 ? rows : false; + }, + { polling: 'raf', timeout: 90000 }, + layer, + count, + ) + .catch(async (cause) => { + const state = await page + .evaluate(() => window.qaSurfaceStable) + .catch(() => null); + throw new Error( + `Placement readiness failed for ${layer}: expected ${count} points; ${JSON.stringify(state)}`, + { cause }, + ); + }); + try { + return await handle.jsonValue(); + } finally { + await handle.dispose(); + } +}