style: format state subscriptions and their adopted controls

This commit is contained in:
Sameh Khamis committed 2026-09-13 05:43:05 -07:00
1 parent 45d97376d0
commit e0652b19f2
12 files changed
+299 -88

No files matched your search

+3 -1
View File
@@ -246,5 +246,7 @@
"src/ui/styles/responsive.css", "src/ui/styles/responsive.css",
"src/ui/styles/voice-cost.css", "src/ui/styles/voice-cost.css",
"src/ui/styles/cockpit.css", "src/ui/styles/cockpit.css",
"src/ui/styles/command-dock-compact.css" "src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs"
] ]
+19 -9
View File
@@ -88,7 +88,9 @@ try {
const lookup = window.__godsEyeView.styleManager._locationLookup; const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = []; window.__qaSearchRequests = [];
window.__qaLocationState = []; window.__qaLocationState = [];
window.__godsEyeView.styleManager.subscribeLocationSearch((notification) => window.__qaLocationState.push(notification)); window.__godsEyeView.styleManager.subscribeLocationSearch((notification) =>
window.__qaLocationState.push(notification),
);
lookup.search = (query, options) => lookup.search = (query, options) =>
new Promise((resolve) => new Promise((resolve) =>
window.__qaSearchRequests.push({ query, options, resolve }), window.__qaSearchRequests.push({ query, options, resolve }),
@@ -141,14 +143,22 @@ try {
); );
}), }),
); );
check('Location subscriptions accept only the current result and retain request identities', await page.evaluate(() => { check(
const seen = window.__qaLocationState; 'Location subscriptions accept only the current result and retain request identities',
const started = seen.filter(({ change }) => change?.type === 'started'); await page.evaluate(() => {
const found = seen.filter(({ change }) => change?.type === 'found'); const seen = window.__qaLocationState;
return seen[0].initial && started.length === 2 && found.length === 1 && const started = seen.filter(({ change }) => change?.type === 'started');
found[0].change.requestId === started[1].change.requestId && const found = seen.filter(({ change }) => change?.type === 'found');
found[0].state.destination.label === 'Second landmark, Test city' && Object.isFrozen(found[0].state.destination); return (
})); seen[0].initial &&
started.length === 2 &&
found.length === 1 &&
found[0].change.requestId === started[1].change.requestId &&
found[0].state.destination.label === 'Second landmark, Test city' &&
Object.isFrozen(found[0].state.destination)
);
}),
);
check( check(
'cancelled POI expansion stays closed after a frame', 'cancelled POI expansion stays closed after a frame',
await page.evaluate(async (id) => { await page.evaluate(async (id) => {
+32 -9
View File
@@ -41,7 +41,9 @@ try {
); );
await page.evaluate(() => { await page.evaluate(() => {
window.__qaSceneState = []; window.__qaSceneState = [];
window.__godsEyeView.sceneDirector.subscribe((notification) => window.__qaSceneState.push(notification)); window.__godsEyeView.sceneDirector.subscribe((notification) =>
window.__qaSceneState.push(notification),
);
}); });
await page.click('[data-collapse-target="scene-panel"]'); await page.click('[data-collapse-target="scene-panel"]');
await page.click('#scene-new-btn'); await page.click('#scene-new-btn');
@@ -307,13 +309,31 @@ try {
document.querySelectorAll('#scene-select option').length > 0, document.querySelectorAll('#scene-select option').length > 0,
), ),
); );
check('Scene subscriptions include current state and the completed native editing actions', await page.evaluate(() => { check(
const seen = window.__qaSceneState; 'Scene subscriptions include current state and the completed native editing actions',
const types = new Set(seen.map(({ change }) => change?.type)); await page.evaluate(() => {
return seen[0].initial && Object.isFrozen(seen[0].state) && const seen = window.__qaSceneState;
['scene-created', 'shot-captured', 'shot-renamed', 'shot-updated', 'project-exported', 'project-imported', 'shot-deleted', 'scene-deleted', 'run-event'].every((type) => types.has(type)) && const types = new Set(seen.map(({ change }) => change?.type));
seen.filter(({ change }) => change?.shot).every(({ change }) => Object.isFrozen(change.shot)); return (
})); seen[0].initial &&
Object.isFrozen(seen[0].state) &&
[
'scene-created',
'shot-captured',
'shot-renamed',
'shot-updated',
'project-exported',
'project-imported',
'shot-deleted',
'scene-deleted',
'run-event',
].every((type) => types.has(type)) &&
seen
.filter(({ change }) => change?.shot)
.every(({ change }) => Object.isFrozen(change.shot))
);
}),
);
const teardown = await page.evaluate(async () => { const teardown = await page.evaluate(async () => {
const director = window.__godsEyeView.sceneDirector; const director = window.__godsEyeView.sceneDirector;
const controls = director._controls; const controls = director._controls;
@@ -359,7 +379,10 @@ try {
'Late import completion cannot replace the disposed project or status', 'Late import completion cannot replace the disposed project or status',
teardown.sameProject && teardown.noLateStatus, teardown.sameProject && teardown.noLateStatus,
); );
check('Scene subscriptions stop before late imports settle', teardown.stateStopped); check(
'Scene subscriptions stop before late imports settle',
teardown.stateStopped,
);
check( check(
'Scene control interaction produces no uncaught browser errors', 'Scene control interaction produces no uncaught browser errors',
errors.length === 0, errors.length === 0,
+7 -2
View File
@@ -121,7 +121,9 @@ try {
feedbackLabel.querySelector('.gev-flap-text')?.firstChild; feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0; let stateNotifications = 0;
ui.subscribeShareState(() => stateNotifications++, { emitCurrent: false }); ui.subscribeShareState(() => stateNotifications++, { emitCurrent: false });
ui.subscribeLocationSearch(() => stateNotifications++, { emitCurrent: false }); ui.subscribeLocationSearch(() => stateNotifications++, {
emitCurrent: false,
});
try { try {
const disposal = ui.dispose(); const disposal = ui.dispose();
const focusBefore = document.activeElement; const focusBefore = document.activeElement;
@@ -256,7 +258,10 @@ try {
'shell teardown cancels deferred work and releases listeners', 'shell teardown cancels deferred work and releases listeners',
result.shellWorkReleased, result.shellWorkReleased,
); );
check('state subscriptions stop synchronously with UI disposal', result.stateStopped); check(
'state subscriptions stop synchronously with UI disposal',
result.stateStopped,
);
check('repeated UI disposal is inert', result.idempotent); check('repeated UI disposal is inert', result.idempotent);
check( check(
'disposal and subsequent resize produce no uncaught browser errors', 'disposal and subsequent resize produce no uncaught browser errors',
+8 -3
View File
@@ -153,14 +153,19 @@ try {
await page.evaluate(() => { await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
const states = []; const states = [];
const unsubscribe = manager.subscribeShareState((value) => states.push(value)); const unsubscribe = manager.subscribeShareState((value) =>
states.push(value),
);
const result = manager.setBloom({ enabled: true, intensityPct: 98 }); const result = manager.setBloom({ enabled: true, intensityPct: 98 });
unsubscribe(); unsubscribe();
const published = states.at(-1); const published = states.at(-1);
const snapshot = manager.getVisualState(); const snapshot = manager.getVisualState();
return ( return (
states[0].initial && states.length > 1 && Object.isFrozen(published.state.options) && states[0].initial &&
published.state.bloomEnabled && published.state.options.bloomIntensity === 98 && states.length > 1 &&
Object.isFrozen(published.state.options) &&
published.state.bloomEnabled &&
published.state.options.bloomIntensity === 98 &&
result.ok && result.ok &&
result.bloom.intensityPct === 98 && result.bloom.intensityPct === 98 &&
snapshot.bloom.intensity === 98 && snapshot.bloom.intensity === 98 &&
+39 -11
View File
@@ -2,17 +2,27 @@
function copyState(value, ancestors = new Set()) { function copyState(value, ancestors = new Set()) {
if (value === null) return value; if (value === null) return value;
if (typeof value !== 'object') { if (typeof value !== 'object') {
if (['string', 'number', 'boolean', 'undefined'].includes(typeof value)) return value; if (['string', 'number', 'boolean', 'undefined'].includes(typeof value))
return value;
throw new TypeError('Product state must contain plain data'); throw new TypeError('Product state must contain plain data');
} }
if (ancestors.has(value)) throw new TypeError('Circular product state'); if (ancestors.has(value)) throw new TypeError('Circular product state');
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { if (
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null
) {
throw new TypeError('Product state must contain plain records'); throw new TypeError('Product state must contain plain records');
} }
const next = new Set(ancestors).add(value); const next = new Set(ancestors).add(value);
const copy = Array.isArray(value) const copy = Array.isArray(value)
? value.map((item) => copyState(item, next)) ? value.map((item) => copyState(item, next))
: Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyState(item, next)])); : Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
copyState(item, next),
]),
);
return Object.freeze(copy); return Object.freeze(copy);
} }
@@ -42,28 +52,46 @@ export function createStateChannel(readSnapshot) {
return Object.freeze({ return Object.freeze({
getSnapshot, getSnapshot,
subscribe(listener, { emitCurrent = true } = {}) { subscribe(listener, { emitCurrent = true } = {}) {
if (typeof listener !== 'function') throw new TypeError('Expected a state listener'); if (typeof listener !== 'function')
throw new TypeError('Expected a state listener');
if (destroyed) return () => {}; if (destroyed) return () => {};
const state = emitCurrent ? getSnapshot() : null; const state = emitCurrent ? getSnapshot() : null;
const entry = { listener, revision }; const entry = { listener, revision };
listeners.add(entry); listeners.add(entry);
if (emitCurrent) deliver(entry, Object.freeze({ if (emitCurrent)
state, change: null, revision, initial: true, deliver(
})); entry,
Object.freeze({
state,
change: null,
revision,
initial: true,
}),
);
return () => listeners.delete(entry); return () => listeners.delete(entry);
}, },
publish(change = null) { publish(change = null) {
if (destroyed) return false; if (destroyed) return false;
pending.push(Object.freeze({ pending.push(
state: getSnapshot(), change: copyState(change), revision: ++revision, initial: false, Object.freeze({
})); state: getSnapshot(),
change: copyState(change),
revision: ++revision,
initial: false,
}),
);
if (delivering) return true; if (delivering) return true;
delivering = true; delivering = true;
try { try {
while (!destroyed && pending.length) { while (!destroyed && pending.length) {
const notification = pending.shift(); const notification = pending.shift();
for (const entry of [...listeners]) { for (const entry of [...listeners]) {
if (destroyed || !listeners.has(entry) || entry.revision >= notification.revision) continue; if (
destroyed ||
!listeners.has(entry) ||
entry.revision >= notification.revision
)
continue;
entry.revision = notification.revision; entry.revision = notification.revision;
deliver(entry, notification); deliver(entry, notification);
} }
+17 -9
View File
@@ -22,7 +22,9 @@ test('snapshots and action data are immutable copies of live product state', ()
test('reentrant updates retain order and a new subscriber never regresses behind its initial snapshot', () => { test('reentrant updates retain order and a new subscriber never regresses behind its initial snapshot', () => {
let count = 0; let count = 0;
const channel = createStateChannel(() => ({ count })); const channel = createStateChannel(() => ({ count }));
const first = [], second = [], joined = []; const first = [],
second = [],
joined = [];
channel.subscribe(({ state, initial }) => { channel.subscribe(({ state, initial }) => {
if (initial) return; if (initial) return;
first.push(state.count); first.push(state.count);
@@ -32,7 +34,9 @@ test('reentrant updates retain order and a new subscriber never regresses behind
channel.subscribe(({ state }) => joined.push(state.count)); channel.subscribe(({ state }) => joined.push(state.count));
} }
}); });
channel.subscribe(({ state, initial }) => { if (!initial) second.push(state.count); }); channel.subscribe(({ state, initial }) => {
if (!initial) second.push(state.count);
});
count = 1; count = 1;
channel.publish(); channel.publish();
assert.deepEqual(first, [1, 2]); assert.deepEqual(first, [1, 2]);
@@ -45,11 +49,14 @@ test('unsubscribe and destroy during delivery revoke pending callbacks and futur
const channel = createStateChannel(() => ({ reads: ++reads })); const channel = createStateChannel(() => ({ reads: ++reads }));
let late = 0; let late = 0;
let remove; let remove;
channel.subscribe(() => { channel.subscribe(
remove(); () => {
channel.publish(); remove();
channel.destroy(); channel.publish();
}, { emitCurrent: false }); channel.destroy();
},
{ emitCurrent: false },
);
remove = channel.subscribe(() => late++, { emitCurrent: false }); remove = channel.subscribe(() => late++, { emitCurrent: false });
channel.publish(); channel.publish();
const stoppedReads = reads; const stoppedReads = reads;
@@ -67,7 +74,9 @@ test('a failed subscriber cannot prevent other controls from receiving an update
const original = console.error; const original = console.error;
console.error = (message) => errors.push(message); console.error = (message) => errors.push(message);
try { try {
channel.subscribe(() => { throw new Error('consumer failure'); }); channel.subscribe(() => {
throw new Error('consumer failure');
});
let received = 0; let received = 0;
channel.subscribe(() => received++); channel.subscribe(() => received++);
channel.publish(); channel.publish();
@@ -79,7 +88,6 @@ test('a failed subscriber cannot prevent other controls from receiving an update
} }
}); });
test('an invalid initial snapshot cannot retain a subscriber without a cleanup handle', () => { test('an invalid initial snapshot cannot retain a subscriber without a cleanup handle', () => {
let live = { action() {} }; let live = { action() {} };
const channel = createStateChannel(() => live); const channel = createStateChannel(() => live);
+32 -13
View File
@@ -539,13 +539,25 @@ export class StyleManager {
); );
}); });
this._shareState = createStateChannel(() => this._readShareState()); this._shareState = createStateChannel(() => this._readShareState());
this._shareState.subscribe(({ state }) => { this._shareState.subscribe(
this.shareLinkManager.onToggleChange(state.bloomEnabled, state.sharpenEnabled, state.options); ({ state }) => {
}, { emitCurrent: false }); this.shareLinkManager.onToggleChange(
this._locationState = createStateChannel(() => this._locationLookup?.getState() || null); state.bloomEnabled,
this._locationState.subscribe(({ state, change }) => { state.sharpenEnabled,
this._handleLocationSearchState(state, change); state.options,
}, { emitCurrent: false }); );
},
{ emitCurrent: false },
);
this._locationState = createStateChannel(
() => this._locationLookup?.getState() || null,
);
this._locationState.subscribe(
({ state, change }) => {
this._handleLocationSearchState(state, change);
},
{ emitCurrent: false },
);
// Parse before panel chrome initializes so every valid share URL starts // Parse before panel chrome initializes so every valid share URL starts
// from deterministic markup defaults instead of recipient-local panel // from deterministic markup defaults instead of recipient-local panel
// preferences. Encoded panel fields are applied after all panels exist. // preferences. Encoded panel fields are applied after all panels exist.
@@ -4513,7 +4525,8 @@ export class StyleManager {
_handleLocationSearchState(state, change) { _handleLocationSearchState(state, change) {
if (this._disposed || !change) return; if (this._disposed || !change) return;
if (change.type === 'started') this._activeLocationSearchGeneration = change.generation; if (change.type === 'started')
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') { else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query; this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null); this._setActiveLocation(null);
@@ -4522,8 +4535,12 @@ export class StyleManager {
this._updateLocationMiniStatus(); this._updateLocationMiniStatus();
} else if (change.type === 'missing') this._showToast('Location not found'); } else if (change.type === 'missing') this._showToast('Location not found');
else if (change.type === 'failed') this._showToast('Search failed'); else if (change.type === 'failed') this._showToast('Search failed');
else if (change.type === 'settled') this._settleLocationSearchUi(change.generation); else if (change.type === 'settled')
else if (change.type === 'reset' && this._activeLocationSearchGeneration !== null) { this._settleLocationSearchUi(change.generation);
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
) {
this._settleLocationSearchUi(this._activeLocationSearchGeneration); this._settleLocationSearchUi(this._activeLocationSearchGeneration);
} }
} }
@@ -4552,9 +4569,11 @@ export class StyleManager {
}), }),
onError: (error) => console.error('[Search] Geocoding failed:', error), onError: (error) => console.error('[Search] Geocoding failed:', error),
}); });
this._locationLookupUnsubscribe = this._locationLookup.subscribe(({ initial, change }) => { this._locationLookupUnsubscribe = this._locationLookup.subscribe(
this._locationState.publish(initial ? { type: 'reset' } : change); ({ initial, change }) => {
}); this._locationState.publish(initial ? { type: 'reset' } : change);
},
);
this._locationControls = new LocationControls({ this._locationControls = new LocationControls({
elements: { elements: {
pills: this._locationPills, pills: this._locationPills,
+77 -14
View File
@@ -2,16 +2,50 @@ import { createStateChannel } from '../app/stateChannel.js';
/** One cancellable place lookup at a time, under the caller's camera authority. */ /** One cancellable place lookup at a time, under the caller's camera authority. */
export class LocationSearch { export class LocationSearch {
constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) { constructor({
Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }); input,
begin,
isCurrent,
beforeFly,
search,
onStart,
onResult,
onMissing,
onError,
onSettled,
}) {
Object.assign(this, {
input,
begin,
isCurrent,
beforeFly,
search,
onStart,
onResult,
onMissing,
onError,
onSettled,
});
this.controller = null; this.controller = null;
this.generation = 0; this.generation = 0;
this.destroyed = false; this.destroyed = false;
this.state = { status: 'idle', searching: false, query: '', generation: null, requestId: 0, destination: null, error: null }; this.state = {
status: 'idle',
searching: false,
query: '',
generation: null,
requestId: 0,
destination: null,
error: null,
};
this.channel = createStateChannel(() => this.state); this.channel = createStateChannel(() => this.state);
} }
getState() { return this.channel.getSnapshot(); } getState() {
subscribe(listener, options) { return this.channel.subscribe(listener, options); } return this.channel.getSnapshot();
}
subscribe(listener, options) {
return this.channel.subscribe(listener, options);
}
async run(query) { async run(query) {
query = String(query || '').trim(); query = String(query || '').trim();
if (!query || this.destroyed) return; if (!query || this.destroyed) return;
@@ -25,9 +59,25 @@ export class LocationSearch {
const controller = new AbortController(); const controller = new AbortController();
this.controller = controller; this.controller = controller;
const generation = ++this.generation; const generation = ++this.generation;
const current = () => !this.destroyed && generation === this.generation && this.isCurrent(authority); const current = () =>
const change = (type) => ({ type, generation: authority, requestId: generation, query }); !this.destroyed &&
this.state = { status: 'searching', searching: true, query, generation: authority, requestId: generation, destination: null, error: null }; generation === this.generation &&
this.isCurrent(authority);
const change = (type) => ({
type,
generation: authority,
requestId: generation,
query,
});
this.state = {
status: 'searching',
searching: true,
query,
generation: authority,
requestId: generation,
destination: null,
error: null,
};
try { try {
this.onStart?.(authority); this.onStart?.(authority);
if (!current() || controller.signal.aborted) return; if (!current() || controller.signal.aborted) return;
@@ -43,7 +93,11 @@ export class LocationSearch {
if (destination) { if (destination) {
this.onResult?.(destination, query); this.onResult?.(destination, query);
if (!current() || controller.signal.aborted) return; if (!current() || controller.signal.aborted) return;
this.state = { ...this.state, status: 'found', destination: { ...destination } }; this.state = {
...this.state,
status: 'found',
destination: { ...destination },
};
this.channel.publish(change('found')); this.channel.publish(change('found'));
} else { } else {
this.onMissing?.(); this.onMissing?.();
@@ -55,15 +109,24 @@ export class LocationSearch {
if (controller.signal.aborted || !current()) return; if (controller.signal.aborted || !current()) return;
this.onError?.(error); this.onError?.(error);
if (!current() || controller.signal.aborted) return; if (!current() || controller.signal.aborted) return;
this.state = { ...this.state, status: 'failed', error: { message: String(error?.message || error) } }; this.state = {
...this.state,
status: 'failed',
error: { message: String(error?.message || error) },
};
this.channel.publish(change('failed')); this.channel.publish(change('failed'));
} finally { } finally {
if (this.controller === controller) this.controller = null; if (this.controller === controller) this.controller = null;
if (!this.destroyed) { if (!this.destroyed) {
if (generation === this.generation) this.state = { if (generation === this.generation)
...this.state, searching: false, this.state = {
status: this.state.status === 'searching' ? 'cancelled' : this.state.status, ...this.state,
}; searching: false,
status:
this.state.status === 'searching'
? 'cancelled'
: this.state.status,
};
this.onSettled?.(authority); this.onSettled?.(authority);
this.channel.publish(change('settled')); this.channel.publish(change('settled'));
} }
+3 -2
View File
@@ -109,7 +109,6 @@ test('empty queries and refused navigation do not issue a lookup', async () => {
assert.ok(f.calls.some((x) => x[0] === 'blur')); assert.ok(f.calls.some((x) => x[0] === 'blur'));
}); });
test('search snapshots retain the current busy owner when an older request settles', async () => { test('search snapshots retain the current busy owner when an older request settles', async () => {
const f = fixture(); const f = fixture();
const events = []; const events = [];
@@ -128,7 +127,9 @@ test('search snapshots retain the current busy owner when an older request settl
const found = events.filter((event) => event.change?.type === 'found'); const found = events.filter((event) => event.change?.type === 'found');
assert.equal(found.length, 1); assert.equal(found.length, 1);
assert.equal(found[0].state.query, 'Second'); assert.equal(found[0].state.query, 'Second');
assert.throws(() => { found[0].state.destination.label = 'Changed'; }, TypeError); assert.throws(() => {
found[0].state.destination.label = 'Changed';
}, TypeError);
assert.equal(f.search.getState().destination.label, 'Second'); assert.equal(f.search.getState().destination.label, 'Second');
assert.equal(f.search.getState().searching, false); assert.equal(f.search.getState().searching, false);
const count = events.length; const count = events.length;
+46 -11
View File
@@ -20,8 +20,14 @@ export class SceneControls {
this.rowRemovers = []; this.rowRemovers = [];
this.playbackKeyRemover = null; this.playbackKeyRemover = null;
if (!elements.select) return; if (!elements.select) return;
if (subscribe) this.unsubscribe = subscribe((notification) => this.present(notification)); if (subscribe)
else { this.renderSceneSelect(); this.renderShotList(); } this.unsubscribe = subscribe((notification) =>
this.present(notification),
);
else {
this.renderSceneSelect();
this.renderShotList();
}
this.listen(elements.select, 'change', () => this.listen(elements.select, 'change', () =>
this.run('selectScene', elements.select.value), this.run('selectScene', elements.select.value),
); );
@@ -58,18 +64,47 @@ export class SceneControls {
present({ state, change, initial }) { present({ state, change, initial }) {
if (this.destroyed) return; if (this.destroyed) return;
const type = change?.type; const type = change?.type;
if (initial || ['scene-options-changed', 'scene-created', 'scene-deleted', 'project-imported'].includes(type)) this.renderSceneSelect(); if (
if (initial || ['shots-changed', 'scene-created', 'scene-deleted', 'shot-captured', 'shot-updated', 'shot-renamed', 'shot-deleted', 'project-imported'].includes(type)) this.renderShotList(); initial ||
if (type === 'selection-changed') presentSceneSelection(this.elements.shots, state.selectedShotId); [
'scene-options-changed',
'scene-created',
'scene-deleted',
'project-imported',
].includes(type)
)
this.renderSceneSelect();
if (
initial ||
[
'shots-changed',
'scene-created',
'scene-deleted',
'shot-captured',
'shot-updated',
'shot-renamed',
'shot-deleted',
'project-imported',
].includes(type)
)
this.renderShotList();
if (type === 'selection-changed')
presentSceneSelection(this.elements.shots, state.selectedShotId);
if (initial || type === 'buttons-changed') this.setButtons(state.running); if (initial || type === 'buttons-changed') this.setButtons(state.running);
if (initial || type === 'progress-changed') this.setProgress(state.progress); if (initial || type === 'progress-changed')
this.setProgress(state.progress);
if (initial || type === 'status-changed') this.updateStatus(state.status); if (initial || type === 'status-changed') this.updateStatus(state.status);
if (initial || type === 'runtime-changed') this.updateRuntime(state.runtime); if (initial || type === 'runtime-changed')
if (initial || type === 'playback-presentation') this.setPlaybackActive(state.playbackActive); this.updateRuntime(state.runtime);
if (initial || type === 'playback-keyboard') this.setPlaybackKeyboardEnabled(state.keyboardEnabled); if (initial || type === 'playback-presentation')
this.setPlaybackActive(state.playbackActive);
if (initial || type === 'playback-keyboard')
this.setPlaybackKeyboardEnabled(state.keyboardEnabled);
if (type === 'project-exported') this.updateStatus('Project exported'); if (type === 'project-exported') this.updateStatus('Project exported');
if (type === 'shot-loaded') this.updateStatus(`Loaded: ${change.sceneTitle} / ${change.shot.title}`); if (type === 'shot-loaded')
if (type === 'run-event' && change.event === 'shot_start') this.setButtons(state.running); this.updateStatus(`Loaded: ${change.sceneTitle} / ${change.shot.title}`);
if (type === 'run-event' && change.event === 'shot_start')
this.setButtons(state.running);
} }
listen(target, type, callback, removers = this.removers) { listen(target, type, callback, removers = this.removers) {
+16 -4
View File
@@ -351,14 +351,24 @@ test('the real director preserves a selected shot label for the following double
} }
}); });
test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => { test('Scene controls consume current state, preserve rows on progress, and unsubscribe on destruction', () => {
const f = fixture(); const f = fixture();
try { try {
f.owner.destroy(); f.owner.destroy();
Object.assign(f.state, { status: 'Ready to resume', progress: 0.25, runtime: '', playbackActive: false, keyboardEnabled: false }); Object.assign(f.state, {
status: 'Ready to resume',
progress: 0.25,
runtime: '',
playbackActive: false,
keyboardEnabled: false,
});
const channel = createStateChannel(() => f.state); const channel = createStateChannel(() => f.state);
const owner = new SceneControls({ read: () => f.state, actions: f.actions, elements: f.elements, subscribe: (listener) => channel.subscribe(listener) }); const owner = new SceneControls({
read: () => f.state,
actions: f.actions,
elements: f.elements,
subscribe: (listener) => channel.subscribe(listener),
});
assert.equal(f.elements.status.textContent, 'Ready to resume'); assert.equal(f.elements.status.textContent, 'Ready to resume');
const row = f.elements.shots.children[0]; const row = f.elements.shots.children[0];
f.state.progress = 0.5; f.state.progress = 0.5;
@@ -372,5 +382,7 @@ test('Scene controls consume current state, preserve rows on progress, and unsub
assert.equal(f.elements.status.textContent, 'Project exported'); assert.equal(f.elements.status.textContent, 'Project exported');
assert.equal(owner.unsubscribe, null); assert.equal(owner.unsubscribe, null);
channel.destroy(); channel.destroy();
} finally { f.restore(); } } finally {
f.restore();
}
}); });