mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
#!/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();
|
|
}
|