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;