Files
gods-eye-view/src/director/clock.test.mjs
T

203 lines
5.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createPlaybackClock } from './clock.js';
const scene = { id: 'scene', shots: [{ id: 'shot' }] };
const shot = scene.shots[0];
const timing = {
shotIndex: 0,
totalSec: 4,
durationSec: 4,
startElapsedSec: 0,
};
function fixture() {
const timers = new Map();
let id = 0,
now = 0,
running = true;
const progress = [];
const clock = createPlaybackClock({
isRunning: () => running,
timingForShot: () => timing,
onProgress: (value) => progress.push(value),
now: () => now,
schedule: (callback) => {
timers.set(++id, callback);
return id;
},
cancel: (handle) => timers.delete(handle),
});
return {
clock,
timers,
progress,
time: (value) => {
now = value;
},
running: (value) => {
running = value;
},
};
}
test('Stop releases every clock timer and queued callbacks cannot publish later', () => {
const f = fixture();
const observed = [];
f.clock.subscribe((snapshot) => observed.push(snapshot));
f.clock.startRunProgress(4);
f.clock.startScene(scene, shot, { cancelled: false });
f.time(2000);
for (const tick of f.timers.values()) tick();
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
const stale = [...f.timers.values()];
f.clock.stop();
assert.equal(f.clock.activeTimers, 0);
assert.equal(f.timers.size, 0);
assert.equal(f.clock.snapshot.stopped, true);
const count = observed.length,
progressCount = f.progress.length;
f.time(3000);
for (const tick of stale) tick();
assert.equal(observed.length, count);
assert.equal(f.progress.length, progressCount);
});
test('replacing a shot clock rejects the old callback without clearing the replacement', () => {
const f = fixture();
f.clock.startScene(scene, shot, {});
const stale = [...f.timers.values()][0];
f.time(1000);
f.clock.startScene(scene, shot, {});
f.time(2000);
stale();
assert.equal(f.clock.snapshot.sceneElapsedSec, 0);
assert.equal(f.clock.activeTimers, 1);
[...f.timers.values()][0]();
assert.equal(f.clock.snapshot.sceneElapsedSec, 1);
f.clock.destroy();
});
test('direct-load progress finishes once and snapshots cannot mutate the clock', () => {
const f = fixture();
f.running(false);
f.clock.startShotProgress({}, 2, 0.25, 0.75, {
scene,
shot,
sceneElapsedFrom: 1,
sceneElapsedTo: 3,
});
const tick = [...f.timers.values()][0];
f.time(1000);
tick();
assert.equal(f.progress.at(-1), 0.5);
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
const copy = f.clock.snapshot;
copy.sceneElapsedSec = 99;
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
f.time(2000);
tick();
assert.equal(f.progress.at(-1), 0.75);
assert.equal(f.clock.activeTimers, 0);
const count = f.progress.length;
tick();
assert.equal(f.progress.length, count);
});
test('aborted starts and destruction during initial notification leave no timers or listeners', () => {
const f = fixture();
const controller = new AbortController();
controller.abort();
f.clock.startScene(scene, shot, { signal: controller.signal });
assert.equal(f.timers.size, 0);
let calls = 0;
f.clock.subscribe(() => {
calls++;
f.clock.destroy();
});
f.clock.startScene(scene, shot, {});
assert.equal(calls, 1);
assert.equal(f.clock.activeTimers, 0);
f.clock.startRunProgress(4);
f.clock.publish(scene, shot, 1);
f.clock.subscribe(() => calls++);
assert.equal(calls, 1);
assert.equal(f.timers.size, 0);
});
test('Stop, abort and destroy settle long holds immediately and release their deadlines', async () => {
for (const action of ['stop', 'abort', 'destroy']) {
const clock = createPlaybackClock({
isRunning: () => true,
timingForShot: () => timing,
onProgress() {},
});
const abort = new AbortController();
const wait = clock.wait(60000, { signal: abort.signal });
assert.equal(clock.activeTimers, 1);
if (action === 'abort') abort.abort();
else clock[action]();
await wait;
assert.equal(clock.activeTimers, 0);
clock.destroy();
}
});
test('a subscriber can Stop or replace the initial clock without the old start acquiring a timer', () => {
for (const action of ['stop', 'replace']) {
const f = fixture();
let first = true;
f.clock.subscribe(() => {
if (!first) return;
first = false;
if (action === 'stop') f.clock.stop();
else f.clock.startScene(scene, { id: 'replacement' }, {});
});
f.clock.startScene(scene, shot, {});
assert.equal(f.timers.size, action === 'stop' ? 0 : 1);
assert.equal(
f.clock.snapshot.shotId,
action === 'stop' ? shot.id : 'replacement',
);
f.clock.destroy();
}
const f = fixture();
f.running(false);
f.clock.subscribe(() => f.clock.stop());
f.clock.startShotProgress({}, 10, 0, 1, {
scene,
shot,
sceneElapsedFrom: 0,
sceneElapsedTo: 4,
});
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;
}
});