mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix: respect camera ownership and finish outward zoom framing
This commit is contained in:
1 parent
895ef536ca
commit
a236504347
8 files changed
+951
-152
No files matched your search
@@ -57,8 +57,8 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
]),
|
||||
],
|
||||
protocolTimeout: 120_000,
|
||||
});
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1200, height: 800 });
|
||||
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.viewer &&
|
||||
window.__godsEyeView.viewer.scene.screenSpaceCameraController
|
||||
.enableInputs &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const renderer = await page.evaluate(async () => {
|
||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
|
||||
const { readCameraTargetFrame } =
|
||||
await import('/src/ui/cameraOrientationControls.js');
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.trackedEntity = undefined;
|
||||
// Explicit real renders make each gesture independent of host frame rate.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
controller.inertiaZoom = 0;
|
||||
controller.inertiaSpin = 0;
|
||||
const original = viewer.camera.setView.bind(viewer.camera);
|
||||
const qa = {
|
||||
viewer,
|
||||
guardWrites: 0,
|
||||
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
|
||||
history: [],
|
||||
frame() {
|
||||
viewer.scene.requestRender();
|
||||
viewer.render();
|
||||
qa.history.push({
|
||||
height: viewer.camera._positionCartographic.height,
|
||||
guardWrites: qa.guardWrites,
|
||||
position: { ...viewer.camera.position },
|
||||
});
|
||||
if (qa.history.length > 80) qa.history.shift();
|
||||
},
|
||||
pose() {
|
||||
return {
|
||||
height: viewer.camera.positionCartographic.height,
|
||||
pitch: viewer.camera.pitch,
|
||||
maxPitch: maxPitchForHeight(
|
||||
viewer.camera.positionCartographic.height,
|
||||
),
|
||||
guardWrites: qa.guardWrites,
|
||||
};
|
||||
},
|
||||
reset(height = 1_000_000, pitch = -35) {
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
|
||||
original({
|
||||
destination: C.Cartesian3.fromDegrees(0, 0, height),
|
||||
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
|
||||
});
|
||||
qa.frame();
|
||||
qa.guardWrites = 0;
|
||||
},
|
||||
};
|
||||
viewer.camera.setView = (options) => {
|
||||
// Cesium itself also calls setView while processing touch movement.
|
||||
if (new Error().stack.includes('/src/app/zoomOutGuard.js'))
|
||||
qa.guardWrites++;
|
||||
return original(options);
|
||||
};
|
||||
window.__zoomGuardQa = qa;
|
||||
const gl = viewer.scene.context._gl;
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
|
||||
});
|
||||
console.log(`[INFO] WebGL renderer: ${renderer}`);
|
||||
const frame = (count = 1) =>
|
||||
page.evaluate(async (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__zoomGuardQa.frame();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return window.__zoomGuardQa.pose();
|
||||
}, count);
|
||||
const reset = (height, pitch) =>
|
||||
page.evaluate((h, p) => window.__zoomGuardQa.reset(h, p), height, pitch);
|
||||
const session = await page.createCDPSession();
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 800 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewport(viewport);
|
||||
await page.evaluate(() => window.__zoomGuardQa.viewer.resize());
|
||||
const x = Math.round(viewport.width / 2);
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await reset(1_000_000, -25);
|
||||
await page.mouse.move(x, y);
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 2400,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
const first = await frame();
|
||||
console.log(`[INFO] ${viewport.width}px wheel delivered`);
|
||||
const settled = await frame(40);
|
||||
assert.ok(first.height > 1_000_000, 'wheel must move the real camera');
|
||||
assert.ok(settled.guardWrites > 0, 'zoom guard must run');
|
||||
assert.ok(
|
||||
settled.pitch <= settled.maxPitch + 1e-6,
|
||||
`stationary pitch must finish easing: ${JSON.stringify(settled)}`,
|
||||
);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 2400,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
await frame();
|
||||
}
|
||||
const ceiling = await frame(40);
|
||||
assert.ok(ceiling.height <= 40_000_001, 'user zoom respects the ceiling');
|
||||
assert.ok(
|
||||
ceiling.height > 39_000_000,
|
||||
'exercise the ceiling, not a low orbit',
|
||||
);
|
||||
assert.ok(
|
||||
ceiling.pitch <= ceiling.maxPitch + 1e-6,
|
||||
`ceiling must settle: ${JSON.stringify(await page.evaluate(() => window.__zoomGuardQa.history))}`,
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px wheel, stationary easing and ceiling`,
|
||||
);
|
||||
if (process.env.QA_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
process.env.QA_SCREENSHOT_DIR,
|
||||
`zoom-ceiling-${viewport.width}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await reset();
|
||||
await page.mouse.move(x, y);
|
||||
await page.keyboard.down('Control');
|
||||
await page.mouse.down({ button: 'right' });
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await page.mouse.move(x, y + i * 3);
|
||||
await frame();
|
||||
}
|
||||
await page.mouse.up({ button: 'right' });
|
||||
await page.keyboard.up('Control');
|
||||
const tilted = await frame(3);
|
||||
assert.equal(
|
||||
tilted.guardWrites,
|
||||
0,
|
||||
'Ctrl+right tilt must not trigger guard',
|
||||
);
|
||||
assert.ok(Math.abs(tilted.height - 1_000_000) > 1, 'tilt must move camera');
|
||||
|
||||
await reset();
|
||||
const touches = (dy) => [
|
||||
{ x: x - 60, y: y + dy, id: 1 },
|
||||
{ x: x + 60, y: y + dy, id: 2 },
|
||||
];
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: touches(0),
|
||||
});
|
||||
await frame();
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: touches(i * 3),
|
||||
});
|
||||
await frame();
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
const translated = await frame(3);
|
||||
assert.equal(
|
||||
translated.guardWrites,
|
||||
0,
|
||||
'parallel two-finger tilt must not trigger guard',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(translated.height - 1_000_000) > 1,
|
||||
'touch tilt must move camera',
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px Ctrl+right and parallel touch tilt`,
|
||||
);
|
||||
await reset(1_000_000, -35);
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 120,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
const beforeTilt = await frame();
|
||||
assert.ok(
|
||||
beforeTilt.guardWrites > 0,
|
||||
'wheel correction must be pending before UI tilt',
|
||||
);
|
||||
const targetPitchBeforeClick = await page.evaluate(() =>
|
||||
window.__zoomGuardQa.targetPitch(),
|
||||
);
|
||||
assert.ok(
|
||||
Number.isFinite(targetPitchBeforeClick),
|
||||
'UI tilt fixture must have a ground target under the canvas center',
|
||||
);
|
||||
// Programmatic fixture resets precede moveEnd, so the button's published
|
||||
// aria state may still describe the previous view. The action toggles the
|
||||
// current target frame: angles shallower than -60 degrees are tilted.
|
||||
const wasTilted = targetPitchBeforeClick > -Math.PI / 3;
|
||||
await page.click('#tilt-map-view');
|
||||
const uiTilt = await page.evaluate(
|
||||
async (desiredDegrees) => {
|
||||
const qa = window.__zoomGuardQa;
|
||||
const desired = (desiredDegrees * Math.PI) / 180;
|
||||
const started = performance.now();
|
||||
const deadline = started + 3000;
|
||||
while (performance.now() < deadline) {
|
||||
qa.frame();
|
||||
// The 650 ms animator can be numerically near its destination
|
||||
// before it releases preUpdate. Finish its lifetime before resetting.
|
||||
if (
|
||||
performance.now() - started >= 700 &&
|
||||
Math.abs(qa.targetPitch() - desired) < 1e-5
|
||||
)
|
||||
return { settled: true, guardWrites: qa.guardWrites };
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
return {
|
||||
settled: false,
|
||||
guardWrites: qa.guardWrites,
|
||||
pitch: qa.targetPitch(),
|
||||
};
|
||||
},
|
||||
wasTilted ? -89 : -35,
|
||||
);
|
||||
assert.ok(
|
||||
uiTilt.settled,
|
||||
`UI tilt must reach its destination: ${JSON.stringify(uiTilt)}`,
|
||||
);
|
||||
assert.equal(
|
||||
uiTilt.guardWrites,
|
||||
beforeTilt.guardWrites,
|
||||
'UI navigation handoff must cancel pending zoom correction',
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px UI tilt takes over a pending wheel correction`,
|
||||
);
|
||||
|
||||
await reset(1_000_000, -25);
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: touches(0),
|
||||
});
|
||||
await frame();
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: x - 35, y, id: 1 },
|
||||
{ x: x + 35, y, id: 2 },
|
||||
],
|
||||
});
|
||||
await frame();
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
const pinched = await frame(40);
|
||||
assert.ok(pinched.height > 1_000_000, 'pinch-out must move camera outward');
|
||||
assert.ok(pinched.guardWrites > 0, 'pinch-out must trigger the guard');
|
||||
assert.ok(pinched.pitch <= pinched.maxPitch + 1e-6);
|
||||
console.log(`[PASS] ${viewport.width}px pinch-out settles below the limb`);
|
||||
}
|
||||
assert.deepEqual(errors, [], 'no JavaScript page errors');
|
||||
console.log('[PASS] no JavaScript page errors');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in new issue
Block a user