mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
352 lines
9.6 KiB
JavaScript
352 lines
9.6 KiB
JavaScript
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: '<Scene A>',
|
|
shots: [
|
|
{
|
|
id: 'shot-a',
|
|
title: '<Shot A>',
|
|
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, '<Scene A>');
|
|
assert.equal(f.elements.select.value, 'scene-a');
|
|
assert.equal(
|
|
f.elements.shots.children[0].children[0].children[0].textContent,
|
|
'<Shot A>',
|
|
);
|
|
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();
|
|
}
|
|
});
|