import test from 'node:test'; import assert from 'node:assert/strict'; import { SceneControls } from './sceneControls.js'; import { SceneDirector } from '../scenes/director.js'; function fixture() { class Element extends EventTarget { constructor() { super(); this.children = []; this.style = {}; this.dataset = {}; this.value = ''; this._text = ''; const classes = new Set(); this.classList = { 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')); } } const saved = { document: globalThis.document, window: globalThis.window }; 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 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, }; 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 owner = new SceneControls({ read: () => state, actions, elements }); 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', () => { const f = fixture(); 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.stop.disabled, true); assert.equal(f.elements.download.disabled, true); f.state.hasRun = true; f.owner.setButtons(true); assert.equal(f.elements.start.disabled, true); assert.equal(f.elements.capture.disabled, true); assert.equal(f.elements.stop.disabled, false); assert.equal(f.elements.download.disabled, false); assert.equal(f.elements.panel.classList.contains('running'), true); f.owner.setProgress(2); assert.equal(f.elements.progress.style.width, '100%'); f.owner.setProgress(-1); assert.equal(f.elements.progress.textContent, '0%'); } finally { f.restore(); } }); test('Scene controls dispatch explicit actions and revoke replaced shot-row listeners', () => { const f = fixture(); try { const row = f.elements.shots.children[0]; const label = row.children[0].children[0]; const load = row.children[0].children[1].children[0]; f.elements.capture.click(); f.elements.start.click(); 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'], ]); 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(); } }); 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' }), ); try { escape(); assert.deepEqual(f.calls, []); f.state.running = true; f.owner.setPlaybackActive(true); f.owner.setPlaybackKeyboardEnabled(true); f.owner.updateRuntime('Scene A / Shot A'); escape(); assert.deepEqual(f.calls, [['stop', 'Stopped (Esc)']]); f.owner.destroy(); f.owner.destroy(); f.owner.setPlaybackKeyboardEnabled(true); escape(); 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.elements.runtime.textContent, ''); assert.equal(f.owner.removers.length, 0); assert.equal(f.owner.rowRemovers.length, 0); } 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.elements.file.files = [{ name: 'project.json' }]; f.elements.file.value = 'project.json'; f.elements.file.dispatchEvent(new Event('change')); f.owner.destroy(); finish(); await new Promise((resolve) => setImmediate(resolve)); assert.equal(f.elements.file.value, 'project.json'); assert.equal(f.elements.status.textContent, 'Ready'); } 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; }); let pending = f.owner.run('load', 'scene-a', 'shot-a'); reject(new Error('unavailable')); await pending; assert.equal(f.elements.status.textContent, 'Scene action failed'); pending = f.owner.run('load', 'scene-a', 'shot-a'); f.owner.run('selectShot', 'shot-a'); f.owner.updateStatus('Current selection'); reject(new Error('obsolete')); await pending; assert.equal(f.elements.status.textContent, 'Current selection'); pending = f.owner.run('load', 'scene-a', 'shot-a'); f.owner.destroy(); reject(new Error('disposed')); await pending; assert.equal(f.elements.status.textContent, 'Current selection'); } finally { f.restore(); } }); test('creation and deletion prompts precede their model actions and remain inert after disposal', () => { const f = fixture(); try { let prompts = 0; window.prompt = () => { prompts++; return null; }; window.confirm = () => { prompts++; return false; }; f.elements.new.click(); f.elements.delete.click(); f.owner.deleteShot('scene-a', 'shot-a'); assert.deepEqual(f.calls, []); window.prompt = () => { prompts++; return 'New title'; }; window.confirm = () => { prompts++; return true; }; f.elements.new.click(); f.elements.delete.click(); f.owner.deleteShot('scene-a', 'shot-a'); assert.deepEqual(f.calls, [ ['create', 'New title'], ['deleteScene'], ['deleteShot', 'scene-a', 'shot-a'], ]); f.owner.destroy(); f.owner.createScene(); f.owner.deleteSelectedScene(); f.owner.deleteShot('scene-a', 'shot-a'); assert.equal(prompts, 6); } finally { f.restore(); } }); test('the real director preserves a selected shot label for the following double-click', async () => { const f = fixture(); const previousStorage = globalThis.localStorage; let director; try { f.owner.destroy(); const ids = { 'scene-panel': 'panel', 'scene-select': 'select', 'scene-new-btn': 'new', 'scene-delete-btn': 'delete', 'scene-capture-btn': 'capture', 'scene-update-shot-btn': 'update', 'scene-shot-list': 'shots', 'scene-start-btn': 'start', 'scene-stop-btn': 'stop', 'scene-next-btn': 'next', 'scene-export-btn': 'export', 'scene-import-btn': 'import', 'scene-import-file': 'file', 'scene-download-btn': 'download', 'scene-status': 'status', 'scene-progress-fill': 'progress', 'scene-runtime': 'runtime', }; f.document.getElementById = (id) => f.elements[ids[id]] || null; globalThis.localStorage = { getItem: () => JSON.stringify({ version: 3, scenes: f.state.scenes }), setItem() {}, }; director = new SceneDirector({ camera: { cancelFlight() {} } }, {}, {}); const label = f.elements.shots.children[0].children[0].children[0]; label.click(); assert.equal(f.elements.shots.children[0].children[0].children[0], label); label.dispatchEvent(new Event('dblclick')); assert.equal(director._getSelectedScene().shots[0].title, 'Renamed'); } finally { await director?.destroy(); globalThis.localStorage = previousStorage; f.restore(); } });