diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eac00e93a..a6b088d31 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -246,5 +246,7 @@ "src/ui/styles/responsive.css", "src/ui/styles/voice-cost.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" ] diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 385bfb5bb..606d67b83 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -88,7 +88,9 @@ try { const lookup = window.__godsEyeView.styleManager._locationLookup; window.__qaSearchRequests = []; window.__qaLocationState = []; - window.__godsEyeView.styleManager.subscribeLocationSearch((notification) => window.__qaLocationState.push(notification)); + window.__godsEyeView.styleManager.subscribeLocationSearch((notification) => + window.__qaLocationState.push(notification), + ); lookup.search = (query, options) => new Promise((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(() => { - const seen = window.__qaLocationState; - const started = seen.filter(({ change }) => change?.type === 'started'); - const found = seen.filter(({ change }) => change?.type === 'found'); - 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( + 'Location subscriptions accept only the current result and retain request identities', + await page.evaluate(() => { + const seen = window.__qaLocationState; + const started = seen.filter(({ change }) => change?.type === 'started'); + const found = seen.filter(({ change }) => change?.type === 'found'); + 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( 'cancelled POI expansion stays closed after a frame', await page.evaluate(async (id) => { diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index 26ffc8ac4..f5c18dfed 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -41,7 +41,9 @@ try { ); await page.evaluate(() => { 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('#scene-new-btn'); @@ -307,13 +309,31 @@ try { document.querySelectorAll('#scene-select option').length > 0, ), ); - check('Scene subscriptions include current state and the completed native editing actions', await page.evaluate(() => { - const seen = window.__qaSceneState; - const types = new Set(seen.map(({ change }) => change?.type)); - 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)); - })); + check( + 'Scene subscriptions include current state and the completed native editing actions', + await page.evaluate(() => { + const seen = window.__qaSceneState; + const types = new Set(seen.map(({ change }) => change?.type)); + 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 director = window.__godsEyeView.sceneDirector; const controls = director._controls; @@ -359,7 +379,10 @@ try { 'Late import completion cannot replace the disposed project or status', 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( 'Scene control interaction produces no uncaught browser errors', errors.length === 0, diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs index 7a6a2d486..5414de6d3 100644 --- a/scripts/qa-ui-disposal.mjs +++ b/scripts/qa-ui-disposal.mjs @@ -121,7 +121,9 @@ try { feedbackLabel.querySelector('.gev-flap-text')?.firstChild; let stateNotifications = 0; ui.subscribeShareState(() => stateNotifications++, { emitCurrent: false }); - ui.subscribeLocationSearch(() => stateNotifications++, { emitCurrent: false }); + ui.subscribeLocationSearch(() => stateNotifications++, { + emitCurrent: false, + }); try { const disposal = ui.dispose(); const focusBefore = document.activeElement; @@ -256,7 +258,10 @@ try { 'shell teardown cancels deferred work and releases listeners', 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( 'disposal and subsequent resize produce no uncaught browser errors', diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs index c1cb91b49..db0696e08 100644 --- a/scripts/qa-visual-effects.mjs +++ b/scripts/qa-visual-effects.mjs @@ -153,14 +153,19 @@ try { await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; 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 }); unsubscribe(); const published = states.at(-1); const snapshot = manager.getVisualState(); return ( - states[0].initial && states.length > 1 && Object.isFrozen(published.state.options) && - published.state.bloomEnabled && published.state.options.bloomIntensity === 98 && + states[0].initial && + states.length > 1 && + Object.isFrozen(published.state.options) && + published.state.bloomEnabled && + published.state.options.bloomIntensity === 98 && result.ok && result.bloom.intensityPct === 98 && snapshot.bloom.intensity === 98 && diff --git a/src/app/stateChannel.js b/src/app/stateChannel.js index df1fe38d5..93403b044 100644 --- a/src/app/stateChannel.js +++ b/src/app/stateChannel.js @@ -2,17 +2,27 @@ function copyState(value, ancestors = new Set()) { if (value === null) return value; 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'); } 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'); } const next = new Set(ancestors).add(value); const copy = Array.isArray(value) ? 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); } @@ -42,28 +52,46 @@ export function createStateChannel(readSnapshot) { return Object.freeze({ getSnapshot, 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 () => {}; const state = emitCurrent ? getSnapshot() : null; const entry = { listener, revision }; listeners.add(entry); - if (emitCurrent) deliver(entry, Object.freeze({ - state, change: null, revision, initial: true, - })); + if (emitCurrent) + deliver( + entry, + Object.freeze({ + state, + change: null, + revision, + initial: true, + }), + ); return () => listeners.delete(entry); }, publish(change = null) { if (destroyed) return false; - pending.push(Object.freeze({ - state: getSnapshot(), change: copyState(change), revision: ++revision, initial: false, - })); + pending.push( + Object.freeze({ + state: getSnapshot(), + change: copyState(change), + revision: ++revision, + initial: false, + }), + ); if (delivering) return true; delivering = true; try { while (!destroyed && pending.length) { const notification = pending.shift(); 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; deliver(entry, notification); } diff --git a/src/app/stateChannel.test.mjs b/src/app/stateChannel.test.mjs index d084bd5db..1cb34339c 100644 --- a/src/app/stateChannel.test.mjs +++ b/src/app/stateChannel.test.mjs @@ -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', () => { let count = 0; const channel = createStateChannel(() => ({ count })); - const first = [], second = [], joined = []; + const first = [], + second = [], + joined = []; channel.subscribe(({ state, initial }) => { if (initial) return; 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, initial }) => { if (!initial) second.push(state.count); }); + channel.subscribe(({ state, initial }) => { + if (!initial) second.push(state.count); + }); count = 1; channel.publish(); 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 })); let late = 0; let remove; - channel.subscribe(() => { - remove(); - channel.publish(); - channel.destroy(); - }, { emitCurrent: false }); + channel.subscribe( + () => { + remove(); + channel.publish(); + channel.destroy(); + }, + { emitCurrent: false }, + ); remove = channel.subscribe(() => late++, { emitCurrent: false }); channel.publish(); const stoppedReads = reads; @@ -67,7 +74,9 @@ test('a failed subscriber cannot prevent other controls from receiving an update const original = console.error; console.error = (message) => errors.push(message); try { - channel.subscribe(() => { throw new Error('consumer failure'); }); + channel.subscribe(() => { + throw new Error('consumer failure'); + }); let received = 0; channel.subscribe(() => received++); 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', () => { let live = { action() {} }; const channel = createStateChannel(() => live); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index e841c37a9..9fc915525 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -539,13 +539,25 @@ export class StyleManager { ); }); this._shareState = createStateChannel(() => this._readShareState()); - this._shareState.subscribe(({ state }) => { - this.shareLinkManager.onToggleChange(state.bloomEnabled, state.sharpenEnabled, state.options); - }, { emitCurrent: false }); - this._locationState = createStateChannel(() => this._locationLookup?.getState() || null); - this._locationState.subscribe(({ state, change }) => { - this._handleLocationSearchState(state, change); - }, { emitCurrent: false }); + this._shareState.subscribe( + ({ state }) => { + this.shareLinkManager.onToggleChange( + state.bloomEnabled, + state.sharpenEnabled, + state.options, + ); + }, + { 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 // from deterministic markup defaults instead of recipient-local panel // preferences. Encoded panel fields are applied after all panels exist. @@ -4513,7 +4525,8 @@ export class StyleManager { _handleLocationSearchState(state, change) { 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') { this._searchedLocationLabel = state.destination.label || state.query; this._setActiveLocation(null); @@ -4522,8 +4535,12 @@ export class StyleManager { this._updateLocationMiniStatus(); } else if (change.type === 'missing') this._showToast('Location not found'); else if (change.type === 'failed') this._showToast('Search failed'); - else if (change.type === 'settled') this._settleLocationSearchUi(change.generation); - else if (change.type === 'reset' && this._activeLocationSearchGeneration !== null) { + else if (change.type === 'settled') + this._settleLocationSearchUi(change.generation); + else if ( + change.type === 'reset' && + this._activeLocationSearchGeneration !== null + ) { this._settleLocationSearchUi(this._activeLocationSearchGeneration); } } @@ -4552,9 +4569,11 @@ export class StyleManager { }), onError: (error) => console.error('[Search] Geocoding failed:', error), }); - this._locationLookupUnsubscribe = this._locationLookup.subscribe(({ initial, change }) => { - this._locationState.publish(initial ? { type: 'reset' } : change); - }); + this._locationLookupUnsubscribe = this._locationLookup.subscribe( + ({ initial, change }) => { + this._locationState.publish(initial ? { type: 'reset' } : change); + }, + ); this._locationControls = new LocationControls({ elements: { pills: this._locationPills, diff --git a/src/ui/locationSearch.js b/src/ui/locationSearch.js index 6361d3731..09669ca56 100644 --- a/src/ui/locationSearch.js +++ b/src/ui/locationSearch.js @@ -2,16 +2,50 @@ import { createStateChannel } from '../app/stateChannel.js'; /** One cancellable place lookup at a time, under the caller's camera authority. */ export class LocationSearch { - constructor({ input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }) { - Object.assign(this, { input, begin, isCurrent, beforeFly, search, onStart, onResult, onMissing, onError, onSettled }); + constructor({ + 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.generation = 0; 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); } - getState() { return this.channel.getSnapshot(); } - subscribe(listener, options) { return this.channel.subscribe(listener, options); } + getState() { + return this.channel.getSnapshot(); + } + subscribe(listener, options) { + return this.channel.subscribe(listener, options); + } async run(query) { query = String(query || '').trim(); if (!query || this.destroyed) return; @@ -25,9 +59,25 @@ export class LocationSearch { const controller = new AbortController(); this.controller = controller; const generation = ++this.generation; - const current = () => !this.destroyed && 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 }; + const current = () => + !this.destroyed && + 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 { this.onStart?.(authority); if (!current() || controller.signal.aborted) return; @@ -43,7 +93,11 @@ export class LocationSearch { if (destination) { this.onResult?.(destination, query); 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')); } else { this.onMissing?.(); @@ -55,15 +109,24 @@ export class LocationSearch { if (controller.signal.aborted || !current()) return; this.onError?.(error); 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')); } finally { if (this.controller === controller) this.controller = null; if (!this.destroyed) { - if (generation === this.generation) this.state = { - ...this.state, searching: false, - status: this.state.status === 'searching' ? 'cancelled' : this.state.status, - }; + if (generation === this.generation) + this.state = { + ...this.state, + searching: false, + status: + this.state.status === 'searching' + ? 'cancelled' + : this.state.status, + }; this.onSettled?.(authority); this.channel.publish(change('settled')); } diff --git a/src/ui/locationSearch.test.mjs b/src/ui/locationSearch.test.mjs index 0a460a248..72ad62a60 100644 --- a/src/ui/locationSearch.test.mjs +++ b/src/ui/locationSearch.test.mjs @@ -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')); }); - test('search snapshots retain the current busy owner when an older request settles', async () => { const f = fixture(); 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'); assert.equal(found.length, 1); 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().searching, false); const count = events.length; diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 0abdfb5c3..751f8e0d4 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -20,8 +20,14 @@ export class SceneControls { this.rowRemovers = []; this.playbackKeyRemover = null; if (!elements.select) return; - if (subscribe) this.unsubscribe = subscribe((notification) => this.present(notification)); - else { this.renderSceneSelect(); this.renderShotList(); } + if (subscribe) + this.unsubscribe = subscribe((notification) => + this.present(notification), + ); + else { + this.renderSceneSelect(); + this.renderShotList(); + } this.listen(elements.select, 'change', () => this.run('selectScene', elements.select.value), ); @@ -58,18 +64,47 @@ export class SceneControls { present({ state, change, initial }) { if (this.destroyed) return; const type = change?.type; - if (initial || ['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 || + [ + '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 === '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 === 'runtime-changed') this.updateRuntime(state.runtime); - if (initial || type === 'playback-presentation') this.setPlaybackActive(state.playbackActive); - if (initial || type === 'playback-keyboard') this.setPlaybackKeyboardEnabled(state.keyboardEnabled); + if (initial || type === 'runtime-changed') + this.updateRuntime(state.runtime); + 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 === 'shot-loaded') this.updateStatus(`Loaded: ${change.sceneTitle} / ${change.shot.title}`); - if (type === 'run-event' && change.event === 'shot_start') this.setButtons(state.running); + if (type === 'shot-loaded') + 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) { diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index 3a78daae0..fd208b940 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -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', () => { const f = fixture(); try { 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 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'); const row = f.elements.shots.children[0]; 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(owner.unsubscribe, null); channel.destroy(); - } finally { f.restore(); } + } finally { + f.restore(); + } });