feat(scenes): render the Scene panel's controls from the store

SceneControls no longer takes element handles or writes the Scene panel's
button states, PLAY label, status line, progress bar or runtime chip: it
presents them to the gev-scenes store, and the panel's buttons, scene
select and IMPORT chooser call its actions there. The chooser opens only
while an owner presents the panel and is cleared once its import settles.
On destroy the panel keeps its last status and buttons, without shots or
the runtime chip, as before.
This commit is contained in:
Sameh Khamis committed 2026-10-31 23:04:31 -07:00
1 parent 0adcd61184
commit ec4c55e78c
10 files changed
+245 -148

No files matched your search

+97 -15
View File
@@ -2,7 +2,6 @@ import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { createStateChannel } from '../app/stateChannel.js';
import { sceneElements } from './scenePresentation.js';
import { setLocale } from '../i18n/index.js';
import { installVueDom } from '../testSupport/vueDom.mjs';
@@ -13,6 +12,7 @@ let SceneControls;
let SceneDirector;
let LayerPanelsView;
let applicationPinia;
let useSceneStore;
let uninstallDom;
before(async () => {
@@ -21,6 +21,7 @@ before(async () => {
({ SceneControls } = await import('./sceneControls.js'));
({ SceneDirector } = await import('../scenes/director.js'));
({ applicationPinia } = await import('./vueRuntime.js'));
({ useSceneStore } = await import('./stores/scenes.js'));
LayerPanelsView = (await import('./views/LayerPanelsView.vue')).default;
});
after(() => uninstallDom());
@@ -33,8 +34,26 @@ function fixture() {
const view = createApp(LayerPanelsView).use(applicationPinia());
view.mount(document.getElementById('layer-panels-host'));
window.prompt = () => 'Renamed';
const elements = sceneElements(document);
elements.shots = document.getElementById('scene-shot-list');
const byId = (id) => document.getElementById(id);
const elements = {
panel: byId('scene-panel'),
select: byId('scene-select'),
new: byId('scene-new-btn'),
delete: byId('scene-delete-btn'),
capture: byId('scene-capture-btn'),
update: byId('scene-update-shot-btn'),
start: byId('scene-start-btn'),
stop: byId('scene-stop-btn'),
next: byId('scene-next-btn'),
export: byId('scene-export-btn'),
import: byId('scene-import-btn'),
file: byId('scene-import-file'),
download: byId('scene-download-btn'),
status: byId('scene-status'),
progress: byId('scene-progress-fill'),
runtime: byId('scene-runtime'),
shots: byId('scene-shot-list'),
};
const state = {
scenes: [
{
@@ -81,7 +100,7 @@ function fixture() {
},
]),
);
const owner = new SceneControls({ read: () => state, actions, elements });
const owner = new SceneControls({ read: () => state, actions });
return {
owner,
state,
@@ -110,27 +129,32 @@ test('Scene controls render the supplied selection and running/download states',
f.elements.shots.children[0].children[1].textContent,
'NORMAL · OFF · 4.0s + 0.9s',
);
await nextTick();
assert.equal(f.elements.stop.disabled, true);
assert.equal(f.elements.download.disabled, true);
f.state.hasRun = true;
f.owner.setButtons(true);
await nextTick();
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);
await nextTick();
assert.equal(f.elements.progress.style.width, '100%');
f.owner.setProgress(-1);
await nextTick();
assert.equal(f.elements.progress.textContent, '0%');
} finally {
f.restore();
}
});
test('Authoring disables idle actions that would do nothing', () => {
test('Authoring disables idle actions that would do nothing', async () => {
const f = fixture();
try {
await nextTick();
assert.equal(f.elements.start.textContent, 'PLAY');
assert.equal(f.elements.start.disabled, false);
assert.equal(f.elements.update.disabled, false);
@@ -138,6 +162,7 @@ test('Authoring disables idle actions that would do nothing', () => {
f.state.selectedShotId = null;
f.owner.setButtons(false);
await nextTick();
assert.equal(f.elements.update.disabled, true);
assert.equal(f.elements.start.disabled, false);
@@ -146,12 +171,14 @@ test('Authoring disables idle actions that would do nothing', () => {
f.state.scenes[0].shots = [];
f.state.scenes.push({ id: 'scene-b', title: 'B', shots: [{ id: 'b1' }] });
f.owner.setButtons(false);
await nextTick();
assert.equal(f.elements.start.disabled, false);
assert.equal(f.elements.next.disabled, false);
assert.equal(f.elements.update.disabled, true);
f.state.scenes.pop();
f.owner.setButtons(false);
await nextTick();
assert.equal(f.elements.start.disabled, true);
assert.equal(f.elements.next.disabled, true);
assert.equal(f.elements.capture.disabled, false);
@@ -160,6 +187,7 @@ test('Authoring disables idle actions that would do nothing', () => {
f.state.scenes = [];
f.state.selectedSceneId = null;
f.owner.setButtons(false);
await nextTick();
assert.equal(f.elements.delete.disabled, true);
assert.equal(f.elements.capture.disabled, true);
assert.equal(f.elements.new.disabled, false);
@@ -197,7 +225,6 @@ test('Scene controls dispatch panel and shot-row actions, and rows outlive repub
const newer = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
});
f.owner.destroy();
await nextTick();
@@ -211,7 +238,7 @@ test('Scene controls dispatch panel and shot-row actions, and rows outlive repub
}
});
test('Scene Escape and recording presentation follow playback and release on destruction', () => {
test('Scene Escape and recording presentation follow playback and release on destruction', async () => {
const f = fixture();
const escape = () =>
f.document.dispatchEvent(
@@ -224,6 +251,9 @@ test('Scene Escape and recording presentation follow playback and release on des
f.owner.setPlaybackActive(true);
f.owner.setPlaybackKeyboardEnabled(true);
f.owner.updateRuntime('Scene A / Shot A');
// The Context view renders the chip from the store.
const panel = () => useSceneStore(applicationPinia()).panel;
assert.equal(panel().runtime, 'Scene A / Shot A');
escape();
assert.deepEqual(f.calls, [['stop', 'Stopped (Esc)']]);
f.owner.destroy();
@@ -237,8 +267,8 @@ test('Scene Escape and recording presentation follow playback and release on des
f.document.body.classList.contains('scene-playback-mode'),
false,
);
assert.equal(f.elements.runtime.textContent, '');
assert.equal(f.owner.removers.length, 0);
assert.equal(panel().runtime, '');
assert.equal(panel().hasShots, false);
} finally {
f.restore();
}
@@ -248,8 +278,10 @@ test('a file import finishing after disposal cannot clear the retained file cont
const f = fixture();
try {
let finish;
f.actions.import = () =>
let imported;
f.actions.import = (file) =>
new Promise((resolve) => {
imported = file;
finish = resolve;
});
// A file input's files and value are read-only to scripts.
@@ -261,9 +293,12 @@ test('a file import finishing after disposal cannot clear the retained file cont
writable: true,
});
f.elements.file.dispatchEvent(new Event('change'));
assert.deepEqual(imported, { name: 'project.json' });
f.owner.destroy();
finish();
await new Promise((resolve) => setImmediate(resolve));
await nextTick();
// The chooser is left as it was once its owner is gone.
assert.equal(f.elements.file.value, 'project.json');
assert.equal(f.elements.status.textContent, 'Ready');
} finally {
@@ -282,24 +317,27 @@ test('Scene action failures are visible only while that action still owns presen
let pending = f.owner.run('load', 'scene-a', 'shot-a');
reject(new Error('unavailable'));
await pending;
await nextTick();
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;
await nextTick();
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;
await nextTick();
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', () => {
test('creation and deletion prompts precede their model actions and remain inert after disposal', async () => {
const f = fixture();
try {
let prompts = 0;
@@ -493,9 +531,9 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
const owner = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
subscribe: (listener) => channel.subscribe(listener),
});
await nextTick();
assert.equal(f.elements.status.textContent, 'Ready to resume');
await nextTick();
const row = f.elements.shots.children[0];
@@ -505,10 +543,12 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
assertSameNode(f.elements.shots.children[0], row);
f.state.status = 'Project exported';
channel.publish({ type: 'project-exported' });
await nextTick();
assert.equal(f.elements.status.textContent, 'Project exported');
owner.destroy();
f.state.status = 'Late';
channel.publish({ type: 'status-changed' });
await nextTick();
assert.equal(f.elements.status.textContent, 'Project exported');
assert.equal(owner.unsubscribe, null);
channel.destroy();
@@ -517,7 +557,7 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
}
});
test('Authoring refreshes idle buttons from published changes', () => {
test('Authoring refreshes idle buttons from published changes', async () => {
const f = fixture();
try {
f.owner.destroy();
@@ -525,19 +565,21 @@ test('Authoring refreshes idle buttons from published changes', () => {
const owner = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
subscribe: (listener) => channel.subscribe(listener),
});
const disabled = (name) => f.elements[name].disabled;
await nextTick();
assert.equal(disabled('start'), false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
f.state.selectedShotId = null;
channel.publish({ type: 'selection-changed' });
await nextTick();
assert.equal(disabled('update'), true);
f.state.scenes[0].shots = [];
channel.publish({ type: 'shot-deleted' });
await nextTick();
assert.equal(disabled('start'), true);
assert.equal(disabled('next'), true);
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
@@ -545,16 +587,19 @@ test('Authoring refreshes idle buttons from published changes', () => {
// A run that ends hands the buttons back with the idle rules applied.
f.state.running = true;
channel.publish({ type: 'buttons-changed' });
await nextTick();
assert.equal(disabled('capture'), true);
assert.equal(disabled('stop'), false);
f.state.running = false;
channel.publish({ type: 'buttons-changed' });
await nextTick();
assert.equal(disabled('capture'), false);
assert.equal(disabled('start'), true);
f.state.scenes = [];
f.state.selectedSceneId = null;
channel.publish({ type: 'scene-deleted' });
await nextTick();
assert.equal(disabled('delete'), true);
assert.equal(disabled('capture'), true);
assert.equal(disabled('new'), false);
@@ -563,10 +608,12 @@ test('Authoring refreshes idle buttons from published changes', () => {
f.state.selectedSceneId = 'scene-b';
f.state.selectedShotId = 's';
channel.publish({ type: 'project-imported' });
await nextTick();
assert.equal(disabled('start'), false);
assert.equal(f.elements.panel.classList.contains('has-shots'), true);
owner.destroy();
await nextTick();
assert.equal(f.elements.panel.classList.contains('has-shots'), false);
channel.destroy();
} finally {
@@ -574,13 +621,48 @@ test('Authoring refreshes idle buttons from published changes', () => {
}
});
test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => {
test('Authoring PLAY uses the scene context, not the radio PLAY', async (context) => {
context.after(() => setLocale('en'));
setLocale('es');
const f = fixture();
try {
await nextTick();
assert.equal(f.elements.start.textContent, 'REPROD.');
} finally {
f.restore();
}
});
test('IMPORT clears its chooser only once the import settles, and is inert without an owner', async () => {
const f = fixture();
try {
let finish;
f.actions.import = () =>
new Promise((resolve) => {
finish = resolve;
});
Object.defineProperty(f.elements.file, 'files', {
value: [{ name: 'project.json' }],
});
Object.defineProperty(f.elements.file, 'value', {
value: 'project.json',
writable: true,
});
f.elements.file.dispatchEvent(new Event('change'));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.elements.file.value, 'project.json', 'still importing');
finish();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.elements.file.value, '');
let opened = 0;
f.elements.file.click = () => opened++;
f.elements.import.click();
assert.equal(opened, 1);
f.owner.destroy();
f.elements.import.click();
assert.equal(opened, 1, 'no chooser once the owner is gone');
} finally {
f.restore();
}
});