Merge branch 'main' into feat/voice-geometry

This commit is contained in:
Sameh Khamis committed 2026-11-03 09:49:42 -08:00
commit 553f0c06ee
9 files changed
+1443

No files matched your search

+3
View File
@@ -57,6 +57,9 @@
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/app/zoomOutGuard.test.mjs",
"src/app/controls.test.mjs",
"scripts/qa-zoom-out-guard.mjs",
"src/app/atmosphereCompat.test.mjs",
"src/standalone/application.js",
"src/standalone/controls.js",
+1
View File
@@ -2280,6 +2280,7 @@
"src/app/layers/traffic.js",
"src/app/operations.js",
"src/app/scene.js",
"src/app/zoomOutGuard.js",
"src/sources/sourceSlot.js",
"src/app/sources.js",
"src/app/startupChrome.js",
+333
View File
@@ -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();
}