mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Merge branch 'german/refactor-e1-readiness-fixes' into german/navara-refactor
This commit is contained in:
commit
facc9541ab
11 files changed
+291
-42
No files matched your search
@@ -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 { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -213,7 +213,7 @@ await strict.run({}, async (b) => {
|
|||||||
const deadline = Date.now() + 20000;
|
const deadline = Date.now() + 20000;
|
||||||
let on = await capture(true, layerId);
|
let on = await capture(true, layerId);
|
||||||
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
|
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
on = await sample();
|
on = await sample();
|
||||||
}
|
}
|
||||||
return on;
|
return on;
|
||||||
@@ -328,7 +328,7 @@ await strict.run({}, async (b) => {
|
|||||||
{ timeout: 15000 },
|
{ timeout: 15000 },
|
||||||
visible,
|
visible,
|
||||||
);
|
);
|
||||||
const click = async (lon, lat, predicate) => {
|
const click = async (lon, lat) => {
|
||||||
const { x, y } = await p.evaluate(
|
const { x, y } = await p.evaluate(
|
||||||
(world) => {
|
(world) => {
|
||||||
const { engine } = window.__godsEyeView;
|
const { engine } = window.__godsEyeView;
|
||||||
@@ -339,7 +339,6 @@ await strict.run({}, async (b) => {
|
|||||||
},
|
},
|
||||||
fromDegrees(lon, lat),
|
fromDegrees(lon, lat),
|
||||||
);
|
);
|
||||||
if (predicate) await whenPickable(p, { x, y }, predicate);
|
|
||||||
await p.mouse.click(x, y);
|
await p.mouse.click(x, y);
|
||||||
};
|
};
|
||||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||||
@@ -400,11 +399,26 @@ await strict.run({}, async (b) => {
|
|||||||
await p.waitForFunction(
|
await p.waitForFunction(
|
||||||
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show,
|
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show,
|
||||||
);
|
);
|
||||||
const vesselPicked = (picked) => picked?.id === '123456789';
|
|
||||||
const selectVessel = () =>
|
const selectVessel = () =>
|
||||||
clickUntilSelected(
|
clickUntilSelected(
|
||||||
p,
|
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',
|
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||||
);
|
);
|
||||||
// Click-picking on the running engine: clear the selection with a click on
|
// Click-picking on the running engine: clear the selection with a click on
|
||||||
|
|||||||
@@ -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
|
// 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,
|
// 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
|
// basemap, toggle, failure, box-drag and zoom-to-fit checks read the rendered
|
||||||
@@ -206,7 +206,10 @@ try {
|
|||||||
await whenSettled(page);
|
await whenSettled(page);
|
||||||
const capture = async (name) => {
|
const capture = async (name) => {
|
||||||
await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa'));
|
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();
|
const buffer = await page.screenshot();
|
||||||
await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer);
|
await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer);
|
||||||
const pixels = await sharp(buffer)
|
const pixels = await sharp(buffer)
|
||||||
|
|||||||
@@ -110,19 +110,7 @@ await strict.run({}, async (browser) => {
|
|||||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||||
await waitForEngine(page, 'navara');
|
await waitForEngine(page, 'navara');
|
||||||
// Cockpit mode deliberately holds camera inputs throughout these handoffs.
|
// Cockpit mode deliberately holds camera inputs throughout these handoffs.
|
||||||
const attached = async (kind) => {
|
const attached = (kind) => waitForEngine(page, kind, { timeout: 90000 });
|
||||||
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,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { whenSettled } from './qa/ready.mjs';
|
import { whenFramesDrawn, whenTilesLoaded } from './qa/ready.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
@@ -90,7 +90,9 @@ await strict.run({}, async (b) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const sample = async () => {
|
const sample = async () => {
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
|
await whenTilesLoaded(p);
|
||||||
|
await whenFramesDrawn(p);
|
||||||
return sharp(await p.screenshot())
|
return sharp(await p.screenshot())
|
||||||
.extract({ left: 490, top: 370, width: 25, height: 25 })
|
.extract({ left: 490, top: 370, width: 25, height: 25 })
|
||||||
.removeAlpha()
|
.removeAlpha()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { whenSettled } from './qa/ready.mjs';
|
import { whenFramesDrawn } from './qa/ready.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
@@ -152,7 +152,7 @@ await strict.run({}, async (b) => {
|
|||||||
const deadline = Date.now() + 20000;
|
const deadline = Date.now() + 20000;
|
||||||
let on = await sample();
|
let on = await sample();
|
||||||
while (!test(on, off) && Date.now() < deadline) {
|
while (!test(on, off) && Date.now() < deadline) {
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
on = await sample();
|
on = await sample();
|
||||||
}
|
}
|
||||||
return on;
|
return on;
|
||||||
|
|||||||
@@ -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 { trafficFixtureResponse } from './traffic-fixtures.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -157,7 +157,7 @@ await strict.run({}, async (b) => {
|
|||||||
'traffic did not settle with 12 vehicles in view: ' +
|
'traffic did not settle with 12 vehicles in view: ' +
|
||||||
JSON.stringify({ loading, inView: rows.length }),
|
JSON.stringify({ loading, inView: rows.length }),
|
||||||
);
|
);
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The population once roads, heights and dots have settled. Source counts
|
// 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.
|
// attached road heights are sampled after drawn frames on either engine.
|
||||||
const settledPopulation = async () => {
|
const settledPopulation = async () => {
|
||||||
await whenSettled(p);
|
await whenSettled(p);
|
||||||
|
await whenTilesLoaded(p);
|
||||||
|
await whenFramesDrawn(p);
|
||||||
await settled();
|
await settled();
|
||||||
return p.evaluate(() => window.qaTraffic.getStats().count);
|
return p.evaluate(() => window.qaTraffic.getStats().count);
|
||||||
};
|
};
|
||||||
@@ -252,7 +254,7 @@ await strict.run({}, async (b) => {
|
|||||||
origin: 'user',
|
origin: 'user',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
const hidden = await raw();
|
const hidden = await raw();
|
||||||
const { width, height } = shown.info;
|
const { width, height } = shown.info;
|
||||||
const scale = width / 1024;
|
const scale = width / 1024;
|
||||||
|
|||||||
@@ -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 { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -183,7 +183,37 @@ await strict.run({}, async (b) => {
|
|||||||
time,
|
time,
|
||||||
);
|
);
|
||||||
const sample = async () => {
|
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())
|
const crop = await sharp(await p.screenshot())
|
||||||
.extract({ left: 535, top: 388, width: 12, height: 12 })
|
.extract({ left: 535, top: 388, width: 12, height: 12 })
|
||||||
.removeAlpha()
|
.removeAlpha()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { whenSettled } from './qa/ready.mjs';
|
import { whenFramesDrawn } from './qa/ready.mjs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||||
import { fromCartesian } from '../src/geo/cartographic.js';
|
import { fromCartesian } from '../src/geo/cartographic.js';
|
||||||
@@ -162,7 +162,7 @@ await strict.run({}, async (b) => {
|
|||||||
(badge) => badge.picked?.id === `wildlife:${badge.id}`,
|
(badge) => badge.picked?.id === `wildlife:${badge.id}`,
|
||||||
);
|
);
|
||||||
if (ok || Date.now() > deadline) return drawn;
|
if (ok || Date.now() > deadline) return drawn;
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const degrees = (position) => {
|
const degrees = (position) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { whenSettled } from './qa/ready.mjs';
|
import { whenFramesDrawn } from './qa/ready.mjs';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
@@ -96,7 +96,7 @@ await strict.run({}, async (b) => {
|
|||||||
window.qaWind.getRowControls().summary.reading?.speed === 10,
|
window.qaWind.getRowControls().summary.reading?.speed === 10,
|
||||||
{ timeout: 90000 },
|
{ timeout: 90000 },
|
||||||
);
|
);
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
const check = await p.evaluate(() => {
|
const check = await p.evaluate(() => {
|
||||||
const { dataManager } = window.__godsEyeView;
|
const { dataManager } = window.__godsEyeView;
|
||||||
const mod = dataManager.layers.get('wind').module;
|
const mod = dataManager.layers.get('wind').module;
|
||||||
@@ -162,7 +162,7 @@ await strict.run({}, async (b) => {
|
|||||||
const frozen = await p.evaluate(
|
const frozen = await p.evaluate(
|
||||||
() => window.qaWind.getDiagnostics().gpu.phaseSeconds,
|
() => window.qaWind.getDiagnostics().gpu.phaseSeconds,
|
||||||
);
|
);
|
||||||
await whenSettled(p);
|
await whenFramesDrawn(p);
|
||||||
await strict.check(
|
await strict.check(
|
||||||
p,
|
p,
|
||||||
'wind',
|
'wind',
|
||||||
|
|||||||
+57
-7
@@ -1,6 +1,6 @@
|
|||||||
import { createSettledState } from '../../src/app/qaProbe.js';
|
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) {
|
export async function clickUntilSelected(page, click, selected) {
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
await click();
|
await click();
|
||||||
@@ -72,7 +72,7 @@ export async function whenSettled(
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
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(
|
return page.evaluate(
|
||||||
@@ -85,6 +85,33 @@ export function waitForSettled(createState, { frames, timeoutMs }) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { engine } = window.__godsEyeView;
|
const { engine } = window.__godsEyeView;
|
||||||
const state = createState(engine);
|
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 finished = false;
|
||||||
let remove = () => {};
|
let remove = () => {};
|
||||||
const finish = (error, result) => {
|
const finish = (error, result) => {
|
||||||
@@ -97,19 +124,37 @@ export function waitForSettled(createState, { frames, timeoutMs }) {
|
|||||||
else resolve(result);
|
else resolve(result);
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const status = state.settled({ frames });
|
const status = statusOf();
|
||||||
if (window.__godsEyeView?.engine !== engine)
|
if (window.__godsEyeView?.engine !== engine)
|
||||||
status.unmet.push('engineUnchanged');
|
status.unmet.push('engineUnchanged');
|
||||||
finish(
|
finish(
|
||||||
new Error(
|
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);
|
}, timeoutMs);
|
||||||
remove = engine.frames.postRender.addEventListener(() => {
|
remove = engine.frames.postRender.addEventListener(() => {
|
||||||
const status = state.settled({ frames });
|
|
||||||
if (window.__godsEyeView?.engine !== engine)
|
if (window.__godsEyeView?.engine !== engine)
|
||||||
return finish(new Error('Readiness interrupted: engineChanged'));
|
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);
|
if (status.settled) finish(null, status);
|
||||||
else
|
else
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -120,7 +165,7 @@ export function waitForSettled(createState, { frames, timeoutMs }) {
|
|||||||
engine.render.request('qa-readiness');
|
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(
|
export async function whenFramesDrawn(
|
||||||
page,
|
page,
|
||||||
{ frames = 3, timeoutMs = 30000 } = {},
|
{ frames = 3, timeoutMs = 30000 } = {},
|
||||||
@@ -151,7 +196,12 @@ export async function whenFramesDrawn(
|
|||||||
/** Tile readiness is independent of camera rest, including a keyless globe. */
|
/** Tile readiness is independent of camera rest, including a keyless globe. */
|
||||||
export async function whenTilesLoaded(page, { timeoutMs = 30000 } = {}) {
|
export async function whenTilesLoaded(page, { timeoutMs = 30000 } = {}) {
|
||||||
const handle = await page.waitForFunction(
|
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 },
|
{ timeout: timeoutMs, polling: 100 },
|
||||||
);
|
);
|
||||||
await handle.dispose();
|
await handle.dispose();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js';
|
import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js';
|
||||||
|
import { createSettledState } from '../../src/app/qaProbe.js';
|
||||||
import {
|
import {
|
||||||
|
waitForSettled,
|
||||||
whenFramesDrawn,
|
whenFramesDrawn,
|
||||||
whenTilesLoaded,
|
whenTilesLoaded,
|
||||||
whenUiSettled,
|
whenUiSettled,
|
||||||
@@ -9,6 +11,164 @@ import {
|
|||||||
until,
|
until,
|
||||||
} from './ready.mjs';
|
} 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) => {
|
test('frame readiness advances a request-mode engine and removes its listener', async (t) => {
|
||||||
const engine = createFakeEngine();
|
const engine = createFakeEngine();
|
||||||
const previous = globalThis.window;
|
const previous = globalThis.window;
|
||||||
|
|||||||
Reference in new issue
Block a user