mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Move the page handoff sequence into SceneSwitcher and include restoration in the application's candidate attachment. Release a failed candidate once before the request coordinator recovers the working engine. Keep address ordering, parked renderers, retained layers and cockpit resumption intact. Define the layer session and its field owners. Share its retention decision across teardown paths. Separate layer scene binding from lifecycle policy, and shell scene composition from page controls. Keep failed renderer releases in the binding owner, with the existing retry behavior. Give Director the scene and use the engine host for its canvas and pack container. Keep the existing tracked-subject interaction guard so neither engine's behavior changes. Pass startup chrome the first backend's explicit welcome policy instead of testing for a viewer. Cesium remains the behavior reference; this restructuring intentionally changes no visible behavior on either engine. Viewer references outside the Cesium engine fall from 948 to 933. All other coupling counts stay unchanged. Scene service ownership, raw debug access, and the remaining shell and host queues still need consolidation. Checked in the browser, keyless, on the dev server: the engine switch, Director engine switch, layer panel and layer visibility on both engines pass, and 14 of 15 strict scenarios pass (rapid, hidden-tab, context-loss, parked-release, Navara HD, failure matrix and soak among them). app-once and the Director next-shot step fail the same way before this change.
372 lines
12 KiB
JavaScript
372 lines
12 KiB
JavaScript
#!/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(
|
|
// A view in the link: the app starts there instead of flying to a city
|
|
// after the shot has placed the camera.
|
|
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0#v=2&lat=30.2672&lon=-97.742&alt=4000&heading=0&pitch=-90&l=`,
|
|
{ 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: 'sentinel2' },
|
|
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.sceneDirector.engine.surface.tilesLoaded(),
|
|
{ timeout: 60000 },
|
|
);
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
const point = await page.evaluate(async () => {
|
|
const d = window.__godsEyeView.sceneDirector;
|
|
const { cartesian3 } = await import('/src/geo/index.js');
|
|
const pos = cartesian3.fromDegrees(-97.742, 30.2672, 600);
|
|
const screen = d.engine.projector.worldToWindow(pos);
|
|
const rect = d.engine.host.canvas.getBoundingClientRect();
|
|
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
|
});
|
|
await page.mouse.click(point.x, point.y);
|
|
// The click's pick answers asynchronously; the focus follows it.
|
|
await page
|
|
.waitForFunction(
|
|
() => document.activeElement?.dataset.directorAction === 'card',
|
|
{ timeout: 10000 },
|
|
)
|
|
.catch(() => {});
|
|
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.sceneDirector.engine.surface.tilesLoaded(),
|
|
})),
|
|
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.engine.camera.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;
|