mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
createSceneDialog keeps its builder but publishes the dialog's lines, fields, buttons and status to a scene sharing store; SceneDialog.vue, teleported to the body from the scene panels view, draws the same modal with the same styles and opens it with Cancel focused. The EDIT DETAILS / SHARE SCENE bar is drawn by that view while sharing offers it. The status directive is shared with Provider Settings, and the two QA scripts that drove the old DOM read the new dialog.
486 lines
15 KiB
JavaScript
486 lines
15 KiB
JavaScript
#!/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);
|
|
// Typing fires input; the dialog reads its fields from those events.
|
|
for (const field of fields)
|
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
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;
|