mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Let readiness waits know when the app owns the camera
While the cockpit holds camera inputs or the camera follows a target, whenSettled no longer waits for enabled inputs or a still pose; it still waits for the engine, the restored view, drawn frames and no flight, and its timeout now prints what it saw (inputs, following, cockpit, last pose change). Scenarios that waited for the camera where they needed the picture wait for it instead: weather and Nepal imagery for their tiles and frames, imagery capture while a box drag holds inputs, and pixel and pick polling for drawn frames. The vessel click retries at the vessel's rendered position.
This commit is contained in:
1 parent
417890b862
commit
b73fe82e27
11 files changed
+291
-42
No files matched your search
+57
-7
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user