From b73fe82e27a1afcf381509f9c4e295e9869b1520 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 18:31:45 +0400 Subject: [PATCH] Let readiness waits know when the app owns the camera While the cockpit holds camera inputs or the camera follows a target, whenSettled no longer waits for enabled inputs or a still pose; it still waits for the engine, the restored view, drawn frames and no flight, and its timeout now prints what it saw (inputs, following, cockpit, last pose change). Scenarios that waited for the camera where they needed the picture wait for it instead: weather and Nepal imagery for their tiles and frames, imagery capture while a box drag holds inputs, and pixel and pick polling for drawn frames. The vessel click retries at the vessel's rendered position. --- scripts/qa-ground-layer-switch.mjs | 26 ++++- scripts/qa-imagery-contract.mjs | 7 +- scripts/qa-layer-handoff.mjs | 14 +-- scripts/qa-nepal-event-switch.mjs | 6 +- scripts/qa-open-layers-switch.mjs | 4 +- scripts/qa-traffic-engine-switch.mjs | 8 +- scripts/qa-weather-engine-switch.mjs | 34 +++++- scripts/qa-wildlife-switch.mjs | 4 +- scripts/qa-wind-engine-switch.mjs | 6 +- scripts/qa/ready.mjs | 64 +++++++++-- scripts/qa/ready.test.mjs | 160 +++++++++++++++++++++++++++ 11 files changed, 291 insertions(+), 42 deletions(-) diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index e4b032664..ab2bf42b5 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -1,4 +1,4 @@ -import { clickUntilSelected, whenPickable, whenSettled } from './qa/ready.mjs'; +import { clickUntilSelected, whenFramesDrawn } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -213,7 +213,7 @@ await strict.run({}, async (b) => { const deadline = Date.now() + 20000; let on = await capture(true, layerId); while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) { - await whenSettled(p); + await whenFramesDrawn(p); on = await sample(); } return on; @@ -328,7 +328,7 @@ await strict.run({}, async (b) => { { timeout: 15000 }, visible, ); - const click = async (lon, lat, predicate) => { + const click = async (lon, lat) => { const { x, y } = await p.evaluate( (world) => { const { engine } = window.__godsEyeView; @@ -339,7 +339,6 @@ await strict.run({}, async (b) => { }, fromDegrees(lon, lat), ); - if (predicate) await whenPickable(p, { x, y }, predicate); await p.mouse.click(x, y); }; for (const kind of ['navara', 'cesium', 'navara']) { @@ -400,11 +399,26 @@ await strict.run({}, async (b) => { await p.waitForFunction( () => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show, ); - const vesselPicked = (picked) => picked?.id === '123456789'; const selectVessel = () => clickUntilSelected( p, - () => click(-79.38, 43.65, vesselPicked), + async () => { + const { x, y } = await p.evaluate(() => { + const { engine } = window.__godsEyeView; + const vessel = window.qaVesselEntry.module + .qaDrawn() + .chevrons.find((row) => row.id === '123456789' && row.show); + const point = + vessel && engine.projector.worldToWindow(vessel.position); + if (!point) throw Error('the vessel is not in view'); + const box = engine.host.canvas.getBoundingClientRect(); + return { x: box.left + point.x, y: box.top + point.y }; + }); + // Vessels also resolve painted-card hits in their click handler; + // picker.pick cannot see those. Retry at the live anchor, including + // its sea datum, and verify selection through the real click path. + await p.mouse.click(x, y); + }, () => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789', ); // Click-picking on the running engine: clear the selection with a click on diff --git a/scripts/qa-imagery-contract.mjs b/scripts/qa-imagery-contract.mjs index 5b57f014a..5e037fe65 100644 --- a/scripts/qa-imagery-contract.mjs +++ b/scripts/qa-imagery-contract.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn, whenSettled, whenTilesLoaded } from './qa/ready.mjs'; // Recent imagery on a keyless preview, starting on Navara: the NASA catalog // and imagery tiles are fixtures, and the image, A/B, swap, divider, opacity, // basemap, toggle, failure, box-drag and zoom-to-fit checks read the rendered @@ -206,7 +206,10 @@ try { await whenSettled(page); const capture = async (name) => { await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa')); - await whenSettled(page); + // The box tool holds inputs until mouseup; this capture only needs its drawing. + await whenFramesDrawn(page); + await whenTilesLoaded(page, { timeoutMs: 90000 }); + await whenFramesDrawn(page); const buffer = await page.screenshot(); await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer); const pixels = await sharp(buffer) diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index 681dd13b7..8128c4a04 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -110,19 +110,7 @@ await strict.run({}, async (browser) => { await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await waitForEngine(page, 'navara'); // Cockpit mode deliberately holds camera inputs throughout these handoffs. - const attached = async (kind) => { - await page.waitForFunction( - (kind) => - window.__godsEyeView?.engine?.kind === kind && - document.querySelector('#globe-engine')?.value === kind && - !document.querySelector('#globe-engine:is([disabled], [aria-busy="true"])'), - { timeout: 90000 }, - kind, - ); - await page.evaluate( - () => window.__godsEyeView.styleManager.initialRestorePromise, - ); - }; + const attached = (kind) => waitForEngine(page, kind, { timeout: 90000 }); await page.evaluate( () => window.__godsEyeView.styleManager.initialRestorePromise, ); diff --git a/scripts/qa-nepal-event-switch.mjs b/scripts/qa-nepal-event-switch.mjs index b686a3ddf..6992277a8 100644 --- a/scripts/qa-nepal-event-switch.mjs +++ b/scripts/qa-nepal-event-switch.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { @@ -90,7 +90,9 @@ await strict.run({}, async (b) => { }); }); const sample = async () => { - await whenSettled(p); + await whenFramesDrawn(p); + await whenTilesLoaded(p); + await whenFramesDrawn(p); return sharp(await p.screenshot()) .extract({ left: 490, top: 370, width: 25, height: 25 }) .removeAlpha() diff --git a/scripts/qa-open-layers-switch.mjs b/scripts/qa-open-layers-switch.mjs index 826a70994..f73d5eda2 100644 --- a/scripts/qa-open-layers-switch.mjs +++ b/scripts/qa-open-layers-switch.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn } from './qa/ready.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { @@ -152,7 +152,7 @@ await strict.run({}, async (b) => { const deadline = Date.now() + 20000; let on = await sample(); while (!test(on, off) && Date.now() < deadline) { - await whenSettled(p); + await whenFramesDrawn(p); on = await sample(); } return on; diff --git a/scripts/qa-traffic-engine-switch.mjs b/scripts/qa-traffic-engine-switch.mjs index 94e61dfaf..eee8cdfa3 100644 --- a/scripts/qa-traffic-engine-switch.mjs +++ b/scripts/qa-traffic-engine-switch.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn, whenSettled, whenTilesLoaded } from './qa/ready.mjs'; import { trafficFixtureResponse } from './traffic-fixtures.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -157,7 +157,7 @@ await strict.run({}, async (b) => { 'traffic did not settle with 12 vehicles in view: ' + JSON.stringify({ loading, inView: rows.length }), ); - await whenSettled(p); + await whenFramesDrawn(p); } }; // The population once roads, heights and dots have settled. Source counts @@ -165,6 +165,8 @@ await strict.run({}, async (b) => { // attached road heights are sampled after drawn frames on either engine. const settledPopulation = async () => { await whenSettled(p); + await whenTilesLoaded(p); + await whenFramesDrawn(p); await settled(); return p.evaluate(() => window.qaTraffic.getStats().count); }; @@ -252,7 +254,7 @@ await strict.run({}, async (b) => { origin: 'user', }), ); - await whenSettled(p); + await whenFramesDrawn(p); const hidden = await raw(); const { width, height } = shown.info; const scale = width / 1024; diff --git a/scripts/qa-weather-engine-switch.mjs b/scripts/qa-weather-engine-switch.mjs index 452596828..7c3f6672f 100644 --- a/scripts/qa-weather-engine-switch.mjs +++ b/scripts/qa-weather-engine-switch.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs'; import { fromDegrees } from '../src/geo/cartesian3.js'; import sharp from 'sharp'; import assert from 'node:assert/strict'; @@ -183,7 +183,37 @@ await strict.run({}, async (b) => { time, ); const sample = async () => { - await whenSettled(p); + const handle = await p.waitForFunction( + (ids) => { + const { engine, dataManager } = window.__godsEyeView; + engine.render.request('qa-weather-imagery'); + const active = ids.filter((id) => dataManager.isEnabled(id)); + return ( + active.length > 0 && + active.every((id) => { + const state = dataManager.layers.get(id).module.getDiagnostics(); + return ( + state.time && !state.loading && !state.hidden && !state.error + ); + }) && + engine.imagery + .list() + .filter( + (layer) => + layer.id.startsWith('weather-') && + layer.shown && + layer.alpha > 0, + ).length >= active.length + ); + }, + { polling: 100, timeout: 50000 }, + ids, + ); + await handle.dispose(); + // A staged frame can be current before the newly shown layer has painted. + await whenFramesDrawn(p); + await whenTilesLoaded(p); + await whenFramesDrawn(p); const crop = await sharp(await p.screenshot()) .extract({ left: 535, top: 388, width: 12, height: 12 }) .removeAlpha() diff --git a/scripts/qa-wildlife-switch.mjs b/scripts/qa-wildlife-switch.mjs index eadef2bab..cedac05d2 100644 --- a/scripts/qa-wildlife-switch.mjs +++ b/scripts/qa-wildlife-switch.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn } from './qa/ready.mjs'; import assert from 'node:assert/strict'; import { fromDegrees } from '../src/geo/cartesian3.js'; import { fromCartesian } from '../src/geo/cartographic.js'; @@ -162,7 +162,7 @@ await strict.run({}, async (b) => { (badge) => badge.picked?.id === `wildlife:${badge.id}`, ); if (ok || Date.now() > deadline) return drawn; - await whenSettled(p); + await whenFramesDrawn(p); } }; const degrees = (position) => { diff --git a/scripts/qa-wind-engine-switch.mjs b/scripts/qa-wind-engine-switch.mjs index 1a454f4dd..b705da7aa 100644 --- a/scripts/qa-wind-engine-switch.mjs +++ b/scripts/qa-wind-engine-switch.mjs @@ -1,4 +1,4 @@ -import { whenSettled } from './qa/ready.mjs'; +import { whenFramesDrawn } from './qa/ready.mjs'; import sharp from 'sharp'; import assert from 'node:assert/strict'; import { @@ -96,7 +96,7 @@ await strict.run({}, async (b) => { window.qaWind.getRowControls().summary.reading?.speed === 10, { timeout: 90000 }, ); - await whenSettled(p); + await whenFramesDrawn(p); const check = await p.evaluate(() => { const { dataManager } = window.__godsEyeView; const mod = dataManager.layers.get('wind').module; @@ -162,7 +162,7 @@ await strict.run({}, async (b) => { const frozen = await p.evaluate( () => window.qaWind.getDiagnostics().gpu.phaseSeconds, ); - await whenSettled(p); + await whenFramesDrawn(p); await strict.check( p, 'wind', diff --git a/scripts/qa/ready.mjs b/scripts/qa/ready.mjs index bcac3dc97..4609aa049 100644 --- a/scripts/qa/ready.mjs +++ b/scripts/qa/ready.mjs @@ -1,6 +1,6 @@ import { createSettledState } from '../../src/app/qaProbe.js'; -/** A CPU-ready pick can precede the GPU hit delivered for a real click. */ +/** Verify real click selection, including targets the CPU picker cannot answer. */ export async function clickUntilSelected(page, click, selected) { for (let attempt = 0; attempt < 5; attempt++) { await click(); @@ -72,7 +72,7 @@ export async function whenSettled( }); } catch { throw new Error( - 'Readiness timeout: engineStarted, initialViewRestored, frameDrawn, noFlight, cameraInputsEnabled, cameraPoseStable', + 'Readiness timeout: engineStarted, initialViewRestored, frameDrawn, noFlight, cameraInputsEnabled, cameraPoseStable; observed: inputsEnabled=unknown, following=unknown, cockpitActive=unknown, lastPoseDelta=null', ); } return page.evaluate( @@ -85,6 +85,33 @@ export function waitForSettled(createState, { frames, timeoutMs }) { return new Promise((resolve, reject) => { const { engine } = window.__godsEyeView; const state = createState(engine); + let previousPose = null; + let lastPoseDelta = null; + const statusOf = () => { + const status = state.settled({ frames }); + const started = status.conditions.engineStarted; + const following = started && Boolean(engine.camera.following()); + const cockpitActive = Boolean( + window.__godsEyeView?.styleManager?.getCockpitState?.()?.active, + ); + status.observed = { + inputsEnabled: status.conditions.cameraInputsEnabled, + following, + cockpitActive, + lastPoseDelta, + }; + // Only a live camera owner excuses motion or held inputs, never a scenario flag. + if (following || cockpitActive) { + status.conditions.cameraInputsEnabled = true; + status.conditions.cameraPoseStable = true; + } + status.conditions.frameDrawn = status.framesDrawn >= frames; + status.unmet = Object.keys(status.conditions).filter( + (key) => !status.conditions[key], + ); + status.settled = !status.unmet.length; + return status; + }; let finished = false; let remove = () => {}; const finish = (error, result) => { @@ -97,19 +124,37 @@ export function waitForSettled(createState, { frames, timeoutMs }) { else resolve(result); }; const timer = setTimeout(() => { - const status = state.settled({ frames }); + const status = statusOf(); if (window.__godsEyeView?.engine !== engine) status.unmet.push('engineUnchanged'); finish( new Error( - `Readiness timeout: ${status.unmet.join(', ')}${status.restoreError ? ': ' + status.restoreError : ''}`, + `Readiness timeout: ${status.unmet.join(', ')}${status.restoreError ? ': ' + status.restoreError : ''}; observed: ${JSON.stringify(status.observed)}`, ), ); }, timeoutMs); remove = engine.frames.postRender.addEventListener(() => { - const status = state.settled({ frames }); if (window.__godsEyeView?.engine !== engine) return finish(new Error('Readiness interrupted: engineChanged')); + if (!engine.isDestroyed()) { + const pose = engine.camera.pose(); + const values = ['position', 'direction', 'up'].flatMap((key) => + ['x', 'y', 'z'].map((axis) => pose[key][axis]), + ); + values.push(pose.fovy, pose.aspectRatio); + if (previousPose) { + const delta = values.map((value, i) => + Math.abs(value - previousPose[i]), + ); + lastPoseDelta = { + positionM: Math.hypot(...delta.slice(0, 3)), + orientation: Math.max(...delta.slice(3, 9)), + projection: Math.max(...delta.slice(9)), + }; + } + previousPose = values; + } + const status = statusOf(); if (status.settled) finish(null, status); else queueMicrotask(() => { @@ -120,7 +165,7 @@ export function waitForSettled(createState, { frames, timeoutMs }) { engine.render.request('qa-readiness'); }); } -/** Wait for drawn frames when the scenario deliberately keeps a follow or orbit moving. */ +/** Wait for drawing independently of camera rest or input ownership. */ export async function whenFramesDrawn( page, { frames = 3, timeoutMs = 30000 } = {}, @@ -151,7 +196,12 @@ export async function whenFramesDrawn( /** Tile readiness is independent of camera rest, including a keyless globe. */ export async function whenTilesLoaded(page, { timeoutMs = 30000 } = {}) { const handle = await page.waitForFunction( - () => window.__godsEyeView?.engine?.surface.tilesLoaded(), + () => { + const engine = window.__godsEyeView?.engine; + if (!engine || engine.isDestroyed()) return false; + engine.render.request('qa-tile-readiness'); + return engine.surface.tilesLoaded(); + }, { timeout: timeoutMs, polling: 100 }, ); await handle.dispose(); diff --git a/scripts/qa/ready.test.mjs b/scripts/qa/ready.test.mjs index f82153e38..cda74e583 100644 --- a/scripts/qa/ready.test.mjs +++ b/scripts/qa/ready.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { createSettledState } from '../../src/app/qaProbe.js'; import { + waitForSettled, whenFramesDrawn, whenTilesLoaded, whenUiSettled, @@ -9,6 +11,164 @@ import { until, } from './ready.mjs'; +function pageEngine(t) { + const engine = createFakeEngine(); + const previous = globalThis.window; + const app = { + engine, + styleManager: { + initialRestorePromise: Promise.resolve(), + getCockpitState: () => ({ active: false }), + }, + }; + globalThis.window = { __godsEyeView: app }; + t.after(() => { + if (previous === undefined) delete globalThis.window; + else globalThis.window = previous; + engine.destroy(); + }); + return app; +} + +test('cockpit and follow readiness allow held inputs and moving poses after drawn frames', async (t) => { + for (const owner of ['cockpit', 'follow']) { + await t.test(owner, async (t) => { + const { engine, styleManager } = pageEngine(t); + engine.input.suspendCameraInputs(); + let drawn = 0; + if (owner === 'cockpit') + styleManager.getCockpitState = () => ({ active: true }); + else + engine.camera.follow({ + position: () => ({ x: 6378137, y: drawn * 10, z: 0 }), + viewFrom: { x: 0, y: -100, z: 100 }, + }); + t.mock.method(engine.render, 'request', () => + setImmediate(() => { + engine.fake.setPose({ heading: ++drawn / 100 }); + engine.fake.frame(); + }), + ); + const status = await waitForSettled(createSettledState, { + frames: 3, + timeoutMs: 1000, + }); + assert.equal(status.settled, true); + assert.equal(status.framesDrawn, 3); + assert.equal(status.observed.inputsEnabled, false); + assert.equal(status.observed.cockpitActive, owner === 'cockpit'); + assert.equal(status.observed.following, owner === 'follow'); + assert.ok(status.observed.lastPoseDelta.orientation > 0); + assert.equal(engine.frames.postRender.numberOfListeners, 0); + }); + } +}); + +test('camera owners do not excuse restoration, flights, missing frames or destruction', async (t) => { + for (const unmet of [ + 'initialViewRestored', + 'noFlight', + 'frameDrawn', + 'engineStarted', + ]) { + await t.test(unmet, async (t) => { + const { engine, styleManager } = pageEngine(t); + styleManager.getCockpitState = () => ({ active: true }); + engine.input.suspendCameraInputs(); + if (unmet === 'initialViewRestored') + styleManager.initialRestorePromise = new Promise(() => {}); + if (unmet === 'noFlight') + engine.camera.flyTo({ + destination: { x: 7000000, y: 0, z: 0 }, + duration: 1, + }); + t.mock.method(engine.render, 'request', () => {}); + const pending = waitForSettled(createSettledState, { + frames: 3, + timeoutMs: 10, + }); + await Promise.resolve(); + if (unmet === 'engineStarted') { + engine.destroy(); + t.mock.method(engine.camera, 'pose', () => { + throw Error('released camera'); + }); + } else engine.fake.frames(unmet === 'frameDrawn' ? 2 : 4); + await assert.rejects(pending, (error) => { + assert.match(error.message, new RegExp(`Readiness timeout: ${unmet}`)); + assert.match(error.message, /"cockpitActive":true/); + return true; + }); + assert.equal(engine.frames.postRender.numberOfListeners, 0); + }); + } +}); + +test('unowned input locks and moving cameras time out with observed state', async (t) => { + for (const moving of [false, true]) { + await t.test(moving ? 'moving' : 'locked', async (t) => { + const { engine } = pageEngine(t); + if (!moving) engine.input.suspendCameraInputs(); + t.mock.method(engine.render, 'request', () => {}); + const pending = waitForSettled(createSettledState, { + frames: 3, + timeoutMs: 10, + }); + await Promise.resolve(); + for (let i = 0; i < 5; i++) { + if (moving) + engine.fake.setPose({ position: { x: 7000000 + i, y: 0, z: 0 } }); + engine.fake.frame(); + } + await assert.rejects(pending, (error) => { + assert.match(error.message, /cameraPoseStable/); + if (!moving) assert.match(error.message, /cameraInputsEnabled/); + const observed = JSON.parse(error.message.split('; observed: ')[1]); + assert.deepEqual(observed, { + inputsEnabled: moving, + following: false, + cockpitActive: false, + lastPoseDelta: { + positionM: moving ? 1 : 0, + orientation: 0, + projection: 0, + }, + }); + return true; + }); + }); + } +}); + +test('leaving a camera owner restores strict input and pose requirements', async (t) => { + for (const owner of ['cockpit', 'follow']) { + await t.test(owner, async (t) => { + const { engine, styleManager } = pageEngine(t); + engine.input.suspendCameraInputs(); + let active = owner === 'cockpit'; + styleManager.getCockpitState = () => ({ active }); + const follow = + owner === 'follow' + ? engine.camera.follow({ + position: () => ({ x: 6378137, y: 0, z: 0 }), + viewFrom: { x: 0, y: -100, z: 100 }, + }) + : null; + t.mock.method(engine.render, 'request', () => {}); + const pending = waitForSettled(createSettledState, { + frames: 3, + timeoutMs: 10, + }); + await Promise.resolve(); + engine.fake.frames(2); + active = false; + follow?.stop(); + engine.fake.frames(4); + await assert.rejects(pending, /cameraInputsEnabled, cameraPoseStable/); + }); + } +}); + test('frame readiness advances a request-mode engine and removes its listener', async (t) => { const engine = createFakeEngine(); const previous = globalThis.window;