diff --git a/scripts/qa-cables-overlay.mjs b/scripts/qa-cables-overlay.mjs index 59a65c6b1..c1e4cbfe9 100644 --- a/scripts/qa-cables-overlay.mjs +++ b/scripts/qa-cables-overlay.mjs @@ -2,6 +2,7 @@ // Cesium-only probes: native DataSource counts and scene-render timing measure the Cesium cable adapter. import { evaluateWithGeo } from './qa/geometry.mjs'; import { whenSettled } from './qa/ready.mjs'; +import { measureIdleRender } from './qa/idle.mjs'; import { launchBrowser, closeBrowser } from './qa/browser.mjs'; /** * qa-cables-overlay — submarine-cable label path measurement + contract gate. @@ -184,12 +185,8 @@ try { : 'scene.render cost, cables ON, 10s orbit', frameCost); // ── d. parked idle honesty (labels must not force continuous render) ── - await whenSettled(page); // orbit stop + fades settle - const idle = await page.evaluate(() => new Promise((resolve) => { - let renders = 0; - const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { renders += 1; }); - setTimeout(() => { remove(); resolve({ renders }); }, 5_000); - })); + await whenSettled(page); + const idle = await measureIdleRender(page); report(control ? 'parked idle, control (postRender fires / 5s)' : 'parked idle with cables ON (postRender fires / 5s)', idle); diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 17e3db98d..d593c6a05 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Cesium-only probes: native primitive inspection independently verifies the Cesium route geometry. import { evaluateWithGeo } from './qa/geometry.mjs'; -import { whenSettled, whenUiSettled, whenTilesLoaded } from './qa/ready.mjs'; +import { whenSettled, whenUiSettled, pollPage } from './qa/ready.mjs'; /** * Browser proof of the Directions layer: real chip clicks, real globe clicks, * a real route from the real proxy, a real camera flight. @@ -779,7 +779,14 @@ try { if (placed) { await waitForRoute(); // Let the shared ground-floor cells warm and the dots re-anchor. - await whenTilesLoaded(page); + await pollPage( + page, + () => { + const { stepCount, stepsShown } = window.__gevQaDirections.drawn(); + return stepCount > 0 && stepsShown.length === stepCount; + }, + { timeoutMs: 60_000 }, + ); const denver = await evaluateWithGeo(page, async (qaGeo) => { const Cartographic = qaGeo.cartographic; const { stepCount, stepsShown } = window.__gevQaDirections.drawn(); diff --git a/scripts/qa-final-polish-credits.mjs b/scripts/qa-final-polish-credits.mjs index 3e0e2aab5..eeeba0ad1 100644 --- a/scripts/qa-final-polish-credits.mjs +++ b/scripts/qa-final-polish-credits.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** Persistent inline attribution through actual OSM layer display lifetimes. */ -import { whenSettled } from './qa/ready.mjs'; +import { whenSettled, whenUiSettled } from './qa/ready.mjs'; import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; @@ -53,6 +53,7 @@ try { ); const shot = async (name, osm, tiles) => { await whenSettled(page); + await whenUiSettled(page); const state = await page.$eval('#cesium-credits', (e) => { const nodes = [ ...e.querySelectorAll('a,img,.cesium-credit-expand-link'), diff --git a/scripts/qa-infra-lod.mjs b/scripts/qa-infra-lod.mjs index 808069e28..8b9895940 100644 --- a/scripts/qa-infra-lod.mjs +++ b/scripts/qa-infra-lod.mjs @@ -27,6 +27,7 @@ import { evaluateWithGeo } from './qa/geometry.mjs'; * FAIL. Commits nothing. */ import { whenSettled } from './qa/ready.mjs'; +import { measureIdleRender } from './qa/idle.mjs'; import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import path from 'node:path'; @@ -297,27 +298,7 @@ try { // ── parked-idle honesty: the LOD walk must not force continuous render ─ await whenSettled(page); - const idle = await page.evaluate(async () => { - // The debug handle's own governor read (no /src import: runs on a build). - const { getRenderGovernorDiagnostics } = window.__godsEyeView; - return new Promise((resolve) => { - const startedAt = Date.now(); - const tilesLoadedBefore = window.__godsEyeView.engine.surface.tilesLoaded(); - let renders = 0; - let framesWithPendingTiles = 0; - const remove = window.__godsEyeView.engine.frames.postRender.addEventListener(() => { - renders += 1; - if (!window.__godsEyeView.engine.surface.tilesLoaded()) framesWithPendingTiles += 1; - }); - setTimeout(() => { - remove(); - const governor = getRenderGovernorDiagnostics(); - resolve({ renders, tilesLoadedBefore, tilesLoadedAfter: window.__godsEyeView.engine.surface.tilesLoaded(), - framesWithPendingTiles, mode: governor.mode, holds: governor.holds, - requests: governor.recentRequests.filter(request => request.at >= startedAt) }); - }, 5_000); - }); - }); + const idle = await measureIdleRender(page); report(CONTROL ? 'parked idle, control (postRender fires / 5s)' : 'parked idle with infra ON (postRender fires / 5s)', idle); diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index 6c2b60072..186a5355f 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -1,10 +1,13 @@ #!/usr/bin/env node /** Reticle coverage on oblique arrivals: source requests, toggle oracle and zoom revisits. */ import { whenSettled } from './qa/ready.mjs'; +import { + moveTrafficCamera as moveCamera, + whenTrafficSettled, +} from './qa/traffic.mjs'; import { launchBrowser, closeBrowser } from './qa/browser.mjs'; import fs from 'node:fs'; import { - moveCamera, createScreencast, installFrameProbe, } from './qa-journey-recorder.mjs'; @@ -96,19 +99,7 @@ try { }; }); const settle = async () => { - await whenSettled(page); - await page - .waitForFunction( - () => { - const s = window.__godsEyeView.dataManager.layers - .get('traffic') - .module.getStats(); - return !s.loading; - }, - { timeout: 15000 }, - ) - .catch(() => {}); - await whenSettled(page); + await whenTrafficSettled(page); return snap(); }; for (const [name, lat, lon, height] of [ @@ -306,6 +297,11 @@ try { fs.writeFileSync(`${out}/result.json`, JSON.stringify(result, null, 2)); await closeBrowser(browser); } +console.log(JSON.stringify({ + failures: result.failures, + errors: result.errors, + forbidden: result.forbidden, +})); if ( !baseline && (result.failures.length || result.errors.length || result.forbidden.length) diff --git a/scripts/qa/idle.mjs b/scripts/qa/idle.mjs new file mode 100644 index 000000000..069d6908b --- /dev/null +++ b/scripts/qa/idle.mjs @@ -0,0 +1,92 @@ +/** Observe a quiet request-mode globe, then measure it without requesting frames. */ +export function measureIdleRender( + page, + { quietMs = 600, sampleMs = 5000, timeoutMs = 15000 } = {}, +) { + return page.evaluate( + ({ quietMs, sampleMs, timeoutMs }) => + new Promise((resolve, reject) => { + const { engine } = window.__godsEyeView; + const started = performance.now(); + let lastActivity = started; + let measuringAt = null; + let renders = 0; + let framesWithPendingTiles = 0; + let tilesLoadedBefore = false; + const settlingRequests = {}; + const requests = {}; + const original = engine.render.request; + const observedRequest = function (reason = 'globe-engine') { + const now = performance.now(); + lastActivity = now; + const counts = measuringAt === null ? settlingRequests : requests; + const row = (counts[reason] ??= { + count: 0, + firstMs: now - started, + lastMs: 0, + }); + row.count++; + row.lastMs = now - started; + return original.apply(this, arguments); + }; + engine.render.request = observedRequest; + const remove = engine.frames.postRender.addEventListener(() => { + lastActivity = performance.now(); + if (measuringAt === null) return; + renders++; + if (!engine.surface.tilesLoaded()) framesWithPendingTiles++; + }); + const cleanup = () => { + clearInterval(timer); + remove(); + if (engine.render.request === observedRequest) + engine.render.request = original; + }; + const timer = setInterval(() => { + if (window.__godsEyeView.engine !== engine || engine.isDestroyed()) { + cleanup(); + reject(new Error('Idle observation interrupted: engine changed')); + return; + } + const now = performance.now(); + const tilesLoaded = engine.surface.tilesLoaded(); + if (measuringAt === null) { + if ( + !tilesLoaded || + engine.camera.flightActive() || + engine.render.isContinuous() + ) + lastActivity = now; + if (now - lastActivity >= quietMs) { + measuringAt = now; + tilesLoadedBefore = tilesLoaded; + } else if (now - started >= timeoutMs) { + cleanup(); + reject( + new Error( + `Readiness timeout: idle render; ${JSON.stringify({ tilesLoaded, settlingRequests })}`, + ), + ); + } + return; + } + if (now - measuringAt < sampleMs) return; + cleanup(); + const governor = + window.__godsEyeView.getRenderGovernorDiagnostics?.(); + resolve({ + renders, + settleMs: Math.round(measuringAt - started), + settlingRequests, + requests, + tilesLoadedBefore, + tilesLoadedAfter: tilesLoaded, + framesWithPendingTiles, + mode: governor?.mode, + holds: governor?.holds, + }); + }, 50); + }), + { quietMs, sampleMs, timeoutMs }, + ); +} diff --git a/scripts/qa/idle.test.mjs b/scripts/qa/idle.test.mjs new file mode 100644 index 000000000..56437ba30 --- /dev/null +++ b/scripts/qa/idle.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { measureIdleRender } from './idle.mjs'; + +function fixture(t) { + const engine = createFakeEngine(); + const previous = globalThis.window; + globalThis.window = { __godsEyeView: { engine } }; + let now = 0; + t.mock.method(performance, 'now', () => now); + t.mock.timers.enable({ apis: ['setInterval'] }); + t.after(() => { + if (previous === undefined) delete globalThis.window; + else globalThis.window = previous; + engine.destroy(); + }); + return { + engine, + page: { evaluate: (fn, options) => fn(options) }, + tick(ms) { + now += ms; + t.mock.timers.tick(ms); + }, + }; +} + +test('idle measurement waits out fades, records reasons, and never requests a frame', async (t) => { + const { engine, page, tick } = fixture(t); + const original = engine.render.request; + const pending = measureIdleRender(page, { quietMs: 100, sampleMs: 200 }); + engine.render.request('world-overlay'); + engine.fake.frame(); + tick(50); + engine.render.request('world-overlay'); + engine.fake.frame(); + tick(50); + tick(50); + engine.render.request('hud'); + engine.fake.frame(); + tick(200); + const result = await pending; + assert.equal(result.settleMs, 150); + assert.equal(result.renders, 1); + assert.equal(result.settlingRequests['world-overlay'].count, 2); + assert.equal(result.requests.hud.count, 1); + assert.equal(result.framesWithPendingTiles, 0); + assert.equal(engine.fake.callsOf('render.request').length, 3); + assert.equal(engine.render.request, original); + assert.equal(engine.frames.postRender.numberOfListeners, 0); + tick(1000); + assert.equal(engine.fake.callsOf('render.request').length, 3); +}); + +test('persistent render demand fails with its reason and releases instrumentation', async (t) => { + const { engine, page, tick } = fixture(t); + const original = engine.render.request; + const pending = measureIdleRender(page, { quietMs: 100, timeoutMs: 300 }); + const rejected = assert.rejects(pending, /idle render.*world-overlay/); + for (let i = 0; i < 6; i++) { + engine.render.request('world-overlay'); + engine.fake.frame(); + tick(50); + } + await rejected; + assert.equal(engine.render.request, original); + assert.equal(engine.frames.postRender.numberOfListeners, 0); + assert.equal(engine.fake.callsOf('render.request').length, 6); +}); + +test('continuous mode and pending tiles cannot masquerade as idle', async (t) => { + for (const condition of ['continuous', 'tiles']) { + await t.test(condition, async (t) => { + const { engine, page, tick } = fixture(t); + if (condition === 'continuous') engine.render.setContinuous(true); + else t.mock.method(engine.surface, 'tilesLoaded', () => false); + const pending = measureIdleRender(page, { quietMs: 100, timeoutMs: 300 }); + const rejected = assert.rejects( + pending, + /Readiness timeout: idle render/, + ); + tick(300); + await rejected; + assert.equal(engine.fake.callsOf('render.request').length, 0); + }); + } +}); + +test('engine destruction stops the idle observer', async (t) => { + const { engine, page, tick } = fixture(t); + const original = engine.render.request; + const pending = measureIdleRender(page); + const rejected = assert.rejects(pending, /engine changed/); + engine.destroy(); + tick(50); + await rejected; + assert.equal(engine.render.request, original); +}); + +test('renders during the sample stay counted even when demand becomes continuous', async (t) => { + const { engine, page, tick } = fixture(t); + const pending = measureIdleRender(page, { quietMs: 100, sampleMs: 500 }); + tick(100); + for (let i = 0; i < 10; i++) { + engine.render.request('world-overlay'); + engine.fake.frame(); + tick(50); + } + const result = await pending; + assert.equal(result.renders, 10); + assert.equal(result.requests['world-overlay'].count, 10); +}); diff --git a/scripts/qa/ready.mjs b/scripts/qa/ready.mjs index 9ea79cf38..ef75ffeb6 100644 --- a/scripts/qa/ready.mjs +++ b/scripts/qa/ready.mjs @@ -66,13 +66,18 @@ export async function whenSettled( ) { const started = Date.now(); try { - await page.waitForFunction(() => window.__godsEyeView?.engine, { - timeout: timeoutMs, - polling: 50, - }); + const handle = await page.waitForFunction( + () => { + if (!window.__godsEyeView?.engine) return false; + const cover = globalThis.document?.querySelector('#loading-screen'); + return !cover || getComputedStyle(cover).visibility === 'hidden'; + }, + { timeout: timeoutMs, polling: 50 }, + ); + await handle?.dispose(); } catch { throw new Error( - 'Readiness timeout: engineStarted, initialViewRestored, frameDrawn, noFlight, cameraInputsEnabled, cameraPoseStable; observed: inputsEnabled=unknown, following=unknown, cockpitActive=unknown, lastPoseDelta=null', + 'Readiness timeout: engineStarted, initialViewRestored, startupUiReady, frameDrawn, noFlight, cameraInputsEnabled, cameraPoseStable; observed: inputsEnabled=unknown, following=unknown, cockpitActive=unknown, lastPoseDelta=null', ); } return page.evaluate( @@ -205,7 +210,6 @@ export async function whenTilesLoaded(page, { timeoutMs = 30000 } = {}) { () => { 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 }, @@ -217,7 +221,6 @@ export async function whenUiSettled(page) { const handle = await page.waitForFunction( () => { const app = window.__godsEyeView; - app?.engine?.render.request('qa-ui-readiness'); return ( !app?.styleManager?.transitions?.size && document diff --git a/scripts/qa/ready.test.mjs b/scripts/qa/ready.test.mjs index 2f3ca759f..cff54c5bb 100644 --- a/scripts/qa/ready.test.mjs +++ b/scripts/qa/ready.test.mjs @@ -4,6 +4,7 @@ import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; import { createSettledState } from '../../src/app/qaProbe.js'; import { waitForSettled, + whenSettled, whenFramesDrawn, whenTilesLoaded, whenUiSettled, @@ -252,3 +253,57 @@ test('scenario polling waits for evidence and identifies an unmet condition', as /broken predicate/, ); }); + +test('camera readiness also waits for the startup cover to stop intercepting clicks', async (t) => { + pageEngine(t); + const previous = { + document: globalThis.document, + getComputedStyle: globalThis.getComputedStyle, + }; + let visibility = 'visible'; + globalThis.document = { querySelector: () => ({}) }; + globalThis.getComputedStyle = () => ({ visibility }); + t.after(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }); + let disposed = false; + await whenSettled({ + async waitForFunction(predicate) { + assert.equal(predicate(), false, 'a started engine still has a cover'); + visibility = 'hidden'; + assert.equal(predicate(), true); + return { + dispose: async () => { + disposed = true; + }, + }; + }, + async evaluate() { + assert.equal(disposed, true); + }, + }); +}); + +test('tile and UI polling never request globe frames', async (t) => { + const { engine } = pageEngine(t); + const previous = globalThis.document; + globalThis.document = { getAnimations: () => [] }; + t.after(() => { + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + }); + t.mock.method(engine.render, 'request', () => { + assert.fail('passive readiness requested a frame'); + }); + const page = { + async waitForFunction(predicate) { + assert.equal(predicate(), true); + return { dispose: async () => {} }; + }, + }; + await whenTilesLoaded(page); + await whenUiSettled(page); +}); diff --git a/scripts/qa/traffic.mjs b/scripts/qa/traffic.mjs new file mode 100644 index 000000000..abc603e0b --- /dev/null +++ b/scripts/qa/traffic.mjs @@ -0,0 +1,69 @@ +import { evaluateWithGeo } from './geometry.mjs'; +import { whenSettled, whenTilesLoaded } from './ready.mjs'; + +/** Preserve the oblique fixture's native collision and flight-arc settings. */ +export function moveTrafficCamera(page, view, seconds) { + return evaluateWithGeo( + page, + async (geo, view, duration) => { + const { engine, viewer } = window.__godsEyeView; + const native = engine.kind === 'cesium'; + const camera = native ? viewer.camera : engine.camera; + // These low-altitude fixture poses must not be lifted by native collision. + if (native) + viewer.scene.screenSpaceCameraController.enableCollisionDetection = false; + camera.cancelFlight(); + await new Promise((resolve) => + camera.flyTo({ + destination: geo.cartesian3.fromDegrees( + view.lon, + view.lat, + view.height, + ), + orientation: { + heading: ((view.heading ?? 0) * Math.PI) / 180, + pitch: ((view.pitch ?? -45) * Math.PI) / 180, + roll: 0, + }, + duration, + // An automatic high arc crosses the traffic layer's activation ceiling. + maximumHeight: Math.max(view.height, engine.camera.height()), + complete: resolve, + cancel: resolve, + }), + ); + }, + view, + seconds, + ); +} + +/** A quiet loader can still be waiting for its camera-change debounce. */ +export async function whenTrafficSettled(page) { + await whenTilesLoaded(page); + await whenSettled(page); + const handle = await page.waitForFunction( + () => { + const { engine, dataManager } = window.__godsEyeView; + const pose = engine.camera.pose(); + const viewKey = [ + pose.cartographic.latitude, + pose.cartographic.longitude, + pose.cartographic.height / 10000, + pose.heading, + pose.pitch, + ] + .map((value) => Number(value || 0).toFixed(7)) + .join(','); + const stats = dataManager.layers.get('traffic').module.getStats(); + return ( + stats.roadBounds?.coverage?.viewKey === viewKey && + stats.count > 0 && + !stats.loading && + !stats.surfacePending + ); + }, + { timeout: 60000, polling: 100 }, + ); + await handle.dispose(); +} diff --git a/scripts/qa/traffic.test.mjs b/scripts/qa/traffic.test.mjs new file mode 100644 index 000000000..e44a40720 --- /dev/null +++ b/scripts/qa/traffic.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js'; +import { moveTrafficCamera, whenTrafficSettled } from './traffic.mjs'; + +function fixture(t) { + const engine = createFakeEngine({ kind: 'cesium' }); + engine.camera.setView({ + destination: { x: 6379137, y: 0, z: 0 }, + orientation: { heading: 0, pitch: -Math.PI / 12, roll: 0 }, + }); + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { __godsEyeView: { engine } }; + globalThis.document = { querySelector: () => null }; + t.after(() => { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + engine.destroy(); + }); + return globalThis.window.__godsEyeView; +} + +test('oblique fixture preserves collision and maximum flight height on Cesium', async (t) => { + const app = fixture(t); + let flight; + let cancelled = false; + app.viewer = { + scene: { screenSpaceCameraController: { enableCollisionDetection: true } }, + camera: { + cancelFlight() { + cancelled = true; + }, + flyTo(options) { + flight = options; + options.complete(); + }, + }, + }; + await moveTrafficCamera( + { evaluate: (fn, ...args) => fn(...args) }, + { lon: 55.037, lat: 24.964, height: 276, pitch: -15 }, + 1.4, + ); + assert.equal(cancelled, true); + assert.equal( + app.viewer.scene.screenSpaceCameraController.enableCollisionDetection, + false, + ); + assert.ok(Math.abs(flight.maximumHeight - 1000) < 1e-6); + assert.equal(flight.duration, 1.4); + assert.equal(flight.orientation.pitch, -Math.PI / 12); +}); + +test('traffic readiness rejects old coverage, pending debounce and partial populations', async (t) => { + const app = fixture(t); + const stats = { + count: 0, + loading: false, + surfacePending: 0, + roadBounds: null, + }; + app.dataManager = { + layers: new Map([['traffic', { module: { getStats: () => stats } }]]), + }; + let calls = 0; + let disposed = 0; + await whenTrafficSettled({ + evaluate: async () => {}, + async waitForFunction(predicate) { + if (++calls < 3) assert.equal(predicate(), true); + else { + assert.equal( + predicate(), + false, + 'idle before the debounce is not loaded', + ); + stats.count = 100; + stats.roadBounds = { coverage: { viewKey: 'old view' } }; + assert.equal(predicate(), false, 'old roads do not cover this camera'); + stats.roadBounds.coverage.viewKey = + '0.0000000,0.0000000,0.1000000,0.0000000,-0.2617994'; + stats.loading = true; + assert.equal(predicate(), false, 'partial road population'); + stats.loading = false; + stats.surfacePending = 1; + assert.equal(predicate(), false, 'unresolved surface'); + stats.surfacePending = 0; + assert.equal(predicate(), true); + } + return { + dispose: async () => { + disposed++; + }, + }; + }, + }); + assert.equal(disposed, 3); +}); + +test('traffic loading timeouts fail the scenario', async (t) => { + fixture(t); + let calls = 0; + await assert.rejects( + whenTrafficSettled({ + evaluate: async () => {}, + async waitForFunction() { + if (++calls === 3) throw new Error('traffic loading timeout'); + return { dispose: async () => {} }; + }, + }), + /traffic loading timeout/, + ); +});