From d42036d74fccba2ca80b99e0957f8328ac403ced Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 03:30:05 -0700 Subject: [PATCH] style: format Scene controls and browser acceptance --- scripts/format-scope.json | 7 +- scripts/qa-scene-controls.mjs | 328 ++++++++++++++++++++++++++++------ src/ui/sceneControls.js | 58 ++++-- src/ui/sceneControls.test.mjs | 163 ++++++++++++++--- src/ui/scenePresentation.js | 45 ++++- 5 files changed, 498 insertions(+), 103 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 29b3d5138..e375bee6d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -208,5 +208,10 @@ "src/ui/cockpitTrackingController.js", "src/cockpitMath.js", "src/cockpitUtilityLayout.js", - "src/cockpitVisionPolicy.js" + "src/cockpitVisionPolicy.js", + "src/ui/scenes.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/sceneControls.test.mjs", + "scripts/qa-scene-controls.mjs" ] diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index af124f04e..f6606de27 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -6,123 +6,335 @@ import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/scene-controls'); fs.mkdirSync(shots, { recursive: true }); -const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] }); +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); const page = await browser.newPage(); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); let promptValue = 'QA Scene'; -page.on('dialog', async (dialog) => { await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); }); +page.on('dialog', async (dialog) => { + await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); +}); let failures = 0; -function check(name, ok) { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; } +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) 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?._controls && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.sceneDirector?._controls && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); await page.click('[data-collapse-target="scene-panel"]'); await page.click('#scene-new-btn'); - await page.waitForFunction(() => document.querySelector('#scene-select option:checked')?.textContent === 'QA Scene'); - check('New creates and selects a persisted empty scene', await page.evaluate(() => { - const director = window.__godsEyeView.sceneDirector; - const saved = JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')); - return director._getSelectedScene().shots.length === 0 && !!document.querySelector('.scene-shot-empty') && saved.scenes.some((scene) => scene.title === 'QA Scene'); - })); + await page.waitForFunction( + () => + document.querySelector('#scene-select option:checked')?.textContent === + 'QA Scene', + ); + check( + 'New creates and selects a persisted empty scene', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const saved = JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ); + return ( + director._getSelectedScene().shots.length === 0 && + !!document.querySelector('.scene-shot-empty') && + saved.scenes.some((scene) => scene.title === 'QA Scene') + ); + }), + ); await page.click('#scene-capture-btn'); - await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 1); - check('Capture stores the actual camera and visual state', await page.evaluate(() => { - const director = window.__godsEyeView.sceneDirector; - const shot = director._getSelectedScene().shots[0]; - const current = director.styleManager.getCameraState(); - return Number.isFinite(shot.camera.lat) && Math.abs(shot.camera.lat - current.lat) < 0.01 && shot.visual.style === director.styleManager.getVisualState().style; - })); + await page.waitForFunction( + () => document.querySelectorAll('.scene-shot-row').length === 1, + ); + check( + 'Capture stores the actual camera and visual state', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const shot = director._getSelectedScene().shots[0]; + const current = director.styleManager.getCameraState(); + return ( + Number.isFinite(shot.camera.lat) && + Math.abs(shot.camera.lat - current.lat) < 0.01 && + shot.visual.style === director.styleManager.getVisualState().style + ); + }), + ); promptValue = 'QA shot'; await page.click('.scene-shot-label', { clickCount: 2 }); - await page.waitForFunction(() => document.querySelector('.scene-shot-label')?.textContent === 'QA shot'); - check('Rename persists literal text without interpreting markup', await page.evaluate(() => !document.querySelector('.scene-shot-label b') && JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((scene) => scene.shots.some((shot) => shot.title === 'QA shot')))); + await page.waitForFunction( + () => + document.querySelector('.scene-shot-label')?.textContent === + 'QA shot', + ); + check( + 'Rename persists literal text without interpreting markup', + await page.evaluate( + () => + !document.querySelector('.scene-shot-label b') && + JSON.parse( + localStorage.getItem('godsEyeView.sceneProject.v2'), + ).scenes.some((scene) => + scene.shots.some((shot) => shot.title === 'QA shot'), + ), + ), + ); await page.click('#scene-capture-btn'); - await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 2); + await page.waitForFunction( + () => document.querySelectorAll('.scene-shot-row').length === 2, + ); await page.click('.scene-shot-label'); - check('Shot selection and Update use the selected shot', await page.evaluate(() => { - const director = window.__godsEyeView.sceneDirector; - const scene = director._getSelectedScene(); - const count = scene.shots.length; - document.getElementById('scene-update-shot-btn').click(); - return director._selectedShotId === scene.shots[0].id && scene.shots.length === count && document.querySelector('.scene-shot-row.active .scene-shot-label').textContent === scene.shots[0].title; - })); + check( + 'Shot selection and Update use the selected shot', + await page.evaluate(() => { + const director = window.__godsEyeView.sceneDirector; + const scene = director._getSelectedScene(); + const count = scene.shots.length; + document.getElementById('scene-update-shot-btn').click(); + return ( + director._selectedShotId === scene.shots[0].id && + scene.shots.length === count && + document.querySelector('.scene-shot-row.active .scene-shot-label') + .textContent === scene.shots[0].title + ); + }), + ); await page.evaluate(() => { window.__sceneExports = []; const create = URL.createObjectURL.bind(URL); URL.createObjectURL = (blob) => { - if (blob.type === 'application/json') window.__sceneExports.push(blob.text()); + if (blob.type === 'application/json') + window.__sceneExports.push(blob.text()); return create(blob); }; }); await page.click('#scene-export-btn'); - const exported = await page.evaluate(async () => JSON.parse(await window.__sceneExports[0])); - check('Export produces the current project JSON', exported.scenes.some((scene) => scene.title === 'QA Scene' && scene.shots.length === 2)); + const exported = await page.evaluate(async () => + JSON.parse(await window.__sceneExports[0]), + ); + check( + 'Export produces the current project JSON', + exported.scenes.some( + (scene) => scene.title === 'QA Scene' && scene.shots.length === 2, + ), + ); // Keep real camera/style playback while isolating this control check from // live provider availability. Layer transactions are covered by their suites. const scene = exported.scenes.find((item) => item.title === 'QA Scene'); - scene.shots = scene.shots.slice(0, 1).map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); + scene.shots = scene.shots + .slice(0, 1) + .map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); const fixture = { ...exported, scenes: [scene] }; const projectFile = path.join(shots, 'project.json'); fs.writeFileSync(projectFile, JSON.stringify(fixture)); const input = await page.$('#scene-import-file'); await input.uploadFile(projectFile); - await page.waitForFunction(() => document.getElementById('scene-status').textContent === 'Imported project.json'); - check('Import replaces the project and clears the file input', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && document.querySelectorAll('.scene-shot-row').length === 1 && document.getElementById('scene-import-file').value === '')); + await page.waitForFunction( + () => + document.getElementById('scene-status').textContent === + 'Imported project.json', + ); + check( + 'Import replaces the project and clears the file input', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 1 && + document.querySelectorAll('.scene-shot-row').length === 1 && + document.getElementById('scene-import-file').value === '', + ), + ); await page.click('.scene-shot-btn'); - await page.waitForFunction(() => document.getElementById('scene-status').textContent.startsWith('Loaded:'), { timeout: 30000 }); - check('LOAD completes the real visual and camera operation', await page.evaluate(() => !window.__godsEyeView.sceneDirector.running && document.getElementById('scene-status').textContent.includes('QA shot'))); + await page.waitForFunction( + () => + document.getElementById('scene-status').textContent.startsWith('Loaded:'), + { timeout: 30000 }, + ); + check( + 'LOAD completes the real visual and camera operation', + await page.evaluate( + () => + !window.__godsEyeView.sceneDirector.running && + document + .getElementById('scene-status') + .textContent.includes('QA shot'), + ), + ); await page.click('#scene-start-btn'); - await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running && document.body.classList.contains('scene-playback-mode')); - check('Playback enters recording presentation and disables editing', await page.evaluate(() => document.getElementById('scene-capture-btn').disabled && document.getElementById('scene-start-btn').disabled && !document.getElementById('scene-stop-btn').disabled && window.__godsEyeView.styleManager.getViewState().recording)); + await page.waitForFunction( + () => + window.__godsEyeView.sceneDirector.running && + document.body.classList.contains('scene-playback-mode'), + ); + check( + 'Playback enters recording presentation and disables editing', + await page.evaluate( + () => + document.getElementById('scene-capture-btn').disabled && + document.getElementById('scene-start-btn').disabled && + !document.getElementById('scene-stop-btn').disabled && + window.__godsEyeView.styleManager.getViewState().recording, + ), + ); await page.keyboard.press('Escape'); - await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running && !document.body.classList.contains('scene-playback-mode')); - check('Escape ends playback and restores idle/recording controls', await page.evaluate(() => !document.getElementById('scene-start-btn').disabled && document.getElementById('scene-stop-btn').disabled && !document.getElementById('scene-download-btn').disabled && !window.__godsEyeView.styleManager.getViewState().recording)); + await page.waitForFunction( + () => + !window.__godsEyeView.sceneDirector.running && + !document.body.classList.contains('scene-playback-mode'), + ); + check( + 'Escape ends playback and restores idle/recording controls', + await page.evaluate( + () => + !document.getElementById('scene-start-btn').disabled && + document.getElementById('scene-stop-btn').disabled && + !document.getElementById('scene-download-btn').disabled && + !window.__godsEyeView.styleManager.getViewState().recording, + ), + ); await page.click('#scene-download-btn'); - const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1))); - check('Run download contains the completed cancelled run', Boolean(metadata.endedAt && metadata.wasCancelled && Array.isArray(metadata.events))); + const metadata = await page.evaluate(async () => + JSON.parse(await window.__sceneExports.at(-1)), + ); + check( + 'Run download contains the completed cancelled run', + Boolean( + metadata.endedAt && + metadata.wasCancelled && + Array.isArray(metadata.events), + ), + ); await page.click('#scene-start-btn'); await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); await page.$eval('#scene-stop-btn', (button) => button.click()); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); - check('The installed Stop control also completes playback cleanup', await page.evaluate(() => !document.body.classList.contains('scene-playback-mode') && !document.getElementById('scene-start-btn').disabled)); + check( + 'The installed Stop control also completes playback cleanup', + await page.evaluate( + () => + !document.body.classList.contains('scene-playback-mode') && + !document.getElementById('scene-start-btn').disabled, + ), + ); const badFile = path.join(shots, 'invalid.json'); fs.writeFileSync(badFile, '{invalid'); await input.uploadFile(badFile); - await page.waitForFunction(() => document.getElementById('scene-status').textContent.includes('invalid JSON')); - check('Invalid import reports failure and preserves the current project', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && window.__godsEyeView.sceneDirector._getSelectedScene().title === 'QA Scene')); + await page.waitForFunction(() => + document + .getElementById('scene-status') + .textContent.includes('invalid JSON'), + ); + check( + 'Invalid import reports failure and preserves the current project', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length === 1 && + window.__godsEyeView.sceneDirector._getSelectedScene().title === + 'QA Scene', + ), + ); 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') }); - check('Scene controls stay inside the narrow viewport', await page.$eval('#scene-select', (element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; })); + check( + 'Scene controls stay inside the narrow viewport', + await page.$eval('#scene-select', (element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; + }), + ); await page.setViewport({ width: 1440, height: 900 }); await page.click('.scene-shot-danger'); - await page.waitForFunction(() => !!document.querySelector('.scene-shot-empty')); - check('Delete shot leaves the empty-state presentation', await page.evaluate(() => window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === 0)); + await page.waitForFunction( + () => !!document.querySelector('.scene-shot-empty'), + ); + check( + 'Delete shot leaves the empty-state presentation', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === + 0, + ), + ); await page.click('#scene-delete-btn'); - await page.waitForFunction(() => !window.__godsEyeView.sceneDirector._project.scenes.some((item) => item.title === 'QA Scene')); - check('Deleting the final scene restores the built-in recipes', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length > 0 && document.querySelectorAll('#scene-select option').length > 0)); + await page.waitForFunction( + () => + !window.__godsEyeView.sceneDirector._project.scenes.some( + (item) => item.title === 'QA Scene', + ), + ); + check( + 'Deleting the final scene restores the built-in recipes', + await page.evaluate( + () => + window.__godsEyeView.sceneDirector._project.scenes.length > 0 && + document.querySelectorAll('#scene-select option').length > 0, + ), + ); const teardown = await page.evaluate(async () => { const director = window.__godsEyeView.sceneDirector; const controls = director._controls; const previousStatus = document.getElementById('scene-status').textContent; const project = director._project; let finish; - const gate = new Promise((resolve) => { finish = resolve; }); - const pending = controls.run('import', { name: 'late.json', text: async () => { await gate; return '{"scenes":[]}'; } }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const pending = controls.run('import', { + name: 'late.json', + text: async () => { + await gate; + return '{"scenes":[]}'; + }, + }); const stopping = director.destroy(); - const stoppedSynchronously = controls.destroyed && controls.removers.length === 0 && controls.rowRemovers.length === 0; + const stoppedSynchronously = + controls.destroyed && + controls.removers.length === 0 && + controls.rowRemovers.length === 0; document.getElementById('scene-new-btn').click(); controls.run('capture'); finish(); await pending; await stopping; await director.destroy(); - return { stoppedSynchronously, sameProject: director._project === project, noLateStatus: document.getElementById('scene-status').textContent === previousStatus, rowsGone: !document.querySelector('.scene-shot-row') }; + return { + stoppedSynchronously, + sameProject: director._project === project, + noLateStatus: + document.getElementById('scene-status').textContent === previousStatus, + rowsGone: !document.querySelector('.scene-shot-row'), + }; }); - check('Disposal revokes fixed and row actions before waiting for scene work', teardown.stoppedSynchronously && teardown.rowsGone); - check('Late import completion cannot replace the disposed project or status', teardown.sameProject && teardown.noLateStatus); - check('Scene control interaction produces no uncaught browser errors', errors.length === 0); -} finally { await browser.close(); } + check( + 'Disposal revokes fixed and row actions before waiting for scene work', + teardown.stoppedSynchronously && teardown.rowsGone, + ); + check( + 'Late import completion cannot replace the disposed project or status', + teardown.sameProject && teardown.noLateStatus, + ); + check( + 'Scene control interaction produces no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} if (failures) process.exitCode = 1; diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 26e032258..0ce6d61dd 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -1,5 +1,12 @@ /** Own Scene panel listeners and presentation through project reads and actions. */ -import { sceneElements, renderSceneOptions, renderSceneShots, presentSceneButtons, presentSceneProgress, presentSceneRuntime } from './scenePresentation.js'; +import { + sceneElements, + renderSceneOptions, + renderSceneShots, + presentSceneButtons, + presentSceneProgress, + presentSceneRuntime, +} from './scenePresentation.js'; export class SceneControls { constructor({ read, actions, elements = sceneElements() }) { @@ -14,11 +21,23 @@ export class SceneControls { if (!elements.select) return; this.renderSceneSelect(); this.renderShotList(); - this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value)); - for (const [element, action] of Object.entries({ new: 'create', delete: 'deleteScene', capture: 'capture', update: 'update', next: 'next', export: 'export', download: 'download' })) { + this.listen(elements.select, 'change', () => + this.run('selectScene', elements.select.value), + ); + for (const [element, action] of Object.entries({ + new: 'create', + delete: 'deleteScene', + capture: 'capture', + update: 'update', + next: 'next', + export: 'export', + download: 'download', + })) { this.listen(elements[element], 'click', () => this.run(action)); } - this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId)); + this.listen(elements.start, 'click', () => + this.run('start', this.read().selectedSceneId), + ); this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped')); this.listen(elements.import, 'click', () => elements.file?.click()); this.listen(elements.file, 'change', async () => { @@ -34,7 +53,9 @@ export class SceneControls { listen(target, type, callback, removers = this.removers) { if (!target) return; - const listener = (event) => { if (!this.destroyed) return callback(event); }; + const listener = (event) => { + if (!this.destroyed) return callback(event); + }; target.addEventListener(type, listener); removers.push(() => target.removeEventListener(type, listener)); } @@ -43,7 +64,8 @@ export class SceneControls { if (this.destroyed) return; const generation = ++this.actionGeneration; const failed = () => { - if (!this.destroyed && generation === this.actionGeneration) this.updateStatus('Scene action failed'); + if (!this.destroyed && generation === this.actionGeneration) + this.updateStatus('Scene action failed'); }; try { const result = this.actions[action](...args); @@ -61,16 +83,19 @@ export class SceneControls { if (this.destroyed) return; for (const remove of this.rowRemovers.splice(0)) remove(); renderSceneShots(this.elements.shots, this.read(), { - listen: (element, type, callback) => this.listen(element, type, callback, this.rowRemovers), + listen: (element, type, callback) => + this.listen(element, type, callback, this.rowRemovers), select: (id) => this.run('selectShot', id), - rename: (sceneId, shotId, title) => this.run('renameShot', sceneId, shotId, title), + rename: (sceneId, shotId, title) => + this.run('renameShot', sceneId, shotId, title), load: (sceneId, shotId) => this.run('load', sceneId, shotId), remove: (sceneId, shotId) => this.run('deleteShot', sceneId, shotId), }); } setButtons(running) { - if (!this.destroyed) presentSceneButtons(this.elements, running, this.read().hasRun); + if (!this.destroyed) + presentSceneButtons(this.elements, running, this.read().hasRun); } setProgress(progress) { @@ -78,7 +103,8 @@ export class SceneControls { } updateStatus(text) { - if (!this.destroyed && this.elements.status) this.elements.status.textContent = text; + if (!this.destroyed && this.elements.status) + this.elements.status.textContent = text; } updateRuntime(text) { @@ -96,10 +122,12 @@ export class SceneControls { this.playbackKeyRemover = null; } else if (!this.destroyed && !this.playbackKeyRemover) { const onKeyDown = (event) => { - if (!this.destroyed && event.key === 'Escape' && this.read().running) this.run('stop', 'Stopped (Esc)'); + if (!this.destroyed && event.key === 'Escape' && this.read().running) + this.run('stop', 'Stopped (Esc)'); }; document.addEventListener('keydown', onKeyDown); - this.playbackKeyRemover = () => document.removeEventListener('keydown', onKeyDown); + this.playbackKeyRemover = () => + document.removeEventListener('keydown', onKeyDown); } } @@ -108,7 +136,11 @@ export class SceneControls { this.destroyed = true; this.actionGeneration++; this.setPlaybackKeyboardEnabled(false); - for (const remove of [...this.removers.splice(0), ...this.rowRemovers.splice(0)]) remove(); + for (const remove of [ + ...this.removers.splice(0), + ...this.rowRemovers.splice(0), + ]) + remove(); if (this.elements.shots) this.elements.shots.textContent = ''; presentSceneRuntime(this.elements.runtime, ''); this.setPlaybackActive(false); diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index 512ffb8a6..34b450f6d 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -12,28 +12,116 @@ function fixture() { this._text = ''; const classes = new Set(); this.classList = { - toggle(name, active) { if (active) classes.add(name); else classes.delete(name); }, + toggle(name, active) { + if (active) classes.add(name); + else classes.delete(name); + }, contains: (name) => classes.has(name), }; } - appendChild(element) { this.children.push(element); } - set textContent(value) { this._text = value; this.children = []; } - get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); } - click() { this.dispatchEvent(new Event('click')); } + appendChild(element) { + this.children.push(element); + } + set textContent(value) { + this._text = value; + this.children = []; + } + get textContent() { + return ( + this._text + this.children.map((node) => node.textContent).join('') + ); + } + click() { + this.dispatchEvent(new Event('click')); + } } const saved = { document: globalThis.document, window: globalThis.window }; - const document = Object.assign(new EventTarget(), { createElement: () => new Element(), body: new Element() }); + const document = Object.assign(new EventTarget(), { + createElement: () => new Element(), + body: new Element(), + }); globalThis.document = document; globalThis.window = { prompt: () => 'Renamed' }; - const elements = Object.fromEntries(['panel','select','new','delete','capture','update','shots','start','stop','next','export','import','file','download','status','progress','runtime'].map((name) => [name, new Element()])); + const elements = Object.fromEntries( + [ + 'panel', + 'select', + 'new', + 'delete', + 'capture', + 'update', + 'shots', + 'start', + 'stop', + 'next', + 'export', + 'import', + 'file', + 'download', + 'status', + 'progress', + 'runtime', + ].map((name) => [name, new Element()]), + ); const state = { - scenes: [{ id: 'scene-a', title: '', shots: [{ id: 'shot-a', title: '', durationSec: 4, holdSec: 0.9, visual: { style: 'normal' } }] }], - selectedSceneId: 'scene-a', selectedShotId: 'shot-a', running: false, hasRun: false, + scenes: [ + { + id: 'scene-a', + title: '', + shots: [ + { + id: 'shot-a', + title: '', + durationSec: 4, + holdSec: 0.9, + visual: { style: 'normal' }, + }, + ], + }, + ], + selectedSceneId: 'scene-a', + selectedShotId: 'shot-a', + running: false, + hasRun: false, }; const calls = []; - const actions = Object.fromEntries(['selectScene','selectShot','renameShot','create','deleteScene','capture','update','start','stop','next','export','import','download','load','deleteShot'].map((name) => [name, (...args) => { calls.push([name, ...args]); }])); + const actions = Object.fromEntries( + [ + 'selectScene', + 'selectShot', + 'renameShot', + 'create', + 'deleteScene', + 'capture', + 'update', + 'start', + 'stop', + 'next', + 'export', + 'import', + 'download', + 'load', + 'deleteShot', + ].map((name) => [ + name, + (...args) => { + calls.push([name, ...args]); + }, + ]), + ); const owner = new SceneControls({ read: () => state, actions, elements }); - return { owner, state, actions, elements, calls, document, restore() { owner.destroy(); Object.assign(globalThis, saved); } }; + return { + owner, + state, + actions, + elements, + calls, + document, + restore() { + owner.destroy(); + Object.assign(globalThis, saved); + }, + }; } test('Scene controls render the supplied selection and running/download states', () => { @@ -41,7 +129,10 @@ test('Scene controls render the supplied selection and running/download states', try { assert.equal(f.elements.select.children[0].textContent, ''); assert.equal(f.elements.select.value, 'scene-a'); - assert.equal(f.elements.shots.children[0].children[0].children[0].textContent, ''); + assert.equal( + f.elements.shots.children[0].children[0].children[0].textContent, + '', + ); assert.equal(f.elements.stop.disabled, true); assert.equal(f.elements.download.disabled, true); f.state.hasRun = true; @@ -55,7 +146,9 @@ test('Scene controls render the supplied selection and running/download states', assert.equal(f.elements.progress.style.width, '100%'); f.owner.setProgress(-1); assert.equal(f.elements.progress.textContent, '0%'); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => { @@ -69,19 +162,30 @@ test('Scene controls dispatch explicit actions and revoke replaced shot-row list label.click(); label.dispatchEvent(new Event('dblclick')); load.click(); - assert.deepEqual(f.calls, [['capture'], ['start','scene-a'], ['selectShot','shot-a'], ['renameShot','scene-a','shot-a','Renamed'], ['load','scene-a','shot-a']]); + assert.deepEqual(f.calls, [ + ['capture'], + ['start', 'scene-a'], + ['selectShot', 'shot-a'], + ['renameShot', 'scene-a', 'shot-a', 'Renamed'], + ['load', 'scene-a', 'shot-a'], + ]); f.owner.renderShotList(); label.click(); load.click(); assert.equal(f.calls.length, 5); f.elements.shots.children[0].children[0].children[1].children[0].click(); assert.equal(f.calls.length, 6); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('Scene Escape and recording presentation follow playback and release on destruction', () => { const f = fixture(); - const escape = () => f.document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + const escape = () => + f.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); try { escape(); assert.deepEqual(f.calls, []); @@ -98,18 +202,26 @@ test('Scene Escape and recording presentation follow playback and release on des f.elements.capture.click(); f.owner.run('capture'); assert.equal(f.calls.length, 1); - assert.equal(f.document.body.classList.contains('scene-playback-mode'), false); + assert.equal( + f.document.body.classList.contains('scene-playback-mode'), + false, + ); assert.equal(f.elements.runtime.textContent, ''); assert.equal(f.owner.removers.length, 0); assert.equal(f.owner.rowRemovers.length, 0); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('a file import finishing after disposal cannot clear the retained file control', async () => { const f = fixture(); try { let finish; - f.actions.import = () => new Promise((resolve) => { finish = resolve; }); + f.actions.import = () => + new Promise((resolve) => { + finish = resolve; + }); f.elements.file.files = [{ name: 'project.json' }]; f.elements.file.value = 'project.json'; f.elements.file.dispatchEvent(new Event('change')); @@ -118,14 +230,19 @@ test('a file import finishing after disposal cannot clear the retained file cont await new Promise((resolve) => setImmediate(resolve)); assert.equal(f.elements.file.value, 'project.json'); assert.equal(f.elements.status.textContent, 'Ready'); - } finally { f.restore(); } + } finally { + f.restore(); + } }); test('Scene action failures are visible only while that action still owns presentation', async () => { const f = fixture(); try { let reject; - f.actions.load = () => new Promise((_resolve, fail) => { reject = fail; }); + f.actions.load = () => + new Promise((_resolve, fail) => { + reject = fail; + }); let pending = f.owner.run('load', 'scene-a', 'shot-a'); reject(new Error('unavailable')); await pending; @@ -141,5 +258,7 @@ test('Scene action failures are visible only while that action still owns presen reject(new Error('disposed')); await pending; assert.equal(f.elements.status.textContent, 'Current selection'); - } finally { f.restore(); } + } finally { + f.restore(); + } }); diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index a4a449a08..d0c0530be 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -1,14 +1,27 @@ /** Scene panel elements and text-only project presentation. */ export function sceneElements(root = document) { const ids = { - panel: 'scene-panel', select: 'scene-select', new: 'scene-new-btn', - delete: 'scene-delete-btn', capture: 'scene-capture-btn', update: 'scene-update-shot-btn', - shots: 'scene-shot-list', start: 'scene-start-btn', stop: 'scene-stop-btn', - next: 'scene-next-btn', export: 'scene-export-btn', import: 'scene-import-btn', - file: 'scene-import-file', download: 'scene-download-btn', status: 'scene-status', - progress: 'scene-progress-fill', runtime: 'scene-runtime', + panel: 'scene-panel', + select: 'scene-select', + new: 'scene-new-btn', + delete: 'scene-delete-btn', + capture: 'scene-capture-btn', + update: 'scene-update-shot-btn', + shots: 'scene-shot-list', + start: 'scene-start-btn', + stop: 'scene-stop-btn', + next: 'scene-next-btn', + export: 'scene-export-btn', + import: 'scene-import-btn', + file: 'scene-import-file', + download: 'scene-download-btn', + status: 'scene-status', + progress: 'scene-progress-fill', + runtime: 'scene-runtime', }; - return Object.fromEntries(Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)])); + return Object.fromEntries( + Object.entries(ids).map(([name, id]) => [name, root.getElementById(id)]), + ); } export function renderSceneOptions(element, { scenes, selectedSceneId }) { @@ -23,7 +36,11 @@ export function renderSceneOptions(element, { scenes, selectedSceneId }) { if (selectedSceneId) element.value = selectedSceneId; } -export function renderSceneShots(element, state, { listen, select, rename, load, remove }) { +export function renderSceneShots( + element, + state, + { listen, select, rename, load, remove }, +) { if (!element) return; const scene = state.scenes.find((item) => item.id === state.selectedSceneId); element.textContent = ''; @@ -74,7 +91,17 @@ export function renderSceneShots(element, state, { listen, select, rename, load, } export function presentSceneButtons(elements, running, hasRun) { - for (const name of ['start', 'next', 'select', 'new', 'delete', 'capture', 'update', 'export', 'import']) { + for (const name of [ + 'start', + 'next', + 'select', + 'new', + 'delete', + 'capture', + 'update', + 'export', + 'import', + ]) { if (elements[name]) elements[name].disabled = running; } if (elements.stop) elements.stop.disabled = !running;