Files
gods-eye-view/src/director/playback.test.mjs
T
Said TurkseverandClaude Opus 5.5 76199c07ee fix(director): keep a falsy phase error over a release failure
runError doubled as the failure flag, so a phase that threw a falsy
value was still masked by a final release error (ianblenke's nit).
Track failure with a boolean and cover it with a test. Make the
changelog fragment a wrapped bullet like the other fragments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:27:25 +01:00

264 lines
7.7 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { buildPlaybackQueue, playSceneQueue } from './playback.js';
const scenes = [
{ id: 'a', title: 'A', shots: [{ id: 'a1' }, { id: 'a2' }] },
{ id: 'empty', shots: [] },
{ id: 'b', title: 'B', shots: [{ id: 'b1' }] },
];
const phases = [
'selectShot',
'applyVisual',
'applyLayers',
'travel',
'settle',
'hold',
'completeShot',
];
function fixture(overrides = {}) {
const events = [];
const abort = new AbortController();
const token = { cancelled: false, signal: abort.signal };
const adapter = {
...Object.fromEntries(
phases.map((phase) => [
phase,
({ shot, token: received }) => {
assert.equal(received, token);
events.push(`${shot.id}:${phase}`);
},
]),
),
releaseScene(scene, received) {
events.push(`release:${scene.id}:${received ? 'handoff' : 'cleanup'}`);
return true;
},
complete() {
events.push('complete');
},
...overrides,
};
return { events, abort, token, adapter };
}
test('queues rotate scenes, skip empty scenes, preserve shot identity and support a single scene', () => {
const queue = buildPlaybackQueue(scenes, 'b');
assert.deepEqual(
queue.map(({ shot }) => shot.id),
['b1', 'a1', 'a2'],
);
assert.equal(queue[0].shot, scenes[2].shots[0]);
assert.deepEqual(buildPlaybackQueue(scenes, 'empty', { single: true }), []);
assert.deepEqual(buildPlaybackQueue([], 'missing'), []);
assert.deepEqual(
buildPlaybackQueue(scenes, 'missing').map(({ shot }) => shot.id),
['a1', 'a2', 'b1'],
);
});
test('playback sequences phases, releases only at scene changes, then releases the final scene', async () => {
const f = fixture();
const result = await playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
assert.deepEqual(result, { status: 'completed', completedShots: 3 });
assert.deepEqual(f.events, [
...phases.map((phase) => `a1:${phase}`),
...phases.map((phase) => `a2:${phase}`),
'release:a:cleanup',
...phases.map((phase) => `b1:${phase}`),
'complete',
'release:b:cleanup',
]);
});
for (const phase of phases) {
for (const cancellation of ['flag', 'signal']) {
test(`${cancellation} cancellation while awaiting ${phase} stops subsequent work and releases resources`, async () => {
let entered;
let resume;
const started = new Promise((resolve) => {
entered = resolve;
});
const pending = new Promise((resolve) => {
resume = resolve;
});
const f = fixture({
[phase]: async () => {
entered();
await pending;
},
});
const run = playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
await started;
if (cancellation === 'flag') f.token.cancelled = true;
else f.abort.abort();
resume();
assert.deepEqual(await run, { status: 'cancelled', completedShots: 0 });
assert.deepEqual(f.events, [
...phases.slice(0, phases.indexOf(phase)).map((name) => `a1:${name}`),
'release:a:cleanup',
]);
});
}
test(`failure in ${phase} propagates after release, without later shots`, async () => {
const failure = new Error('adapter failed');
const f = fixture({
[phase]: () => {
throw failure;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
(error) => error === failure,
);
assert.equal(f.events.at(-1), 'release:a:cleanup');
assert.ok(
!f.events.some(
(event) => event.startsWith('a2:') || event === 'complete',
),
);
});
}
test('empty and pre-aborted runs never acquire or release adapter resources', async () => {
const f = fixture();
assert.deepEqual(await playSceneQueue([], f), {
status: 'completed',
completedShots: 0,
});
f.abort.abort();
assert.deepEqual(
await playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
...f,
previousScene: scenes[2],
}),
{ status: 'cancelled', completedShots: 0 },
);
assert.deepEqual(f.events, []);
});
test('a prior scene must release before playback; a refused handoff starts no shot', async () => {
const f = fixture();
await playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), {
...f,
previousScene: scenes[2],
});
assert.equal(f.events[0], 'release:b:handoff');
const refused = fixture({ releaseScene: () => false });
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
...refused,
previousScene: scenes[2],
}),
/Could not leave scene: B/,
);
assert.deepEqual(refused.events, []);
});
test('cancellation during an inter-scene release never acquires the next scene', async () => {
const f = fixture();
const release = f.adapter.releaseScene;
f.adapter.releaseScene = (...args) => {
f.abort.abort();
return release(...args);
};
const result = await playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
assert.deepEqual(result, { status: 'cancelled', completedShots: 2 });
assert.equal(f.events.at(-1), 'release:a:cleanup');
assert.equal(
f.events.filter((event) => event.startsWith('release')).length,
1,
);
});
test('non-preview playback retains the final scene but still releases preceding scenes', async () => {
const f = fixture();
await playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
...f,
releaseOnFinish: false,
});
assert.deepEqual(
f.events.filter((event) => event.startsWith('release')),
['release:a:cleanup'],
);
});
test('a cleanup failure rejects for the caller to restore its own controls', async () => {
const failure = new Error('cleanup failed');
const f = fixture({
releaseScene: () => {
throw failure;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
(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;
}
});