Merge branch 'german/refactor-e1-readiness-fixes' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 18:41:42 +04:00
commit facc9541ab
11 files changed
+291 -42

No files matched your search

+20 -6
View File
@@ -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
+5 -2
View File
@@ -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)
+1 -13
View File
@@ -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,
);
+4 -2
View File
@@ -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()
+2 -2
View File
@@ -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;
+5 -3
View File
@@ -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;
+32 -2
View File
@@ -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()
+2 -2
View File
@@ -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) => {
+3 -3
View File
@@ -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',
+57 -7
View File
@@ -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();
+160
View File
@@ -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;