mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into fix/i18n-layer-errors-in-data
This commit is contained in:
8 files changed
+226
-7
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
- Director clock listeners, interaction observers and scene release failures
|
||||||
|
no longer leave the director inconsistent: each scene is released once, and
|
||||||
|
a phase error is kept (with the release failure logged) when cleanup also
|
||||||
|
fails (deepeshbind, #1014).
|
||||||
@@ -1144,6 +1144,10 @@ Shot selection updates the highlight without replacing the row, preserving
|
|||||||
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
||||||
late file and failed-action completions cannot update removed presentation.
|
late file and failed-action completions cannot update removed presentation.
|
||||||
|
|
||||||
|
Director clock listeners, interaction observers and scene release failures no
|
||||||
|
longer leave the director inconsistent, and a phase error is kept when cleanup
|
||||||
|
also fails.
|
||||||
|
|
||||||
## Cockpit component ownership
|
## Cockpit component ownership
|
||||||
|
|
||||||
Cockpit presentation is separated from its camera/controller behavior. Existing
|
Cockpit presentation is separated from its camera/controller behavior. Existing
|
||||||
|
|||||||
@@ -124,7 +124,13 @@ class PlaybackClock {
|
|||||||
subscribe(listener) {
|
subscribe(listener) {
|
||||||
if (this._destroyed || typeof listener !== 'function') return () => {};
|
if (this._destroyed || typeof listener !== 'function') return () => {};
|
||||||
this._sceneClockListeners.add(listener);
|
this._sceneClockListeners.add(listener);
|
||||||
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
|
if (this._sceneClockSnapshot) {
|
||||||
|
try {
|
||||||
|
listener({ ...this._sceneClockSnapshot });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Scenes] Scene clock listener failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
return () => this._sceneClockListeners.delete(listener);
|
return () => this._sceneClockListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a
|
|||||||
});
|
});
|
||||||
assert.equal(f.timers.size, 0);
|
assert.equal(f.timers.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.clock.publish(scene, shot, 1);
|
||||||
|
let calls = 0;
|
||||||
|
const warnings = [];
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = (...args) => warnings.push(args);
|
||||||
|
try {
|
||||||
|
const unsubscribe = f.clock.subscribe(() => {
|
||||||
|
calls++;
|
||||||
|
throw new Error('listener failure');
|
||||||
|
});
|
||||||
|
assert.equal(typeof unsubscribe, 'function');
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:');
|
||||||
|
f.clock.publish(scene, shot, 2);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
unsubscribe();
|
||||||
|
f.clock.publish(scene, shot, 3);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
f.clock.destroy();
|
||||||
|
} finally {
|
||||||
|
console.warn = origWarn;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -178,6 +178,92 @@ test('synchronous stop before execution prevents any side effect; rejection unlo
|
|||||||
assert.equal(calls, 2);
|
assert.equal(calls, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => {
|
||||||
|
let executed = 0;
|
||||||
|
let throwOnBusy = true;
|
||||||
|
let throwOnEnd = true;
|
||||||
|
const warnings = [];
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = (...args) => warnings.push(args);
|
||||||
|
try {
|
||||||
|
const session = createInteractionSession({
|
||||||
|
execute: () => {
|
||||||
|
executed++;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
changed: (state) => {
|
||||||
|
if (throwOnBusy && state.busy) throw new Error('start failed');
|
||||||
|
if (throwOnEnd && !state.busy && state.selected === 'a') {
|
||||||
|
throw new Error('end failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
session.activate([{ id: 'a' }]);
|
||||||
|
const result = await session.dispatch('a');
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.equal(executed, 1);
|
||||||
|
assert.deepEqual(session.getState(), {
|
||||||
|
active: true,
|
||||||
|
busy: false,
|
||||||
|
selected: 'a',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
warnings[0][0],
|
||||||
|
'[Scenes] Interaction session observer failed:',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
warnings[1][0],
|
||||||
|
'[Scenes] Interaction session observer failed:',
|
||||||
|
);
|
||||||
|
throwOnBusy = false;
|
||||||
|
throwOnEnd = false;
|
||||||
|
assert.equal(await session.dispatch('a'), true);
|
||||||
|
assert.equal(executed, 2);
|
||||||
|
session.clear();
|
||||||
|
assert.deepEqual(session.getState(), {
|
||||||
|
active: false,
|
||||||
|
busy: false,
|
||||||
|
selected: null,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
console.warn = origWarn;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an observer that always throws cannot stop activate or clear', () => {
|
||||||
|
const warnings = [];
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = (...args) => warnings.push(args);
|
||||||
|
try {
|
||||||
|
const session = createInteractionSession({
|
||||||
|
execute: () => true,
|
||||||
|
changed: () => {
|
||||||
|
throw new Error('observer failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
session.activate([{ id: 'a' }, { id: 'b' }]);
|
||||||
|
assert.deepEqual(session.getState(), {
|
||||||
|
active: true,
|
||||||
|
busy: false,
|
||||||
|
selected: null,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
session.clear();
|
||||||
|
assert.deepEqual(session.getState(), {
|
||||||
|
active: false,
|
||||||
|
busy: false,
|
||||||
|
selected: null,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
assert.equal(warnings.length, 3);
|
||||||
|
} finally {
|
||||||
|
console.warn = origWarn;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
|
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
|
||||||
const { SceneDirector } = await import('../../scenes/director.js');
|
const { SceneDirector } = await import('../../scenes/director.js');
|
||||||
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
selected = null;
|
selected = null;
|
||||||
let actions = new Map();
|
let actions = new Map();
|
||||||
const state = () => ({ active, busy, selected, count: actions.size });
|
const state = () => ({ active, busy, selected, count: actions.size });
|
||||||
|
function notify() {
|
||||||
|
try {
|
||||||
|
changed(state());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Scenes] Interaction session observer failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
function clear() {
|
function clear() {
|
||||||
active = false;
|
active = false;
|
||||||
busy = false;
|
busy = false;
|
||||||
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
actions.clear();
|
actions.clear();
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
controller = null;
|
controller = null;
|
||||||
changed(state());
|
notify();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
clear,
|
clear,
|
||||||
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
clear();
|
clear();
|
||||||
actions = new Map(items.map((item) => [item.id, item]));
|
actions = new Map(items.map((item) => [item.id, item]));
|
||||||
active = !!actions.size;
|
active = !!actions.size;
|
||||||
changed(state());
|
notify();
|
||||||
},
|
},
|
||||||
async dispatch(id) {
|
async dispatch(id) {
|
||||||
const item = actions.get(id);
|
const item = actions.get(id);
|
||||||
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
controller = current;
|
controller = current;
|
||||||
busy = true;
|
busy = true;
|
||||||
selected = id;
|
selected = id;
|
||||||
changed(state());
|
notify();
|
||||||
let abort;
|
let abort;
|
||||||
try {
|
try {
|
||||||
const cancelled = new Promise((resolve) => {
|
const cancelled = new Promise((resolve) => {
|
||||||
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
if (controller === current) {
|
if (controller === current) {
|
||||||
busy = false;
|
busy = false;
|
||||||
controller = null;
|
controller = null;
|
||||||
changed(state());
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function buildPlaybackQueue(
|
|||||||
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
||||||
* (false refuses that initial handoff). Between scenes and during final cleanup
|
* (false refuses that initial handoff). Between scenes and during final cleanup
|
||||||
* it receives no cancelled token, so resource release can finish after Stop.
|
* it receives no cancelled token, so resource release can finish after Stop.
|
||||||
|
* The first error wins when the final release also fails.
|
||||||
* An empty/already-cancelled queue acquires and releases no resources.
|
* An empty/already-cancelled queue acquires and releases no resources.
|
||||||
*
|
*
|
||||||
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
|
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
|
||||||
@@ -53,6 +54,7 @@ export async function playSceneQueue(
|
|||||||
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
||||||
let activeScene = null;
|
let activeScene = null;
|
||||||
let completedShots = 0;
|
let completedShots = 0;
|
||||||
|
let runFailed = false;
|
||||||
try {
|
try {
|
||||||
if (!queue.length || cancelled()) {
|
if (!queue.length || cancelled()) {
|
||||||
return {
|
return {
|
||||||
@@ -68,8 +70,9 @@ export async function playSceneQueue(
|
|||||||
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
||||||
const { scene, shot } = queue[index];
|
const { scene, shot } = queue[index];
|
||||||
if (activeScene && activeScene.id !== scene.id) {
|
if (activeScene && activeScene.id !== scene.id) {
|
||||||
await adapter.releaseScene(activeScene);
|
const toRelease = activeScene;
|
||||||
activeScene = null;
|
activeScene = null;
|
||||||
|
await adapter.releaseScene(toRelease);
|
||||||
if (cancelled()) break;
|
if (cancelled()) break;
|
||||||
}
|
}
|
||||||
activeScene = scene;
|
activeScene = scene;
|
||||||
@@ -82,8 +85,23 @@ export async function playSceneQueue(
|
|||||||
}
|
}
|
||||||
if (!cancelled()) await adapter.complete?.();
|
if (!cancelled()) await adapter.complete?.();
|
||||||
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
||||||
|
} catch (error) {
|
||||||
|
runFailed = true;
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
|
if (releaseOnFinish && activeScene) {
|
||||||
|
const toRelease = activeScene;
|
||||||
|
activeScene = null;
|
||||||
|
try {
|
||||||
|
await adapter.releaseScene(toRelease);
|
||||||
|
} catch (error) {
|
||||||
|
if (runFailed) {
|
||||||
|
console.warn('[Scenes] Scene release failed:', error);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,3 +196,68 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy
|
|||||||
(error) => error === failure,
|
(error) => error === failure,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a phase failure is preserved when final scene release also throws', async () => {
|
||||||
|
const phaseError = new Error('travel failed');
|
||||||
|
const releaseError = new Error('release failed');
|
||||||
|
const warnings = [];
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = (...args) => warnings.push(args);
|
||||||
|
try {
|
||||||
|
const f = fixture({
|
||||||
|
travel: async () => {
|
||||||
|
throw phaseError;
|
||||||
|
},
|
||||||
|
releaseScene: async () => {
|
||||||
|
throw releaseError;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||||
|
(error) => error === phaseError,
|
||||||
|
);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.equal(warnings[0][0], '[Scenes] Scene release failed:');
|
||||||
|
assert.equal(warnings[0][1], releaseError);
|
||||||
|
} finally {
|
||||||
|
console.warn = origWarn;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing inter-scene release releases the active scene only once', async () => {
|
||||||
|
const releaseError = new Error('release failed');
|
||||||
|
const released = [];
|
||||||
|
const f = fixture({
|
||||||
|
releaseScene: async (scene) => {
|
||||||
|
released.push(scene.id);
|
||||||
|
throw releaseError;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
|
||||||
|
(error) => error === releaseError,
|
||||||
|
);
|
||||||
|
assert.deepEqual(released, ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a falsy phase failure is still preserved over a release failure', async () => {
|
||||||
|
const releaseError = new Error('release failed');
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
try {
|
||||||
|
const f = fixture({
|
||||||
|
travel: async () => {
|
||||||
|
throw undefined;
|
||||||
|
},
|
||||||
|
releaseScene: async () => {
|
||||||
|
throw releaseError;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||||
|
(error) => error === undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
console.warn = origWarn;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user