Merge upstream main into feature/catalonia-cctv-cameras to resolve PR #595 conflicts

Upstream added Director, Transit/GTFS-Realtime and Bhote Koshi scene work
in the same window; the only real conflict was two adjacent CHANGELOG
entries, resolved by keeping both.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ulihhhhandClaude Sonnet 5 committed 2026-09-16 10:34:16 +02:00
commit 25f90692cf
128 files changed
+18562 -6497

No files matched your search

+6 -3
View File
@@ -35,9 +35,12 @@ const portableExport = (key) =>
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
key,
) ||
['./voice/action-schemas', './voice/session', './data/lifecycle'].includes(
key,
);
[
'./director',
'./voice/action-schemas',
'./voice/session',
'./data/lifecycle',
].includes(key);
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
export function checkImportDirections(root) {
+19 -1
View File
@@ -181,6 +181,7 @@
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/layerKeyRequirement.test.mjs",
"src/ui/layerPanel.test.mjs",
"src/ui/clearLayersControl.js",
"scripts/qa-layer-panel.mjs",
"scripts/qa-directions.mjs",
@@ -702,6 +703,7 @@
"src/layers/transit/viewport.js",
"src/layers/transit/lifecycle.test.mjs",
"scripts/qa-transit.mjs",
"scripts/qa-transit-heading.mjs",
"src/tooling/transitProvider.test.mjs",
"src/app/layers/transit.js",
"src/data/transitIcons.js",
@@ -724,7 +726,23 @@
"scripts/qa-transit-controls.mjs",
"src/tooling/transitQa.test.mjs",
"scripts/qa-transit-browser.mjs",
"scripts/qa-transit-recovery.mjs",
"scripts/format-runtime.json",
"src/tooling/transitService.test.mjs",
"src/sources/overpassFeatures.test.mjs"
"src/sources/overpassFeatures.test.mjs",
"src/director/clock.test.mjs",
"src/director/timeline.test.mjs",
"src/scenes/packs/registry.test.mjs",
"scripts/qa-director-timing.mjs",
"src/director/document.test.mjs",
"src/director/camera.test.mjs",
"src/scenes/cameraMotion.test.mjs",
"scripts/qa-director-camera.mjs",
"src/director/packs/packs.test.mjs",
"scripts/qa-director-packs.mjs",
"src/scenes/dataPacks/presentation.test.mjs",
"src/director/interactions/interactions.test.mjs",
"scripts/qa-director-interactions.mjs",
"src/director/sharing/sharing.test.mjs",
"scripts/qa-director-sharing.mjs"
]
+127 -5
View File
@@ -504,6 +504,8 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/locationNavigation.js",
"src/ui/cockpitCoordinator.js",
"src/ui/cameraOrientationControls.js",
"src/ui/applicationShortcuts.js",
"src/ui/cctv.js",
@@ -574,7 +576,11 @@
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/worldFocus.js",
"src/data/feedState.js"
"src/data/feedState.js",
"src/ui/aircraftDisplay.js",
"src/ui/layerBindings.js",
"src/ui/displayBindings.js",
"src/ui/shellFacade.js"
],
"external": ["cesium"]
},
@@ -606,6 +612,29 @@
],
"external": []
},
"director": {
"exports": ["./director"],
"modules": [
"src/director/playback.js",
"src/director/clock.js",
"src/director/timeline.js",
"src/director/document.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
"src/director/packs/manifest.js",
"src/director/interactions/document.js",
"src/director/interactions/session.js",
"src/director/sharing/bundle.js",
"src/director/sharing/preview.js",
"src/director/sharing/lifetime.js",
"src/director/authoring.js"
],
"external": []
},
"scenes": {
"exports": ["./scenes"],
"modules": [
@@ -615,6 +644,8 @@
"src/data/bhoteKoshiFloodPath.js",
"src/maps/catalog.js",
"src/scenes/director.js",
"src/scenes/playbackAdapter.js",
"src/director/playback.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
"src/bloom.js",
@@ -622,7 +653,36 @@
"src/ui/scenes.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/contextModePolicy.js"
"src/contextModePolicy.js",
"src/director/clock.js",
"src/director/timeline.js",
"src/scenes/shotPresentation.js",
"src/scenes/packs/registry.js",
"src/scenes/packs/defaults.js",
"src/scenes/packs/nepal.js",
"src/director/document.js",
"src/scenes/project.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/scenes/cameraMotion.js",
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
"src/director/packs/manifest.js",
"src/scenes/dataPacks/controller.js",
"src/scenes/dataPacks/presentation.js",
"src/scenes/dataPacks/geometry.js",
"src/director/interactions/document.js",
"src/director/interactions/session.js",
"src/scenes/interactions.js",
"src/data/inputOwnership.js",
"src/director/sharing/bundle.js",
"src/director/sharing/preview.js",
"src/director/sharing/lifetime.js",
"src/director/authoring.js",
"src/scenes/sharing.js",
"src/ui/sceneSharing.js"
],
"external": ["cesium"]
},
@@ -1105,7 +1165,18 @@
"src/voice/session.js",
"src/voice/sessionCommands.js",
"src/voice/realtimeSession.js",
"src/voice/realtimeEvents.js"
"src/voice/realtimeEvents.js",
"src/voice/realtimePreferences.js",
"src/voice/realtimeProtocol.js",
"src/voice/realtimeDiagnostics.js",
"src/voice/realtimeViewport.js",
"src/voice/realtimeInputPolicy.js",
"src/voice/realtimeInput.js",
"src/voice/realtimeCost.js",
"src/voice/realtimeFacade.js",
"src/voice/realtimeRadio.js",
"src/voice/realtimeTurns.js",
"src/voice/realtimeConnection.js"
],
"external": []
},
@@ -1518,6 +1589,8 @@
"src/renderGovernor.js",
"src/rightRailPolicy.js",
"src/scenes/director.js",
"src/scenes/playbackAdapter.js",
"src/director/playback.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
"src/scopeMask.js",
@@ -1547,6 +1620,8 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/locationNavigation.js",
"src/ui/cockpitCoordinator.js",
"src/ui/cameraOrientationControls.js",
"src/ui/applicationShortcuts.js",
"src/ui/cctv.js",
@@ -1691,7 +1766,50 @@
"src/sources/overpassFeaturesRecords.js",
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/overpassRoads.js"
"src/sources/overpassRoads.js",
"src/ui/aircraftDisplay.js",
"src/ui/layerBindings.js",
"src/ui/displayBindings.js",
"src/ui/shellFacade.js",
"src/voice/realtimePreferences.js",
"src/voice/realtimeProtocol.js",
"src/voice/realtimeDiagnostics.js",
"src/voice/realtimeViewport.js",
"src/voice/realtimeInputPolicy.js",
"src/voice/realtimeInput.js",
"src/voice/realtimeCost.js",
"src/voice/realtimeFacade.js",
"src/voice/realtimeRadio.js",
"src/voice/realtimeTurns.js",
"src/voice/realtimeConnection.js",
"src/director/clock.js",
"src/director/timeline.js",
"src/scenes/shotPresentation.js",
"src/scenes/packs/registry.js",
"src/scenes/packs/defaults.js",
"src/scenes/packs/nepal.js",
"src/director/document.js",
"src/scenes/project.js",
"src/director/documentFields.js",
"src/director/cameraDocument.js",
"src/director/camera.js",
"src/scenes/cameraMotion.js",
"src/director/packs/source.js",
"src/director/packs/session.js",
"src/director/packs/geojson.js",
"src/director/packs/manifest.js",
"src/scenes/dataPacks/controller.js",
"src/scenes/dataPacks/presentation.js",
"src/scenes/dataPacks/geometry.js",
"src/director/interactions/document.js",
"src/director/interactions/session.js",
"src/scenes/interactions.js",
"src/director/sharing/bundle.js",
"src/director/sharing/preview.js",
"src/director/sharing/lifetime.js",
"src/director/authoring.js",
"src/scenes/sharing.js",
"src/ui/sceneSharing.js"
],
"external": [
"@mapbox/vector-tile",
@@ -2156,7 +2274,11 @@
},
"ui-navigation": {
"exports": ["./ui/navigation"],
"modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"],
"modules": [
"src/navigationPolicy.js",
"src/ui/navigationController.js",
"src/app/stateChannel.js"
],
"external": ["cesium"]
},
"ui-share-restoration": {
+3 -2
View File
@@ -2171,7 +2171,8 @@ try {
);
const resetResult = await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
// Both the UI and compatibility facade delegate to this navigation owner.
const manager = window.__godsEyeView.styleManager._locationNavigation;
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
window.__qaCockpitReset = {
calls: 0,
@@ -2203,7 +2204,7 @@ try {
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
const height = gev.viewer.camera.positionCartographic?.height;
gev.styleManager.resetToGlobeView = qa.original;
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
delete window.__qaCockpitReset;
return {
calls: qa.calls,
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
/** Verify imported camera directions through real rendering and camera authority handoffs. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-camera');
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failed = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failed++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const file = {
version: 4,
scenes: [
{
id: 'directions',
title: 'Camera directions',
anchors: [
{
id: 'start',
title: 'Austin west',
lat: 30.2672,
lon: -97.76,
alt: 6000,
altitudeReference: 'ellipsoid',
},
{
id: 'end',
title: 'Austin east',
lat: 30.2672,
lon: -97.73,
alt: 7000,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Across Austin',
durationSec: 1,
holdSec: 0.2,
camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 },
move: {
from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 },
easing: 'linear',
},
layers: {},
visual: { style: 'normal' },
},
],
},
],
};
await d.importProjectFile(
new File([JSON.stringify(file)], 'directions.json'),
);
const scene = d._project.scenes[0];
const shot = scene.shots[0];
const imported =
scene.anchors.length === 2 && shot.camera.anchorId === 'end';
await d.seekScene(scene.id, 0.5 / 1.2);
const middle = style.getCameraState();
const seekMidpoint =
Math.abs(middle.lon - -97.745) < 1e-6 &&
Math.abs(middle.alt - 6500) < 0.01;
await d.seekScene(scene.id, 0);
const back = style.getCameraState();
const backward = Math.abs(back.lon - -97.76) < 1e-6;
const replay = await d.replayShot(scene.id, shot.id);
const end = style.getCameraState();
const replayEnd =
replay?.started &&
Math.abs(end.lon - -97.73) < 1e-6 &&
Math.abs(end.alt - 7000) < 0.01;
d.stopScene();
await d.startScene(scene.id, { single: true, preview: false });
const completed =
!d.running && d.getPlaybackTimingState().activeTimers === 0;
const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6;
// Updating the target creates an explicit inline pose while retaining the authored start.
d.updateSelectedShot();
const saved = JSON.parse(
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
const updated =
saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' &&
saved.scenes[0].shots[0].move.from.anchorId === 'start';
shot.durationSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
const started = d._cameraMotion.active;
style.runImmediateNavigation('location', () => true);
await run;
const revoked =
started &&
!d.running &&
!d._cameraMotion.active &&
d.getPlaybackTimingState().activeTimers === 0;
const stoppedPose = style.getCameraState();
await new Promise((r) => setTimeout(r, 150));
const stable =
Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7;
return {
imported,
seekMidpoint,
backward,
replayEnd,
completed,
settled,
updated,
revoked,
stable,
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
await new Promise((r) => setTimeout(r, 6000));
await page.screenshot({ path: path.join(output, 'austin.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
const pose = d.styleManager.getCameraState();
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
d.viewer.scene.requestRender();
});
await new Promise((r) => setTimeout(r, 3000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const inputStarted = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes[0];
window.__qaCameraRun = d.startScene(scene.id, {
single: true,
preview: false,
});
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
return d._cameraMotion.active;
});
const canvas =
(await page.$('canvas.cesium-widget-canvas')) ||
(await page.$('.cesium-widget canvas'));
const rect = await canvas.boundingBox();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.mouse.down();
await page.mouse.up();
const input =
inputStarted &&
(await page.evaluate(async () => {
await window.__qaCameraRun;
const d = window.__godsEyeView.sceneDirector;
return !d.running && d.getPlaybackTimingState().activeTimers === 0;
}));
check('manual canvas input revokes the move and releases clocks', input);
const hold = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const scene = d._project.scenes[0];
const shot = scene.shots[0];
shot.durationSec = 0.2;
shot.holdSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (
(!d._activeSceneTravel || !d.running || d._cameraMotion.active) &&
Date.now() < deadline
)
await new Promise((r) => setTimeout(r, 20));
const holding = d.running && !d._cameraMotion.active;
const began = performance.now();
style.runImmediateNavigation('location', () => true);
await run;
return (
holding &&
performance.now() - began < 1000 &&
d.getPlaybackTimingState().activeTimers === 0
);
});
check('navigation during an authored hold settles immediately', hold);
if (errors.length)
console.error(
errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
if (failed) process.exitCode = 1;
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const output = 'qa-shots/director-interactions';
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
let failures = 0;
const errors = [];
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json')
void request
.respond({
status: 200,
contentType: 'application/geo+json',
body: JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] },
},
],
}),
})
.catch(() => {});
else void request.continue().catch(() => {});
});
const action = (id) => `[data-director-action="${id}"]`;
const load = () =>
page.evaluate(() =>
window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', {
flyDuration: 0.2,
}),
);
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
altitudeReference: 'ellipsoid',
};
const target = { packId: 'data', featureId: 'point' };
const interaction = (id, label, action) => ({ id, label, target, action });
window.__interactionProject = {
version: 6,
scenes: [
{
id: 'interactive',
title: 'Interactive fixture',
anchors: [
{
id: 'view',
lat: 30.27,
lon: -97.74,
alt: 5000,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'qa/actions.json' },
attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' },
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'one',
title: 'Choose an action',
camera,
durationSec: 0.2,
holdSec: 0,
move: {
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
easing: 'linear',
},
visual: { style: 'normal', mapStack: 'photoreal' },
layers: { traffic: false },
dataPackIds: ['data'],
interactions: [
interaction('card', 'Read source', {
type: 'card',
text: '<b>Plain text only</b>',
url: 'https://example.org/source',
}),
interaction('focus', 'Focus anchor', {
type: 'focus',
anchorId: 'view',
}),
interaction('layer', 'Show traffic', {
type: 'layer',
layerId: 'traffic',
enabled: true,
}),
interaction('next', 'Next shot', {
type: 'shot',
shotId: 'two',
}),
],
},
{
id: 'two',
title: 'Next',
camera: { ...camera, heading: 30 },
durationSec: 0.2,
holdSec: 0,
layers: { traffic: false },
},
],
},
],
};
await d.importProjectFile(
new File([JSON.stringify(window.__interactionProject)], 'actions.json'),
);
});
check(
'import installs no action handlers or UI',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
!document.querySelector('[data-director-interactions]'),
),
);
await load();
check(
'settled LOAD activates four actions',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().count === 4,
),
);
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await new Promise((r) => setTimeout(r, 2000));
const point = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const entity = d.viewer.entities.values.find(
(e) => e.name === 'data / point',
);
const pos = entity.position.getValue(d.viewer.clock.currentTime);
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
const rect = d.viewer.scene.canvas.getBoundingClientRect();
return { x: screen.x + rect.left, y: screen.y + rect.top };
});
await page.mouse.click(point.x, point.y);
check(
'real globe pick selects the feature and focuses its keyboard action',
await page.evaluate(
() =>
document.activeElement?.dataset.directorAction === 'card' &&
document
.querySelector('[data-director-interactions] [role=status]')
.textContent.includes('point'),
),
);
await page.keyboard.press('Enter');
check(
'Enter shows literal text and a safe source link',
await page.evaluate(() => {
const card = document.querySelector('[data-director-action-card]');
return (
card?.textContent.includes('<b>Plain text only</b>') &&
!card.querySelector('b') &&
card.querySelector('a')?.rel === 'noopener noreferrer'
);
}),
);
await page.screenshot({ path: `${output}/selected.png` });
fs.writeFileSync(
`${output}/report.json`,
JSON.stringify(
await page.evaluate(() => ({
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
})),
null,
2,
),
);
await page.keyboard.press('Tab');
await page.keyboard.press('Space');
check(
'Tab/Space focuses the authored anchor through camera ownership',
await page.evaluate(
() =>
Math.abs(
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
.height - 5000,
) < 1,
),
);
await page.screenshot({ path: `${output}/anchor.png` });
const sourceBase = process.env.QA_SOURCE_BASE || '/src';
// Resolve ownership from the same module graph as the active application.
await page.evaluate(async (base) => {
window.__inputOwner = await import(`${base}/data/inputOwnership.js`);
window.__lease = window.__inputOwner.claimPointer('qa-draw');
}, sourceBase);
await page.click(action('card'));
check(
'drawing ownership refuses actions',
await page.evaluate(() =>
document
.querySelector('[data-director-interactions] [role=status]')
.textContent.includes('unavailable'),
),
);
await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease));
// Hold the actual manager admission call to exercise Stop before an enable settles.
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector,
m = d.dataManager;
window.__originalEnable = m.setEnabled;
m.setEnabled = function (id, enabled, options) {
if (id !== 'traffic' || !enabled)
return window.__originalEnable.call(this, id, enabled, options);
window.__actionSignal = options.signal;
return new Promise((resolve) => {
window.__lateAction = () => resolve(true);
options.signal.addEventListener('abort', () => resolve(false), {
once: true,
});
});
};
});
await page.click(action('layer'));
await page.waitForFunction(() => window.__actionSignal);
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
check(
'Stop aborts pending layer admission and removes handlers/cards/selection',
await page.evaluate(
() =>
window.__actionSignal.aborted &&
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
!document.querySelector('[data-director-interactions]'),
),
);
await page.evaluate(() => {
window.__lateAction();
window.__godsEyeView.sceneDirector.dataManager.setEnabled =
window.__originalEnable;
});
await load();
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1),
);
check(
'same-shot seek restores pack geometry and resets action state',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().count === 4 &&
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
null &&
window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
),
);
await page.evaluate(() => {
window.__oldActionButton = document.querySelector(
'[data-director-action=card]',
);
});
await page.click(action('next'));
await page.waitForFunction(
() => window.__godsEyeView.sceneDirector._selectedShotId === 'two',
);
check(
'explicit shot transition replaces resources and removes actions',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
window.__godsEyeView.sceneDirector.getDataPackState().count === 0,
),
);
await load();
await page.evaluate(() => window.__oldActionButton.click());
check(
'detached buttons cannot invoke replacement actions with the same ID',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
null &&
!document.querySelector('[data-director-action-card]').textContent,
),
);
await page.evaluate(() => {
window.__godsEyeView.sceneDirector._interactionTransitions = 64;
});
await page.click(action('next'));
check(
'bounded transition chain cannot take another branch',
await page.evaluate(
() => window.__godsEyeView.sceneDirector._selectedShotId === 'one',
),
);
await load();
await page.evaluate(async () => {
const p = structuredClone(window.__interactionProject);
p.scenes[0].shots[0].interactions[0].target.featureId = 'missing';
const d = window.__godsEyeView.sceneDirector;
await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json'));
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
});
check(
'unknown loaded feature refuses the whole action set',
await page.evaluate(
() =>
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
!document.querySelector('[data-director-interactions]'),
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
await d.importProjectFile(
new File([JSON.stringify(window.__interactionProject)], 'restore.json'),
);
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
await d.destroy();
});
check(
'teardown releases interactive resources',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getInteractionState().count === 0 &&
!document.querySelector('[data-director-interactions]'),
),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env node
/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const output = path.resolve('qa-shots/director-packs');
fs.mkdirSync(output, { recursive: true });
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: { r: 255, g: 80, b: 30, alpha: 0.65 },
},
})
.png()
.toBuffer();
const wav = Buffer.alloc(44 + 16000);
wav.write('RIFF');
wav.writeUInt32LE(wav.length - 8, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(16000, 40);
const geojson = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
},
{
type: 'Feature',
id: 'line',
geometry: {
type: 'LineString',
coordinates: [
[-97.745, 30.268, 400],
[-97.735, 30.268, 400],
],
},
},
{
type: 'Feature',
id: 'polygon',
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.746, 30.265, 300],
[-97.74, 30.265, 300],
[-97.74, 30.267, 300],
[-97.746, 30.265, 300],
],
],
},
},
],
};
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [],
requests = [];
let pending,
failures = 0;
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.startsWith('/scene-assets/'))
return void request.continue();
requests.push(url.pathname);
if (url.pathname.endsWith('/slow.json')) {
pending = request;
return;
}
const reply = url.pathname.endsWith('/outline.json')
? { contentType: 'application/geo+json', body: JSON.stringify(geojson) }
: url.pathname.endsWith('/image.png')
? { contentType: 'image/png', body: png }
: url.pathname.endsWith('/audio.wav')
? { contentType: 'audio/wav', body: wav }
: { status: 404, body: 'missing' };
void request.respond(reply).catch(() => {});
});
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const manifest = (id, format, file, placement) => ({
id,
version: 1,
format,
source: { adapter: 'assets', path: `qa/${file}` },
attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' },
placement,
});
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
};
const project = {
version: 5,
scenes: [
{
id: 'packs',
title: 'Data packs QA',
anchors: [
{
id: 'media',
lat: 30.269,
lon: -97.737,
alt: 450,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
manifest('outline', 'geojson', 'outline.json', {
altitudeReference: 'ellipsoid',
}),
manifest('image', 'image', 'image.png', {
bounds: [-97.738, 30.264, -97.732, 30.267],
height: 300,
altitudeReference: 'ellipsoid',
}),
manifest('media', 'media', 'audio.wav', { anchorId: 'media' }),
manifest('slow', 'geojson', 'slow.json', {
altitudeReference: 'ellipsoid',
}),
manifest('missing', 'geojson', 'missing.json', {
altitudeReference: 'ellipsoid',
}),
],
shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({
id,
title: id,
camera,
durationSec: 0.3,
holdSec: 0.2,
visual: { style: 'normal', mapStack: 'photoreal' },
layers: {},
dataPackIds:
id === 'all'
? ['outline', 'image', 'media']
: id === 'empty'
? []
: [id],
})),
},
],
};
await d.importProjectFile(
new File([JSON.stringify(project)], 'packs.json'),
);
window.__packBaseline = d.viewer.entities.values.length;
window.__packPrimitiveBaseline = d.viewer.scene.primitives.length;
window.__packRevoked = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => {
window.__packRevoked.push(url);
revoke(url);
};
});
check('import does not acquire unselected assets', requests.length === 0);
const loaded = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 });
window.__packMaterials = Array.from(
{
length:
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
},
(_, i) =>
d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i)
.appearance?.material,
).filter(Boolean);
return {
started: result?.started,
state: d.getDataPackState(),
count: d.viewer.entities.values.length - window.__packBaseline,
primitives:
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
cards: document.querySelectorAll('[data-director-pack]').length,
paused: document.querySelector('[data-director-pack] audio')?.paused,
};
});
check(
'GeoJSON, PNG and media load only through registered sources',
loaded.started && loaded.state.count === 3 && requests.length === 3,
);
check(
'geometry and image batches plus anchored markers render separately',
loaded.count === 2 && loaded.primitives === 3,
);
check(
'attribution is visible and media does not autoplay',
loaded.cards === 3 && loaded.paused === true,
);
await page.waitForFunction(
() => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal',
{ timeout: 15000 },
);
await new Promise((r) => setTimeout(r, 5000));
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
const evidence = await page.evaluate(() => ({
mapStack: window.__godsEyeView.mapStackController.getActiveId(),
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
}));
fs.writeFileSync(
path.join(output, 'report.json'),
JSON.stringify(evidence, null, 2),
);
check(
'static geometry is ready before visual acceptance',
await page.evaluate(() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
return Array.from(
{ length: primitives.length - window.__packPrimitiveBaseline },
(_, i) => primitives.get(window.__packPrimitiveBaseline + i),
).every((p) => p.ready !== false);
}),
);
await page.screenshot({ path: path.join(output, 'packs.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
d._setCameraView({
...d.styleManager.getCameraState(),
heading: 30,
pitch: -85,
});
});
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await new Promise((r) => setTimeout(r, 2000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const life = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const media = document.querySelector('[data-director-pack] audio');
await media.play();
const played = !media.paused;
await d.loadShot('packs', 'empty', { flyDuration: 0.3 });
const cleared =
d.getDataPackState().count === 0 &&
!document.querySelector('[data-director-packs]') &&
d.viewer.entities.values.length === window.__packBaseline &&
d.viewer.scene.primitives.length === window.__packPrimitiveBaseline &&
media.paused &&
!media.getAttribute('src') &&
window.__packRevoked.length === 2 &&
window.__packMaterials.length === 1 &&
window.__packMaterials.every((material) => material.isDestroyed());
await d.seekScene('packs', 0.1);
const seek = d.getDataPackState().count === 3;
d.stopScene();
const stopped =
d.getDataPackState().count === 0 &&
d.viewer.entities.values.length === window.__packBaseline;
const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 });
return {
played,
cleared,
seek,
stopped,
missing: missing?.started === false && d.getDataPackState().count === 0,
};
});
for (const [name, passed] of Object.entries(life)) check(name, passed);
await page.evaluate(() => {
window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot(
'packs',
'slow',
);
});
const deadline = Date.now() + 15000;
while (!pending && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 50));
check('pending transport reached', !!pending);
const cancelled = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const start = performance.now();
d.stopScene();
await window.__pendingPack;
return performance.now() - start < 1000 && d.getDataPackState().count === 0;
});
check('Stop promptly settles a pending transport', cancelled);
if (pending)
await pending
.respond({
contentType: 'application/json',
body: JSON.stringify(geojson),
})
.catch(() => {});
check(
'late response cannot reintroduce entities',
await page.evaluate(
() =>
window.__godsEyeView.viewer.entities.values.length ===
window.__packBaseline &&
window.__godsEyeView.viewer.scene.primitives.length ===
window.__packPrimitiveBaseline,
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
await d.loadShot('packs', 'all', { flyDuration: 0.3 });
await d.importProjectFile(
new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'),
);
});
check(
'replacement import clears every pack resource',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getDataPackState().count === 0 &&
!document.querySelector('[data-director-packs]'),
),
);
check('no uncaught browser errors', errors.length === 0);
if (errors.length)
console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]')));
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env node
/** Sharing/authoring acceptance with local synthetic assets and real installed controls. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-sharing');
fs.mkdirSync(output, { recursive: true });
const geometry = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'point',
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 500] },
},
],
};
fs.writeFileSync(path.join(output, 'data.geojson'), JSON.stringify(geometry));
const camera = {
lat: 30.2672,
lon: -97.742,
alt: 4000,
heading: 0,
pitch: -90,
roll: 0,
};
const project = {
version: 6,
scenes: [
{
id: 'share',
title: 'Sharing fixture',
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'example/data.geojson' },
attribution: { text: 'Synthetic <b>fixture</b>', license: 'CC0-1.0' },
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'shot',
title: 'First',
camera,
durationSec: 0.3,
holdSec: 0,
visual: { style: 'normal', mapStack: 'photoreal' },
layers: {},
dataPackIds: ['data'],
interactions: [
{
id: 'info',
label: 'Read fixture',
target: { packId: 'data', featureId: 'point' },
action: { type: 'card', text: 'Synthetic scene note' },
},
],
},
],
},
{ id: 'second', title: 'Other scene', shots: [] },
],
};
const file = path.join(output, 'project.json');
fs.writeFileSync(file, JSON.stringify(project));
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
let failures = 0;
const errors = [],
requests = [];
const check = (name, ok) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
if (!ok) failures++;
};
page.on('pageerror', (e) => errors.push(e.message));
page.on('request', (r) => {
if (new URL(r.url()).pathname.startsWith('/scene-assets/'))
requests.push(r.url());
});
async function click(label) {
const handle = await page.evaluateHandle(
(label) =>
[...document.querySelectorAll('button')].find(
(b) => b.textContent === label,
),
label,
);
await handle.asElement().click();
await handle.dispose();
}
async function value(label, text) {
await page.$eval(
`[aria-label="${label}"]`,
(node, text) => {
node.value = text;
node.dispatchEvent(new Event('input', { bubbles: true }));
},
text,
);
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.click('[data-collapse-target="scene-panel"]');
const before = await page.evaluate(() =>
JSON.stringify(window.__godsEyeView.sceneDirector._project),
);
const input = await page.$('#scene-import-file');
await input.uploadFile(file);
await page.waitForSelector('[data-director-apply-import]');
check(
'preview parses metadata without acquiring assets or replacing the current project',
requests.length === 0 &&
(await page.evaluate(
(before) =>
JSON.stringify(window.__godsEyeView.sceneDirector._project) ===
before,
before,
)),
);
check(
'preview renders author attribution as literal text',
await page.evaluate(
() =>
document
.querySelector('[data-director-dialog]')
.textContent.includes('Synthetic <b>fixture</b>') &&
!document.querySelector('[data-director-dialog] b'),
),
);
await page.keyboard.press('Escape');
check(
'Escape cancels preview without altering saved work',
await page.evaluate(
(before) =>
!document.querySelector('[data-director-dialog]') &&
JSON.stringify(window.__godsEyeView.sceneDirector._project) === before,
before,
),
);
await input.uploadFile(file);
await page.waitForSelector('[data-director-apply-import]');
await page.click('[data-director-apply-import]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
check(
'Apply import replaces metadata but does not start playback or fetch a pack',
requests.length === 0 &&
(await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes.length === 2 &&
!window.__godsEyeView.sceneDirector.running,
)),
);
await click('EDIT DETAILS');
await click('Capture camera as anchor');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('Anchor added'),
);
await click('Set move start to current camera');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('Move added'),
);
const draft = await page.$eval(
'[aria-label="Shot camera, timing, packs and actions"]',
(n) => n.value,
);
const bad = JSON.parse(draft);
bad.camera.lat = 91;
await value('Shot camera, timing, packs and actions', JSON.stringify(bad));
const saved = await page.evaluate(() =>
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
await page.click('[data-director-apply-details]');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('camera.lat'),
);
check(
'invalid draft preserves saved bytes and existing authored content',
await page.evaluate(
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
saved,
),
);
await value('Shot camera, timing, packs and actions', draft);
await page.click('[data-director-apply-details]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
check(
'author controls preserve selected shot, pack attribution and action definitions',
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector,
s = d._getSelectedScene();
return (
d._selectedShotId === 'shot' &&
s.anchors.length === 1 &&
s.shots[0].move.easing === 'cubic-in-out' &&
s.shots[0].interactions.length === 1 &&
s.dataPacks[0].attribution.license === 'CC0-1.0'
);
}),
);
await page.evaluate(() => {
window.__shareExports = [];
const create = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
if (blob.type === 'application/json')
window.__shareExports.push(blob.text());
return create(blob);
};
});
await click('SHARE SCENE');
await click('Download scene JSON');
const exported = await page.evaluate(async () =>
JSON.parse(await window.__shareExports.at(-1)),
);
check(
'scene JSON exports only the selected scene and keeps its credit',
exported.scenes.length === 1 &&
exported.scenes[0].dataPacks[0].attribution.license === 'CC0-1.0',
);
await click('Download asset bundle');
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
.textContent.includes('Could not complete'),
);
check(
'missing explicit files produce no partial download or network fallback',
requests.length === 0 &&
(await page.evaluate(() => window.__shareExports.length === 1)),
);
const files = await page.$('[aria-label="Choose data-pack files"]');
await files.uploadFile(path.join(output, 'data.geojson'));
await click('Download asset bundle');
await page.waitForFunction(
() =>
document.querySelector('[data-director-dialog] [role=status]')
.textContent === 'Asset bundle downloaded',
);
const bundle = await page.evaluate(
async () => await window.__shareExports.at(-1),
);
const bundleFile = path.join(output, 'scene.gevbundle.json');
fs.writeFileSync(bundleFile, bundle);
check(
'bundle contains the selected bytes and preserves attribution',
JSON.parse(bundle).assets.length === 1 &&
JSON.parse(bundle).project.scenes[0].dataPacks[0].attribution.license ===
'CC0-1.0',
);
await page.keyboard.press('Escape');
await input.uploadFile(bundleFile);
await page.waitForSelector('[data-director-apply-import]');
check(
'bundle preview verifies bytes and explains session-only storage',
await page.evaluate(
() =>
document
.querySelector('[data-director-dialog]')
.textContent.includes('bundled bytes verified') &&
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
),
);
await page.screenshot({ path: path.join(output, 'preview.png') });
await page.click('[data-director-apply-import]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.loadShot('share', 'shot', {
flyDuration: 0.3,
}),
);
check(
'imported bundle renders and enables feature actions with zero asset requests',
requests.length === 0 &&
(await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getDataPackState().count === 1 &&
window.__godsEyeView.sceneDirector.getInteractionState().count === 1,
)),
);
await page.waitForFunction(
() => window.__godsEyeView.tileset?.tilesLoaded === true,
{ timeout: 60000 },
);
await page.screenshot({ path: path.join(output, 'rendered.png') });
fs.writeFileSync(
path.join(output, 'report.json'),
JSON.stringify(
await page.evaluate(() => ({
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
})),
),
);
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
check(
'Stop releases rendered resources while retaining reusable project bytes',
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
return (
d.getDataPackState().count === 0 &&
d.getSharingState().assets.count === 1
);
}),
);
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.seekScene('share', 0),
);
check(
'seek reloads the bundle without a network fallback',
requests.length === 0 &&
(await page.evaluate(
() => window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
)),
);
await click('EDIT DETAILS');
await page.evaluate(() => {
window.__godsEyeView.sceneDirector._project.scenes[0].title = 'Newer edit';
});
await page.click('[data-director-apply-details]');
check(
'stale author draft cannot overwrite a newer edit',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector._project.scenes[0].title ===
'Newer edit',
),
);
await page.keyboard.press('Escape');
await click('SHARE SCENE');
await page.setViewport({ width: 390, height: 844 });
check(
'modal remains within a narrow viewport',
await page.$eval('[data-director-dialog]', (n) => {
const r = n.getBoundingClientRect();
return (
r.left >= 0 &&
r.right <= innerWidth &&
r.top >= 0 &&
r.bottom <= innerHeight
);
}),
);
await page.screenshot({ path: path.join(output, 'narrow.png') });
await page.keyboard.press('Escape');
const pending = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
let finish;
const work = d._sharing.preview({
name: 'pending.json',
text: () => new Promise((r) => (finish = r)),
});
d._sharing.close();
await work;
finish(JSON.stringify({ version: 6, scenes: [] }));
return !d.getSharingState().open;
});
check(
'cancel settles a pending file read and suppresses its late completion',
pending,
);
const cancelledApply = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const before = JSON.stringify(d._project);
const saved = JSON.stringify({ ...localStorage });
let finish;
const pendingWork = new Promise((resolve) => (finish = resolve));
d._pendingWork.add(pendingWork);
const owner = new AbortController();
const work = d.importProjectFile(
{ name: 'cancelled.json' },
{
prepared: { project: { version: 6, scenes: [] }, assets: new Map() },
expectedProject: JSON.stringify(d._project, null, 2),
signal: owner.signal,
},
);
owner.abort();
finish();
const applied = await work;
d._pendingWork.delete(pendingWork);
return (
applied === false &&
JSON.stringify(d._project) === before &&
JSON.stringify({ ...localStorage }) === saved &&
d.getSharingState().assets.count === 1
);
});
check(
'cancelling Apply during pending scene cleanup preserves saved project and bundle bytes',
cancelledApply,
);
await click('EDIT DETAILS');
await page.evaluate(() => {
const fields = [
...document.querySelectorAll('[data-director-dialog] textarea'),
];
const scene = JSON.parse(fields[0].value),
shot = JSON.parse(fields[1].value);
scene.dataPacks = [];
shot.dataPackIds = [];
shot.interactions = [];
fields[0].value = JSON.stringify(scene);
fields[1].value = JSON.stringify(shot);
});
await page.click('[data-director-apply-details]');
await page.waitForFunction(
() => !document.querySelector('[data-director-dialog]'),
);
check(
'removing a pack through authoring releases its retained bytes',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
),
);
await page.evaluate(
(text) =>
window.__godsEyeView.sceneDirector.importProjectFile(
new File([text], 'again.gevbundle.json'),
),
bundle,
);
await page.evaluate(() =>
window.__godsEyeView.sceneDirector.importProjectFile(
new File([JSON.stringify({ version: 6, scenes: [] })], 'empty.json'),
),
);
check(
'replacement releases stored bundle bytes',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
),
);
await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
void d._sharing.preview({
name: 'pending.json',
text: () => new Promise(() => {}),
});
await d.destroy();
});
check(
'teardown removes pending dialogs and authoring controls',
await page.evaluate(
() =>
!document.querySelector('[data-director-dialog]') &&
!document.querySelector('[data-director-authoring]') &&
!window.__godsEyeView.sceneDirector.getSharingState().open,
),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const shots = path.resolve('qa-shots/director-timing');
fs.mkdirSync(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failures = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const director = window.__godsEyeView.sceneDirector;
const camera = director.styleManager.getCameraState();
const visual = director.styleManager.getVisualState();
const scene = {
id: 'timing-qa',
title: 'Timing QA',
shots: [0, 1].map((index) => ({
id: `timing-${index}`,
title: `Timing ${index}`,
durationSec: 0.4,
holdSec: 0.3,
camera: { ...camera, lon: camera.lon + index * 0.002 },
visual,
layers: {},
})),
};
director._project.scenes.push(scene);
const load = await director.loadShot(scene.id, scene.shots[0].id, {
flyDuration: 0.3,
});
const forward = await director.seekScene(scene.id, 0.9);
const forwardSnapshot = director.getPlaybackTimingState().snapshot;
const backward = await director.seekScene(scene.id, 0.1);
const backwardSnapshot = director.getPlaybackTimingState().snapshot;
const replay = await director.replayShot(scene.id, scene.shots[1].id);
director.stopScene();
await director.startScene(scene.id, { single: true, preview: false });
const completed =
!director.running && director.getPlaybackTimingState().activeTimers === 0;
scene.shots[0].holdSec = 60;
const run = director.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (
director.getPlaybackTimingState().activeTimers < 3 &&
Date.now() < deadline
)
await new Promise((resolve) => setTimeout(resolve, 25));
const holding = director.getPlaybackTimingState().activeTimers >= 3;
const stoppedAt = performance.now();
director.stopScene('QA Stop during hold');
await run;
const stoppedPromptly = performance.now() - stoppedAt < 1000;
const noTimers = director.getPlaybackTimingState().activeTimers === 0;
director._project.scenes = director._project.scenes.filter(
({ id }) => id !== scene.id,
);
return {
loaded: load?.started,
forward: forward && forwardSnapshot.shotId === 'timing-1',
backward: backward && backwardSnapshot.shotId === 'timing-0',
replay: replay?.started,
completed,
holding,
stoppedPromptly,
noTimers,
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
const content = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes.find(({ title }) =>
/Nepal Flood Incident/i.test(title),
);
if (!scene) return { exists: false };
const shot =
scene.shots.find(({ title }) => /Regional Approach/i.test(title)) ||
scene.shots[0];
const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 });
return {
exists: true,
shots: scene.shots.length,
loaded: loaded?.started === true,
};
});
check(
'existing Nepal scene and authored shots load',
content.exists && content.shots >= 25 && content.loaded,
);
await new Promise((resolve) => setTimeout(resolve, 2500));
await page.screenshot({ path: path.join(shots, 'nepal-regional.png') });
await page.evaluate(() => {
const g = window.__godsEyeView;
g.sceneDirector.stopScene();
const pose = g.styleManager.getCameraState();
g.sceneDirector._setCameraView({
...pose,
heading: pose.heading + 45,
pitch: -55,
});
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await page.screenshot({ path: path.join(shots, 'nepal-angle.png') });
check(
'no pending clocks after Stop',
await page.evaluate(
() =>
window.__godsEyeView.sceneDirector.getPlaybackTimingState()
.activeTimers === 0,
),
);
check('no browser exceptions', errors.length === 0);
} finally {
await browser.close();
}
if (failures) process.exitCode = 1;
+57 -2
View File
@@ -85,6 +85,9 @@ try {
);
promptValue = '<b>QA shot</b>';
await page.click('.scene-shot-label', { count: 2 });
await page.waitForSelector('.scene-shot-rename', { visible: true });
await page.type('.scene-shot-rename', promptValue);
await page.keyboard.press('Enter');
await page.waitForFunction(
() =>
document.querySelector('.scene-shot-label')?.textContent ===
@@ -151,7 +154,20 @@ try {
const projectFile = path.join(shots, 'project.json');
fs.writeFileSync(projectFile, JSON.stringify(fixture));
const input = await page.$('#scene-import-file');
const beforePreview = await page.evaluate(() =>
JSON.stringify(window.__godsEyeView.sceneDirector._project),
);
await input.uploadFile(projectFile);
await page.waitForSelector('[data-director-apply-import]');
check(
'Import preview leaves the current project untouched',
await page.evaluate(
(saved) =>
JSON.stringify(window.__godsEyeView.sceneDirector._project) === saved,
beforePreview,
),
);
await page.click('[data-director-apply-import]');
await page.waitForFunction(
() =>
document.getElementById('scene-status').textContent ===
@@ -259,8 +275,8 @@ try {
await input.uploadFile(badFile);
await page.waitForFunction(() =>
document
.getElementById('scene-status')
.textContent.includes('invalid JSON'),
.querySelector('[data-director-dialog] [role=status]')
?.textContent.includes('invalid JSON'),
);
check(
'Invalid import reports failure and preserves the current project',
@@ -271,6 +287,45 @@ try {
'QA Scene',
),
);
const savedBefore = await page.evaluate(() =>
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
const futureFile = path.join(shots, 'future.json');
fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] }));
await input.uploadFile(futureFile);
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
?.textContent.includes('$.version'),
);
check(
'Unsupported versions leave authored state and saved bytes unchanged',
await page.evaluate(
(saved) =>
localStorage.getItem('godsEyeView.sceneProject.v2') === saved &&
window.__godsEyeView.sceneDirector._getSelectedScene().title ===
'QA Scene',
savedBefore,
),
);
const malformed = structuredClone(fixture);
malformed.scenes[0].shots[0].camera.lat = 91;
const malformedFile = path.join(shots, 'malformed.json');
fs.writeFileSync(malformedFile, JSON.stringify(malformed));
await input.uploadFile(malformedFile);
await page.waitForFunction(() =>
document
.querySelector('[data-director-dialog] [role=status]')
?.textContent.includes('camera.lat'),
);
check(
'Invalid camera field identifies its path without replacing the project',
await page.evaluate(
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
savedBefore,
),
);
await page.keyboard.press('Escape');
await page.screenshot({ path: path.join(shots, 'desktop.png') });
await page.setViewport({ width: 390, height: 844 });
await page.screenshot({ path: path.join(shots, 'narrow.png') });
+107 -37
View File
@@ -367,7 +367,7 @@ export async function sampleTransitPixels(page, palette) {
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const readLuma = (cx, cy) => {
const px = Math.floor(cx * scale);
const py = Math.floor(canvas.height - 1 - cy * scale);
const py = canvas.height - 1 - Math.floor(cy * scale);
const buf = new Uint8Array(4);
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
return luma(buf[0], buf[1], buf[2]);
@@ -399,46 +399,117 @@ export async function sampleTransitPixels(page, palette) {
continue;
}
}
// Pick nine points from a solid patch of the source silhouette,
// before examining framebuffer brightness. Avoid panels/window gaps.
let corePoints = null,
widestBand = -1;
const cosCore = Math.cos(s.rotation),
sinCore = Math.sin(s.rotation);
// A tram's parallel sides have equally wide bands. Prefer its upper
// body band over the geometric centre when those widths tie.
const offsets =
s.mode === 'tram'
? [-0.2, 0.2, -0.1, 0.1, 0]
: [0, -0.1, 0.1, -0.2, 0.2];
for (const offset of offsets) {
const candidates = [];
let solid = true,
bandWidth = Infinity;
// Rank source geometry, never the observed framebuffer brightness.
// A thin tram needs the widest opaque white band, with room for
// the pixel filter on each side of the sampled patch.
for (let dy = -1; dy <= 1; dy++) {
const ry = Math.floor(
((dy + offset * s.rect.height) / s.rect.height + 0.5) *
s.raster.height,
);
const middle = Math.floor(s.raster.width / 2);
const white = (x) =>
x >= 0 &&
x < s.raster.width &&
[0, 1, 2, 3].every(
(c) =>
s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240,
);
let left = middle,
right = middle;
while (white(left - 1)) left--;
while (white(right + 1)) right++;
bandWidth = Math.min(
bandWidth,
white(middle) ? right - left + 1 : 0,
);
}
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++) {
const localY = dy + offset * s.rect.height;
const rx = Math.floor(
(dx / s.rect.width + 0.5) * s.raster.width,
);
const ry = Math.floor(
(localY / s.rect.height + 0.5) * s.raster.height,
);
const i = (ry * s.raster.width + rx) * 4;
if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240))
solid = false;
candidates.push(
screenPoint(
s.screenX + dx * cosCore + localY * sinCore,
s.screenY - dx * sinCore + localY * cosCore,
),
);
}
if (solid && bandWidth > widestBand) {
corePoints = candidates;
widestBand = bandWidth;
}
}
if (!corePoints) {
skipped.push({
key: s.key,
reason: 'no solid silhouette interior for nine samples',
});
continue;
}
// Picking is independent of rendered brightness: an overlapping
// label or sprite must not masquerade as a dim sensor signature.
// Check the whole sampled core, not just its centre pick.
let coreOwned = true;
if (scene.pick) {
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++) {
let u = (s.x + dx) / width,
v = 1 - (s.y + dy) / height;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d =
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
if (sensor) {
const grid = 1 + (pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) /
canvas.width -
u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) /
canvas.height -
v) *
intensity;
}
const picked = scene.pick(
{ x: u * width, y: (1 - v) * height },
1,
1,
);
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
coreOwned = false;
for (const core of corePoints) {
let u = core.x / width,
v = 1 - core.y / height;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d =
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
if (sensor) {
const grid = 1 + (pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) /
canvas.width -
u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) /
canvas.height -
v) *
intensity;
}
const picked = scene.pick(
{ x: u * width, y: (1 - v) * height },
1,
1,
);
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
coreOwned = false;
}
if (gl.bindFramebuffer)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
}
@@ -451,9 +522,8 @@ export async function sampleTransitPixels(page, palette) {
continue;
}
let centre = 0;
for (let dx = -1; dx <= 1; dx += 1)
for (let dy = -1; dy <= 1; dy += 1)
centre += readLuma(s.x + dx, s.y + dy) / 9;
for (const core of corePoints)
centre += readLuma(core.x, core.y) / corePoints.length;
let ringMin = 1;
let ringMax = 0;
// Sample the actual rotated raster halo, not a fixed-radius circle
+156
View File
@@ -0,0 +1,156 @@
import puppeteer from 'puppeteer';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox', '--disable-background-timer-throttling'],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
{ timeout: 90000 },
);
await new Promise((resolve) => setTimeout(resolve, 6000));
await page.evaluate(async () => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
const layer = app.dataManager.layers.get('transit').module;
layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100);
const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest();
const { destroyTrack } = await import('/src/data/contactPlayback.js');
const {
initializePlayback,
recordFix,
updatePlayback,
attachFloorToPlace,
} = await import('/src/layers/transit/movement.js');
// The first three are parked: held east, reported east only, unknown.
for (let i = 0; i < 3; i++) {
const entry = state._vehicles.get(`mbta:qa-${i}`);
state._moving.delete(entry);
destroyTrack(entry.track);
initializePlayback(entry, state._historyBudget);
recordFix(entry, {
t: Date.now() - 30000,
lat: entry.record.lat,
lon: entry.record.lon,
});
attachFloorToPlace(entry, entry.record, 20);
updatePlayback(entry, Date.now(), performance.now());
entry.courseDeg = i === 0 ? 90 : null;
entry.record.bearing = i === 1 ? 90 : null;
entry.displayPaths.clear();
parts.rendering.placeSample(entry);
parts.rendering.schedulePlayback(entry);
}
parts.rendering.syncRenderHold();
window.__headingErrors = [];
app.viewer.scene.renderError.addEventListener((scene, error) =>
window.__headingErrors.push(error.message),
);
});
const pose = async (heading) => {
await page.evaluate((heading) => {
const camera = window.__godsEyeView.viewer.camera;
const C = camera.positionCartographic.constructor;
camera.setView({
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
});
}, heading);
await new Promise((resolve) => setTimeout(resolve, 700));
};
const sample = () =>
page.evaluate(async () => {
const app = window.__godsEyeView,
scene = app.viewer.scene;
const state = app.dataManager.layers
.get('transit')
.module._transitStateForTest();
const mul = (m, v) =>
[0, 1, 2, 3].map(
(r) =>
m[r] * v[0] +
m[r + 4] * v[1] +
m[r + 8] * v[2] +
m[r + 12] * v[3],
);
const project = (p) => {
const clip = mul(
scene.camera.frustum.projectionMatrix,
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
);
return {
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2,
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2,
};
};
return [0, 1, 2, 3].map((i) => {
const entry = state._vehicles.get(`mbta:qa-${i}`);
const p = project(entry.marker.position);
return {
rotation: entry.marker.rotation,
course: entry.courseDeg,
moving: state._moving.has(entry),
visible: entry.marker.show,
x: p.x,
y: p.y,
};
});
});
await pose(0);
const before = await sample();
await pose(Math.PI / 2);
const after = await sample();
const deltaDeg = (a, b) =>
(Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI;
for (const [i, label] of ['held course', 'reported bearing'].entries()) {
const delta = deltaDeg(before[i].rotation, after[i].rotation);
check(
`parked ${label} reprojects through a 90 degree camera orbit`,
before[i].visible &&
after[i].visible &&
!before[i].moving &&
!after[i].moving &&
Math.abs(delta - 90) <= 5,
JSON.stringify({ before: before[i], after: after[i], delta }),
);
}
check(
'unknown parked course remains screen-up',
before[2].rotation === 0 && after[2].rotation === 0,
);
await new Promise((resolve) => setTimeout(resolve, 1200));
const moved = (await sample())[3];
const dx = moved.x - after[3].x,
dy = moved.y - after[3].y;
const travelRotation = Math.atan2(-dx, -dy);
const error = Math.abs(deltaDeg(travelRotation, moved.rotation));
check(
'moving vehicle still points along its rendered travel after orbit',
moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5,
JSON.stringify({
pixels: Math.hypot(dx, dy),
error,
after: after[3],
moved,
}),
);
check(
'heading regression has no scene render errors',
(await page.evaluate(() => window.__headingErrors)).length === 0,
);
} finally {
await browser.close();
}
}
+330
View File
@@ -0,0 +1,330 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(
`${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`,
);
await page.waitForFunction(() =>
window.__godsEyeView?.dataManager?.layers.has('transit'),
);
await page.evaluate(async () => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
});
await page.waitForFunction(
() => {
const scene = window.__godsEyeView.viewer.scene;
return scene.globe.tilesLoaded && scene.globe.show;
},
{ timeout: 60000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return !cover || cover.classList.contains('hidden');
},
{ timeout: 60000 },
);
await page.evaluate(() => {
const app = window.__godsEyeView,
camera = app.viewer.camera;
const C = camera.positionCartographic.constructor,
lat = 30.267 + 90 / 111320;
const floor =
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0;
camera.setView({
destination: C.toCartesian(
C.fromDegrees(
-97.7431,
lat - 600 / Math.tan(Math.PI / 3) / 111320,
floor + 600,
),
),
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
await page.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 60000 },
);
await new Promise((resolve) => setTimeout(resolve, 1500));
const result = await page.evaluate(async () => {
const app = window.__godsEyeView,
scene = app.viewer.scene,
camera = app.viewer.camera;
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest();
const { getRegisteredTransitFeed } =
await import('/src/data/transitFeeds.js');
const { setRate } = await import('/src/data/contactPlayback.js');
const C = camera.positionCartographic.constructor;
const feed = {
...getRegisteredTransitFeed('capmetro-austin'),
id: 'qa-camera-recovery',
};
const aim = (lat, height = 600) =>
camera.setView({
destination: C.toCartesian(
C.fromDegrees(
-97.7431,
lat - 600 / Math.tan(Math.PI / 3) / 111320,
height,
),
),
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
});
parts.selection.clearSelection();
parts.ingestion.abortAllInFlight();
state._activeFeeds.clear();
for (const key of state._vehicles.keys())
parts.ingestion.removeVehicle(key);
// Model an outstanding floor lookup while reports arrive off screen.
// Only acquisition is controlled; selection, visibility, paths and rendering
// use their real owners. Release to the loaded terrain at the camera jump.
const requestHeight = parts.height.requestHeight;
parts.height.requestHeight = (lat, lon) => ({
cell: `${lat},${lon}`,
height: null,
prior: null,
});
aim(30.28);
const now = Date.now();
for (let i = 0; i < 9; i++) {
const timestamp = now - 135000 + i * 15000;
parts.ingestion.applySnapshot(
feed,
{
fetchedAt: timestamp,
vehicles: [
{
id: 'moving-bus',
routeId: '1',
tripId: 'qa',
lat: 30.267 + (i * 90) / 111320,
lon: -97.7431,
timestamp: timestamp / 1000,
timestampSource: 'vehicle',
bearing: 0,
},
],
},
{ stale: false },
);
}
const entry = state._vehicles.get(`${feed.id}:moving-bus`);
entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction.
parts.rendering.sampleIdle(entry);
const before = {
shown: entry.marker.show,
heightPending: entry.heightPending,
phase: entry.sample.phase,
fixes: entry.track.count,
};
clearTimeout(state._floorTimer);
state._floorTimer = null;
const started = performance.now();
parts.selection.selectVehicle(entry.key);
const preparedAtSelection = !!parts.trails.diagnostics()?.body;
parts.height.requestHeight = requestHeight;
const floor =
scene.globe.getHeight(
C.fromDegrees(entry.sample.lon, entry.sample.lat),
) || 0;
aim(entry.sample.lat, floor + 600);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let trail;
while (performance.now() - started < 1400) {
trail = parts.trails.diagnostics();
if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
scene.requestRender();
await wait(25);
}
const status = {
before,
preparedAtSelection,
markerShow: entry.marker.show,
body: !!trail?.body,
bodyShow: trail?.body?.show === true,
visibility: layer
._transitVisibilityForTest()
.find((e) => e.key === entry.key),
recoveryMs: performance.now() - started,
};
if (!entry.marker.show || !trail?.body?.show || !trail.body.ready)
return {
...status,
reason: 'marker or body did not recover before pixel deadline',
};
setRate(entry.track, 0, {
wallNowMs: Date.now(),
monoNowMs: performance.now(),
});
parts.rendering.sampleIdle(entry);
parts.rendering.schedulePlayback(entry);
const V = entry.marker.position.constructor;
const points = entry.trailSegments
.filter((s) => s.toT <= entry.sample.displayT)
.flatMap((s) => s.positions);
const lengths = [0];
for (let i = 1; i < points.length; i++)
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
const canvas = scene.canvas,
gl = scene.context._gl,
scale = canvas.width / canvas.clientWidth;
const mul = (m, v) =>
[0, 1, 2, 3].map(
(i) =>
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
);
const project = (p) => {
const q = mul(
camera.frustum.projectionMatrix,
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]),
);
return {
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
inFront: q[3] > 0,
};
};
const samples = Array.from({ length: 8 }, (_, i) => {
const d = (lengths.at(-1) * (i + 1)) / 9;
let j = 1;
while (j < lengths.length - 1 && lengths[j] < d) j++;
const p = V.lerp(
points[j - 1],
points[j],
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
new V(),
);
const cart = C.fromCartesian(p);
cart.height = scene.globe.getHeight(cart) || 0;
return {
...project(C.toCartesian(cart)),
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
};
});
const sprite = {
...project(entry.marker.position),
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
};
const read = () =>
samples.map(({ x, y }) => {
const r = Math.ceil(3 * scale),
px = Math.round(x * scale),
py = Math.round(canvas.height - 1 - y * scale);
if (
px < r ||
py < r ||
px + r >= canvas.width ||
py + r >= canvas.height
)
return null;
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
gl.readPixels(
px - r,
py - r,
2 * r + 1,
2 * r + 1,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
return Array.from(data);
});
const frame = () =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('No recovery frame'));
}, 500);
const remove = scene.postRender.addEventListener(() => {
remove();
clearTimeout(timer);
resolve(read());
});
scene.requestRender();
});
let visible = true;
const remove = scene.preRender.addEventListener(() => {
trail.body.show = visible;
if (trail.headPrimitive)
trail.headPrimitive.show = visible && trail.head.show;
});
const captures = [];
try {
for (const show of [false, true, false, true]) {
visible = show;
await frame();
captures.push(await frame());
}
} finally {
remove();
parts.trails.update();
}
const [off, on, offAgain, onAgain] = captures;
return {
...status,
samples,
sprite,
off,
on,
offAgain,
onAgain,
tilesReady:
scene.globe.tilesLoaded &&
parts.trails.diagnostics().body === trail.body,
elapsedMs: performance.now() - started,
attributes: Array.from({ length: 4 }, (_, i) => {
const a = trail.body.getGeometryInstanceAttributes(`${i}:1`);
return { show: Array.from(a.show), color: Array.from(a.color) };
}),
};
});
const pixels = reduceTrailPixels(result.on, result.off, result);
result.present = pixels.present;
result.pixelPass = pixels.pass;
result.pixelDetails = pixels;
result.errors = errors;
await page.screenshot({ path: `${shots}/recovery.png` });
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
const pass =
result.before?.shown === false &&
result.before?.phase === 'playing' &&
result.preparedAtSelection &&
result.markerShow &&
result.body &&
result.bodyShow &&
result.elapsedMs <= 2000 &&
pixels.pass &&
!errors.length;
console.log(
`${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`,
);
console.log(JSON.stringify(result));
process.exitCode = pass ? 0 : 1;
} finally {
await browser.close();
}
+466
View File
@@ -1,3 +1,12 @@
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
// Not registered or active: camera discovery and live polls never own this feed.
/** CapMetro metadata under a private QA identity for selected-trail pixels. */
export const TRAIL_VISIBILITY_FEED = Object.freeze({
...getRegisteredTransitFeed('capmetro-austin'),
id: 'qa-trail-capmetro',
});
import { reportTransitVisibility } from './qa-transit-browser.mjs';
import { writeFile } from 'node:fs/promises';
import {
@@ -5,6 +14,7 @@ import {
reduceMotion,
reduceBackground,
reduceSensorContrast,
reduceTrailPixels,
} from '../src/layers/transit/qaMetrics.js';
/** CDP includes objects collected during the sample, not just surviving objects. */
@@ -581,3 +591,459 @@ export async function runBostonLive({
JSON.stringify(evidence, null, 2),
);
}
/** Google 3D proof: paired framebuffer reads reject coincidental road colours. */
export async function runTrailVisibility({
page,
check,
wait,
shots,
tag,
selectStyle,
altitude = 600,
pitch = 45,
}) {
const name = 'Google 3D selected trail visible at 6 of 8 projected samples';
await selectStyle(page, 'normal');
await page.evaluate(
async ({ altitude, pitch }) => {
const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
if (app.mapStackController.isStackAvailable('photoreal'))
await app.mapStackController.setStack('photoreal');
const camera = app.viewer.camera,
C = camera.positionCartographic.constructor;
camera.setView({
destination: C.toCartesian(
C.fromDegrees(
-97.7431,
30.26725 - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
altitude,
),
),
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
});
},
{ altitude, pitch },
);
await wait(8000);
const setup = await page.evaluate(
async ({ altitude, pitch, feed }) => {
const app = window.__godsEyeView,
scene = app.viewer.scene;
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest();
const google =
!!app.tileset &&
app.tileset.show !== false &&
app.mapStackController.getActiveId() === 'photoreal';
if (!google)
return { google: false, reason: 'visible Google 3D tileset required' };
const C = scene.camera.positionCartographic.constructor;
const centreLat = 30.267 + 90 / 111320;
const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat));
if (Number.isFinite(floor))
scene.camera.setView({
destination: C.toCartesian(
C.fromDegrees(
-97.7431,
centreLat - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
floor + altitude,
),
),
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
});
parts.selection.clearSelection();
parts.ingestion.abortAllInFlight();
state._activeFeeds.clear();
for (const key of state._vehicles.keys())
parts.ingestion.removeVehicle(key);
clearTimeout(state._cameraDebounceTimer);
state._cameraDebounceTimer = null;
const { seek, setRate } = await import('/src/data/contactPlayback.js');
const now = Date.now();
// Nine straight street fixes, 30 m apart. Use the production surface path.
for (let i = 0; i < 9; i++) {
const timestamp = (now - 120000 + i * 15000) / 1000;
parts.ingestion.applySnapshot(
feed,
{
fetchedAt: timestamp * 1000,
vehicles: [
{
id: 'trail-visible',
routeId: '1',
tripId: 'trail-visible',
lat: 30.267 + (i * 30) / 111320,
lon: -97.7431,
timestamp,
timestampSource: 'vehicle',
bearing: 0,
},
],
},
{ stale: false },
);
}
const entry = state._vehicles.get(`${feed.id}:trail-visible`);
entry.qaFixture = true;
const clocks = { wallNowMs: now, monoNowMs: performance.now() };
seek(entry.track, now - 45000, clocks);
setRate(entry.track, 1, clocks);
parts.rendering.sampleIdle(entry);
parts.height.anchorFloors();
parts.rendering.refreshVisibility();
parts.rendering.maintainPresentation();
parts.selection.selectVehicle(entry.key);
return {
google,
key: entry.key,
fixes: entry.track.count,
startDisplayT: now - 45000,
startLat: entry.sample.lat,
};
},
{ altitude, pitch, feed: TRAIL_VISIBILITY_FEED },
);
if (!setup.google) {
check(name, false, JSON.stringify(setup), { unexercised: true });
return;
}
const cost = await page.evaluate(async (key) => {
const app = window.__godsEyeView,
scene = app.viewer.scene;
const layer = app.dataManager.layers.get('transit').module;
const parts = layer._transitPartsForTest(),
entry = layer._transitStateForTest()._vehicles.get(key);
const gl = scene.context._gl,
originals = new Map(),
wrapped = new Map();
let owner = null,
headBytes = 0,
bodyBytes = 0,
headUploadMax = 0,
bodyUploadMax = 0,
frames = 0;
const headCpu = [];
const wrap = (primitive, kind) => {
if (!primitive || wrapped.has(primitive)) return;
const update = primitive.update;
wrapped.set(primitive, update);
primitive.update = function (frameState) {
const before = performance.now();
owner = kind;
try {
return update.call(this, frameState);
} finally {
owner = null;
if (kind === 'head') headCpu.push(performance.now() - before);
}
};
};
for (const name of ['bufferData', 'bufferSubData']) {
const original = gl[name];
originals.set(name, original);
gl[name] = function (...args) {
if (owner) {
const value = args[name === 'bufferData' ? 1 : 2],
offset = args[3] || 0,
length = args[4];
const bytes =
typeof value === 'number'
? value
: value
? length !== undefined
? length * (value.BYTES_PER_ELEMENT || 1)
: value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1)
: 0;
if (owner === 'head') headBytes += bytes;
else bodyBytes += bytes;
}
return original.apply(this, args);
};
}
const pre = scene.preRender.addEventListener(() => {
headBytes = bodyBytes = 0;
const trail = parts.trails.diagnostics();
wrap(trail?.body, 'body');
wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head');
});
const post = scene.postRender.addEventListener(() => {
frames++;
headUploadMax = Math.max(headUploadMax, headBytes);
bodyUploadMax = Math.max(bodyUploadMax, bodyBytes);
});
try {
// Include a full history upload, even if the first selection already rendered.
entry.pathRevision = -1;
parts.trails.prepareEntry(entry);
scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 20000));
headCpu.sort((a, b) => a - b);
return {
frames,
headUploadMax,
bodyUploadMax,
headCpuP95Ms: headCpu[Math.floor(headCpu.length * 0.95)] ?? null,
headCpuMaxMs: headCpu.at(-1) ?? null,
};
} finally {
pre();
post();
for (const [name, original] of originals) gl[name] = original;
for (const [primitive, update] of wrapped)
if (!primitive.isDestroyed()) primitive.update = update;
}
}, setup.key);
const result = await page.evaluate(async (setup) => {
const app = window.__godsEyeView,
scene = app.viewer.scene;
const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest();
const entry = state._vehicles.get(setup.key);
const trail = parts.trails.diagnostics();
if (!entry || !trail)
return { ...setup, present: 0, reason: 'selection disappeared' };
const { setRate } = await import('/src/data/contactPlayback.js');
setRate(entry.track, 0, {
wallNowMs: Date.now(),
monoNowMs: performance.now(),
});
parts.rendering.sampleIdle(entry);
parts.rendering.schedulePlayback(entry);
parts.rendering.syncRenderHold();
const ready = () =>
app.tileset?.show !== false &&
app.tileset?.tilesLoaded === true &&
(scene.globe?.show === false || scene.globe?.tilesLoaded === true) &&
trail.body?.ready === true &&
(!trail.headPrimitive || trail.headPrimitive.ready === true);
// Require sustained readiness after freezing playback, before projecting
// onto the refined surface. Repeated captures still check pixel stability.
const tilesReady = await new Promise((resolve) => {
const started = performance.now();
let readySince = null;
const timer = setInterval(() => {
const now = performance.now();
readySince = ready() ? (readySince ?? now) : null;
scene.requestRender();
if (
(readySince !== null && now - readySince >= 500) ||
now - started >= 30000
) {
clearInterval(timer);
resolve(readySince !== null && now - readySince >= 500);
}
}, 80);
});
if (!tilesReady)
return {
...setup,
present: 0,
reason: 'tiles or trail geometry did not become ready',
};
const canvas = scene.canvas,
gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const scale = canvas.width / canvas.clientWidth;
const points = entry.trailSegments
.filter((s) => s.toT <= entry.sample.displayT)
.flatMap((s) => s.positions);
if (trail.head?.show) points.push(...trail.head.positions);
if (points.length < 2)
return { ...setup, present: 0, reason: 'no completed path' };
const C = scene.camera.positionCartographic.constructor;
const V = entry.marker.position.constructor;
const lengths = [0];
for (let i = 1; i < points.length; i++)
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
const mul = (m, v) =>
[0, 1, 2, 3].map(
(i) =>
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
);
const samples = Array.from({ length: 8 }, (_, i) => {
const d = (lengths.at(-1) * (i + 1)) / 9;
let j = 1;
while (j < lengths.length - 1 && lengths[j] < d) j++;
const p = V.lerp(
points[j - 1],
points[j],
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
new V(),
);
const cart = C.fromCartesian(p);
const height = scene.sampleHeight(
cart,
[entry.marker, trail.body, trail.headPrimitive].filter(Boolean),
);
if (Number.isFinite(height)) cart.height = height;
const surface = C.toCartesian(cart);
const q = mul(
scene.camera.frustum.projectionMatrix,
mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]),
);
return {
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
inFront: q[3] > 0,
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
};
});
const marker = entry.marker.position;
const markerClip = mul(
scene.camera.frustum.projectionMatrix,
mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]),
);
const sprite = {
x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight,
// Include the padded, rotated sprite and its selection bracket.
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
};
const read = () =>
samples.map(({ x, y, inFront }) => {
const r = Math.ceil(3 * scale),
px = Math.round(x * scale),
py = Math.round(canvas.height - 1 - y * scale);
if (
!inFront ||
px < r ||
py < r ||
px + r >= canvas.width ||
py + r >= canvas.height
)
return null;
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
gl.readPixels(
px - r,
py - r,
2 * r + 1,
2 * r + 1,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
return data;
});
const frame = () =>
new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
remove();
reject(new Error('Trail framebuffer did not render within 5 s'));
}, 5000);
const remove = scene.postRender.addEventListener(() => {
remove();
clearTimeout(timeout);
resolve(read());
});
scene.requestRender();
});
const bodyShow = trail.body?.show,
headShow = trail.head?.show,
headPrimitiveShow = trail.headPrimitive?.show,
backingShow = trail.backing?.show;
let showTrail = false;
const applyVisibility = () => {
if (trail.body) trail.body.show = showTrail && bodyShow;
if (trail.headPrimitive)
trail.headPrimitive.show = showTrail && headPrimitiveShow;
if (trail.head) trail.head.show = showTrail && headShow;
if (trail.backing) trail.backing.show = showTrail && backingShow;
};
// Override the layer's frame update in both states; selection stays fixed.
const removeVisibility = scene.preRender.addEventListener(applyVisibility);
const captures = [];
let captureReady = true;
try {
for (const visible of [false, true, false, true]) {
showTrail = visible;
// Let post-processing settle before each measured framebuffer.
for (let i = 0; i < 3; i++) await frame();
captures.push(await frame());
const current = parts.trails.diagnostics();
captureReady &&=
ready() &&
current?.body === trail.body &&
current?.headPrimitive === trail.headPrimitive;
}
} finally {
removeVisibility();
if (trail.body) trail.body.show = bodyShow;
if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow;
if (trail.head) trail.head.show = headShow;
if (trail.backing) trail.backing.show = backingShow;
await frame();
}
const [off, on, offAgain, onAgain] = captures.map((patches) =>
patches.map((p) => (p ? Array.from(p) : null)),
);
return {
...setup,
fixes: entry.track.count,
selected: state._selectedKey === setup.key,
pathMetres: lengths.at(-1),
samples,
on,
off,
onAgain,
offAgain,
sprite,
tilesReady: captureReady,
elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT,
movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320,
body: trail.body?.constructor.name,
headStrategy: trail.headStrategy || 'PolylineCollection',
headRebuilds: trail.headRebuilds ?? null,
};
}, setup);
const pixels = reduceTrailPixels(result.on, result.off, result);
result.cost = cost;
result.altitude = altitude;
result.pitch = pitch;
result.present = pixels.present;
result.total = pixels.total;
result.samples = result.samples?.map((s, i) => ({
...s,
hit: pixels.hits[i],
stable: pixels.stable[i],
eligible: pixels.eligible[i],
}));
await page.screenshot({
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
type: 'jpeg',
quality: 95,
});
await writeFile(
`${shots}/${tag}-${altitude}m-trail-visible.json`,
JSON.stringify(result, null, 2),
);
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
check(
name,
result.google &&
result.selected &&
result.fixes >= 5 &&
result.pathMetres > 15 &&
result.elapsedDisplayMs >= 19000 &&
result.movedMetres >= 5 &&
pixels.pass,
JSON.stringify(result),
);
check(
'selected trail stays within 2 MiB rebuild and 2 KiB/frame head uploads',
cost.frames > 30 &&
cost.bodyUploadMax > 0 &&
cost.bodyUploadMax <= 2 * 1024 * 1024 &&
cost.headUploadMax > 0 &&
cost.headUploadMax <= 2048,
JSON.stringify({ altitude, ...cost }),
);
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('transit')
.module._transitPartsForTest()
.selection.clearSelection(),
);
}
+21
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
import {
boundPageEvaluations,
reportTransitVisibility,
@@ -16,6 +17,7 @@ import {
runFleetBudgets,
runBostonMatrix,
runBostonLive,
runTrailVisibility,
} from './qa-transit-scenes.mjs';
/**
* Browser proof for the Transit layer against a running app.
@@ -78,6 +80,18 @@ const check = (
failures += 1;
} else if (!passed) failures += 1;
};
// A focused deterministic regression can run independently of live-feed QA.
if (process.argv.includes('--heading-only')) {
try {
await runTransitHeadingRegression(BASE, check);
} catch (error) {
check('heading regression completed', false, error.stack || error.message);
}
console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`);
process.exit(failures ? 1 : 0);
}
if (runs('heading')) await runTransitHeadingRegression(BASE, check);
const renderingSource = await readFile(
new URL('../src/layers/transit/rendering.js', import.meta.url),
'utf8',
@@ -1774,6 +1788,13 @@ try {
selectStyle,
sampleRendered,
};
if (runs('trail-visible'))
for (const [altitude, pitch] of [
[300, 55],
[600, 45],
[900, 45],
])
await runTrailVisibility({ ...sceneQA, altitude, pitch });
if (runs('budgets')) await runFleetBudgets(sceneQA);
if (runs('matrix')) await runBostonMatrix(sceneQA);
if (runs('boston-live')) await runBostonLive(sceneQA);
+34 -5
View File
@@ -2,7 +2,7 @@
/**
* Credentialed AI voice acceptance using a prerecorded Chromium microphone.
*
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk]
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
@@ -11,8 +11,13 @@ import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const appUrl = process.argv[2] || 'http://localhost:4189';
const wavPath = process.argv[3]
const pushToTalk = process.argv.includes('--push-to-talk');
const positional = process.argv.slice(2).filter(arg => arg !== '--push-to-talk');
const targetUrl = new URL(positional[0] || 'http://localhost:4189');
// Keep first-run focus changes outside this voice/keyboard acceptance test.
targetUrl.searchParams.set('welcome', '0');
const appUrl = targetUrl.href;
const wavPath = positional[1]
|| path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav');
const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb';
@@ -60,6 +65,7 @@ try {
await page.waitForFunction(() => (
window.__godsEyeView?.voiceCommands
&& document.getElementById('gev-voice-button')
&& document.getElementById('loading-screen')?.classList.contains('hidden')
), { timeout: 30_000 });
const readState = () => page.evaluate(() => {
@@ -81,7 +87,26 @@ try {
});
const initial = await readState();
await page.evaluate(() => document.getElementById('gev-voice-button').click());
let pushToTalkStarted = false;
let microphoneReleased = false;
if (pushToTalk) {
await page.bringToFront();
await page.focus('.cesium-widget canvas');
await page.keyboard.down('Space');
await page.waitForFunction(() => document.querySelector(
'[data-push-to-talk="held"][data-microphone="active"]',
), { timeout: 30_000 });
pushToTalkStarted = true;
// The hash-pinned fixture is 9.2 seconds long and plays once from mic acquisition.
await new Promise(resolve => setTimeout(resolve, 10_000));
await page.keyboard.up('Space');
microphoneReleased = await page.evaluate(() => {
const status = window.__godsEyeView.voiceCommands.status;
return status === 'idle' || Boolean(document.querySelector('[data-microphone="muted"]'));
});
} else {
await page.click('#gev-voice-button');
}
const timeline = [initial];
let lastSignature = JSON.stringify(initial);
@@ -113,10 +138,14 @@ try {
}
const result = {
ok: finalState.voiceStatus === 'idle'
ok: (!pushToTalk || (pushToTalkStarted && microphoneReleased))
&& finalState.voiceStatus === 'idle'
&& finalState.radioAudioState === 'playing'
&& finalState.radioVoiceDucked === false
&& Number(finalState.cameraHeightM) >= 10_000_000,
mode: pushToTalk ? 'push-to-talk' : 'click',
pushToTalkStarted,
microphoneReleased,
fixture: wavPath,
fixtureSha256,
appUrl,