From b5cc4feacbb6ada44a7f38901ba9dc7c85ecb6e8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 20:14:50 +0400 Subject: [PATCH] Restore the conditions eight converted QA scripts were waiting for The harness conversion replaced pauses that stood in for real conditions with camera readiness: the cables and infrastructure idle checks started measuring while the overlay was still fading in after moveEnd, directions sampled before its maneuver dots had ground cells, three scripts clicked before the loading cover was gone, the credits check hit-tested during UI transitions, and the oblique traffic flight lost its height cap. Each now waits for its condition; idle checks observe without requesting frames and record the reasons of the frames they count; readiness waits for the startup cover and no longer requests frames while polling. --- scripts/qa-cables-overlay.mjs | 9 +-- scripts/qa-directions.mjs | 11 ++- scripts/qa-final-polish-credits.mjs | 3 +- scripts/qa-infra-lod.mjs | 23 +----- scripts/qa-traffic-oblique.mjs | 24 +++--- scripts/qa/idle.mjs | 92 ++++++++++++++++++++++ scripts/qa/idle.test.mjs | 112 +++++++++++++++++++++++++++ scripts/qa/ready.mjs | 17 ++-- scripts/qa/ready.test.mjs | 55 +++++++++++++ scripts/qa/traffic.mjs | 69 +++++++++++++++++ scripts/qa/traffic.test.mjs | 116 ++++++++++++++++++++++++++++ 11 files changed, 480 insertions(+), 51 deletions(-) create mode 100644 scripts/qa/idle.mjs create mode 100644 scripts/qa/idle.test.mjs create mode 100644 scripts/qa/traffic.mjs create mode 100644 scripts/qa/traffic.test.mjs 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/, + ); +});