mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 08:39:34 +00:00
test(server): characterise decorator behaviour before migration
The two TypeScript legacy decorators in the server package (`@broadcastReturn` on AuxTimerService, `@broadcastResult` on RuntimeService) drive all runtime state broadcasting but had no test coverage, so a migration to standard decorators would have been unverifiable. Add characterisation tests that lock in the current behaviour: - AuxTimerService: emit key derived from the last argument, payload equal to the return value, one emit per public call, and the undecorated interval update emitting a combined patch instead. - RuntimeService: which methods broadcast and which do not, return value forwarding, batch contents for each kind of state change, and the restore point side effect. No production code changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTztQittreLfXz8cgUeeMs
This commit is contained in:
@@ -0,0 +1,235 @@
|
|||||||
|
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
|
||||||
|
|
||||||
|
import { AuxTimerService } from '../AuxTimerService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests characterise the behaviour of the `@broadcastReturn` decorator applied
|
||||||
|
* to the public methods of AuxTimerService:
|
||||||
|
* - the decorated method emits its own return value
|
||||||
|
* - the emit key is derived from the *last* argument of the call
|
||||||
|
* - the return value of the decorated method is the value of the original method
|
||||||
|
* - private methods (`update`) are not decorated and emit a patch instead
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Emitted = Record<string, SimpleTimerState>;
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
// the emitted state objects are live references into SimpleTimer,
|
||||||
|
// so we clone on capture to keep an accurate record of each emit
|
||||||
|
const emitted: Emitted[] = [];
|
||||||
|
const emit = vi.fn((state) => {
|
||||||
|
emitted.push(structuredClone(state) as Emitted);
|
||||||
|
});
|
||||||
|
|
||||||
|
let now = 0;
|
||||||
|
const getTime = () => now;
|
||||||
|
const setTime = (value: number) => {
|
||||||
|
now = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AuxTimerService(emit, getTime);
|
||||||
|
return { service, emit, emitted, setTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuxTimerService decorated methods', () => {
|
||||||
|
describe('emit key resolution', () => {
|
||||||
|
it.each([1, 2, 3])('start(%i) emits under the matching auxtimer key', (index) => {
|
||||||
|
const { service, emit, emitted } = makeService();
|
||||||
|
|
||||||
|
const result = service.start(index);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Object.keys(emitted[0])).toEqual([`auxtimer${index}`]);
|
||||||
|
expect(emitted[0][`auxtimer${index}`]).toMatchObject({ playback: SimplePlayback.Start });
|
||||||
|
// the decorator emits exactly what the original method returned
|
||||||
|
expect(result).toMatchObject({ playback: SimplePlayback.Start });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the key from the last argument, not from a fixed position', () => {
|
||||||
|
const { service, emitted } = makeService();
|
||||||
|
|
||||||
|
service.setTime(1000, 2);
|
||||||
|
service.addTime(500, 3);
|
||||||
|
service.setDirection(SimpleDirection.CountUp, 1);
|
||||||
|
|
||||||
|
expect(Object.keys(emitted[0])).toEqual(['auxtimer2']);
|
||||||
|
expect(Object.keys(emitted[1])).toEqual(['auxtimer3']);
|
||||||
|
expect(Object.keys(emitted[2])).toEqual(['auxtimer1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the three timers isolated from each other', () => {
|
||||||
|
const { service, emitted } = makeService();
|
||||||
|
|
||||||
|
service.setTime(1000, 1);
|
||||||
|
service.setTime(2000, 2);
|
||||||
|
service.setTime(3000, 3);
|
||||||
|
|
||||||
|
expect(emitted[0].auxtimer1.duration).toBe(1000);
|
||||||
|
expect(emitted[1].auxtimer2.duration).toBe(2000);
|
||||||
|
expect(emitted[2].auxtimer3.duration).toBe(3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emit payload matches the return value', () => {
|
||||||
|
it('setTime returns and emits the new duration', () => {
|
||||||
|
const { service, emit, emitted } = makeService();
|
||||||
|
|
||||||
|
const result = service.setTime(5000, 1);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.duration).toBe(5000);
|
||||||
|
expect(result.current).toBe(5000);
|
||||||
|
expect(emitted[0].auxtimer1).toMatchObject({ duration: 5000, current: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setDirection returns and emits the new direction', () => {
|
||||||
|
const { service, emitted } = makeService();
|
||||||
|
|
||||||
|
const result = service.setDirection(SimpleDirection.CountUp, 1);
|
||||||
|
|
||||||
|
expect(result.direction).toBe(SimpleDirection.CountUp);
|
||||||
|
expect(emitted[0].auxtimer1.direction).toBe(SimpleDirection.CountUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pause returns and emits the paused state', () => {
|
||||||
|
const { service, emit, emitted, setTime } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.start(1);
|
||||||
|
setTime(2000);
|
||||||
|
const result = service.pause(1);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(3);
|
||||||
|
expect(result.playback).toBe(SimplePlayback.Pause);
|
||||||
|
expect(emitted[2].auxtimer1.playback).toBe(SimplePlayback.Pause);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop returns and emits the reset state', () => {
|
||||||
|
const { service, emitted, setTime } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.start(1);
|
||||||
|
setTime(2000);
|
||||||
|
const result = service.stop(1);
|
||||||
|
|
||||||
|
expect(result.playback).toBe(SimplePlayback.Stop);
|
||||||
|
expect(result.current).toBe(10000);
|
||||||
|
expect(emitted.at(-1)?.auxtimer1).toMatchObject({ playback: SimplePlayback.Stop, current: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTime on a running timer emits the recalculated value', () => {
|
||||||
|
const { service, emitted, setTime } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.start(1);
|
||||||
|
setTime(2000);
|
||||||
|
|
||||||
|
const result = service.addTime(5000, 1);
|
||||||
|
|
||||||
|
// running timers are updated against the current time before emitting
|
||||||
|
expect(result.duration).toBe(15000);
|
||||||
|
expect(result.current).toBe(13000);
|
||||||
|
expect(emitted.at(-1)?.auxtimer1).toMatchObject({ duration: 15000, current: 13000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTime on a stopped timer emits without recalculating', () => {
|
||||||
|
const { service, emitted, setTime } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
setTime(2000);
|
||||||
|
|
||||||
|
const result = service.addTime(5000, 1);
|
||||||
|
|
||||||
|
expect(result.duration).toBe(15000);
|
||||||
|
expect(result.current).toBe(15000);
|
||||||
|
expect(emitted.at(-1)?.auxtimer1).toMatchObject({ duration: 15000, current: 15000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every decorated call emits exactly once', () => {
|
||||||
|
it('emits one message per public call', () => {
|
||||||
|
const { service, emit } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.setDirection(SimpleDirection.CountUp, 1);
|
||||||
|
service.start(1);
|
||||||
|
service.addTime(1000, 1);
|
||||||
|
service.pause(1);
|
||||||
|
service.stop(1);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits even when the underlying timer refused the operation', () => {
|
||||||
|
const { service, emit, emitted } = makeService();
|
||||||
|
|
||||||
|
// pausing a stopped timer is a no-op in SimpleTimer, the decorator still emits
|
||||||
|
const result = service.pause(1);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.playback).toBe(SimplePlayback.Stop);
|
||||||
|
expect(emitted[0].auxtimer1.playback).toBe(SimplePlayback.Stop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interval driven updates are not decorated', () => {
|
||||||
|
it('emits a combined patch for all running timers', () => {
|
||||||
|
const { service, emit, emitted, setTime } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.setTime(20000, 2);
|
||||||
|
service.start(1);
|
||||||
|
service.start(2);
|
||||||
|
emit.mockClear();
|
||||||
|
emitted.length = 0;
|
||||||
|
|
||||||
|
setTime(1000);
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(1);
|
||||||
|
// a single patch carrying both running timers, rather than one emit per timer
|
||||||
|
expect(Object.keys(emitted[0]).sort()).toEqual(['auxtimer1', 'auxtimer2']);
|
||||||
|
expect(emitted[0].auxtimer1.current).toBe(9000);
|
||||||
|
expect(emitted[0].auxtimer2.current).toBe(19000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit when no timer is running', () => {
|
||||||
|
const { service, emit } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.start(1);
|
||||||
|
service.stop(1);
|
||||||
|
emit.mockClear();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
expect(emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ticking while another timer is still running', () => {
|
||||||
|
const { service, emit, emitted, setTime } = makeService();
|
||||||
|
|
||||||
|
service.setTime(10000, 1);
|
||||||
|
service.setTime(20000, 2);
|
||||||
|
service.start(1);
|
||||||
|
service.start(2);
|
||||||
|
service.stop(1);
|
||||||
|
emit.mockClear();
|
||||||
|
emitted.length = 0;
|
||||||
|
|
||||||
|
setTime(1000);
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Object.keys(emitted[0])).toEqual(['auxtimer2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { OffsetMode, Playback, SupportedEntry, runtimeStorePlaceholder } from 'ontime-types';
|
||||||
|
|
||||||
|
import type { RuntimeState } from '../../../stores/runtimeState.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests characterise the behaviour of the `@broadcastResult` decorator applied
|
||||||
|
* to the public methods of RuntimeService:
|
||||||
|
* - which methods broadcast and which do not
|
||||||
|
* - the decorator forwards the return value of the original method
|
||||||
|
* - the contents of the batch sent to the event store for a given state change
|
||||||
|
* - the side effect of saving a restore point
|
||||||
|
*
|
||||||
|
* The service is a module level singleton holding a static `previousState`,
|
||||||
|
* so every test re-imports the module to get an isolated instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const makeState = (): RuntimeState => ({
|
||||||
|
clock: 0,
|
||||||
|
groupNow: null,
|
||||||
|
eventNow: null,
|
||||||
|
eventNext: null,
|
||||||
|
eventFlag: null,
|
||||||
|
offset: { ...runtimeStorePlaceholder.offset },
|
||||||
|
timer: { ...runtimeStorePlaceholder.timer },
|
||||||
|
rundown: { ...runtimeStorePlaceholder.rundown },
|
||||||
|
_timer: {
|
||||||
|
forceFinish: null,
|
||||||
|
pausedAt: null,
|
||||||
|
pausedDuration: 0,
|
||||||
|
secondaryTarget: null,
|
||||||
|
hasFinished: false,
|
||||||
|
},
|
||||||
|
_rundown: { totalDelay: 0 },
|
||||||
|
_group: null,
|
||||||
|
_flag: null,
|
||||||
|
_end: null,
|
||||||
|
_startEpoch: null,
|
||||||
|
_startDayOffset: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
makeState,
|
||||||
|
// assigned by loadService(), the placeholder import is not available at hoist time
|
||||||
|
state: null as unknown as RuntimeState,
|
||||||
|
/** every batch created by the decorator, in order */
|
||||||
|
batches: [] as { patch: Record<string, unknown>; sent: boolean }[],
|
||||||
|
timer: {
|
||||||
|
start: vi.fn(() => true),
|
||||||
|
pause: vi.fn(() => true),
|
||||||
|
stop: vi.fn(() => true),
|
||||||
|
addTime: vi.fn(() => true),
|
||||||
|
shutdown: vi.fn(),
|
||||||
|
setOnUpdateCallback: vi.fn(),
|
||||||
|
},
|
||||||
|
save: vi.fn(() => Promise.resolve()),
|
||||||
|
setOffsetMode: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../stores/EventStore.js', () => ({
|
||||||
|
eventStore: {
|
||||||
|
createBatch() {
|
||||||
|
const entry: { patch: Record<string, unknown>; sent: boolean } = { patch: {}, sent: false };
|
||||||
|
mocks.batches.push(entry);
|
||||||
|
return {
|
||||||
|
add(key: string, value: unknown) {
|
||||||
|
entry.patch[key] = value;
|
||||||
|
},
|
||||||
|
send() {
|
||||||
|
// mirrors the empty patch guard in the real event store
|
||||||
|
if (Object.keys(entry.patch).length > 0) entry.sent = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/runtimeState.js', () => ({
|
||||||
|
getState: () => mocks.state,
|
||||||
|
setOffsetMode: mocks.setOffsetMode,
|
||||||
|
stop: vi.fn(() => true),
|
||||||
|
roll: vi.fn(() => ({ didStart: false, eventId: null })),
|
||||||
|
resume: vi.fn(),
|
||||||
|
updateAll: vi.fn(),
|
||||||
|
load: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../EventTimer.js', () => ({
|
||||||
|
EventTimer: class {
|
||||||
|
start = mocks.timer.start;
|
||||||
|
pause = mocks.timer.pause;
|
||||||
|
stop = mocks.timer.stop;
|
||||||
|
addTime = mocks.timer.addTime;
|
||||||
|
shutdown = mocks.timer.shutdown;
|
||||||
|
setOnUpdateCallback = mocks.timer.setOnUpdateCallback;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../restore-service/restore.service.js', () => ({
|
||||||
|
restoreService: { save: mocks.save },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../api-data/rundown/rundown.dao.js', () => ({
|
||||||
|
getCurrentRundown: vi.fn(() => ({ entries: {}, order: [], flatOrder: [], id: 'rundown', title: '' })),
|
||||||
|
getEntryWithId: vi.fn(() => undefined),
|
||||||
|
getRundownMetadata: vi.fn(() => ({ timedEventOrder: [], playableEventOrder: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../api-data/automation/automation.service.js', () => ({ triggerAutomations: vi.fn() }));
|
||||||
|
vi.mock('../../../api-data/report/report.service.js', () => ({ triggerReportEntry: vi.fn() }));
|
||||||
|
vi.mock('../../../classes/Logger.js', () => ({
|
||||||
|
logger: { info: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function loadService() {
|
||||||
|
vi.resetModules();
|
||||||
|
mocks.state = mocks.makeState();
|
||||||
|
mocks.batches.length = 0;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { runtimeService } = await import('../runtime.service.js');
|
||||||
|
return runtimeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the last batch that actually reached the socket */
|
||||||
|
function lastSentPatch() {
|
||||||
|
return mocks.batches.filter((batch) => batch.sent).at(-1)?.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RuntimeService decorated methods', () => {
|
||||||
|
describe('which methods broadcast', () => {
|
||||||
|
it('creates a batch for every decorated call', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
expect(mocks.batches).toHaveLength(1);
|
||||||
|
|
||||||
|
service.pause();
|
||||||
|
expect(mocks.batches).toHaveLength(2);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(mocks.batches).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['startById', () => ['an-id']],
|
||||||
|
['startByIndex', () => [0]],
|
||||||
|
['startByCue', () => ['a-cue']],
|
||||||
|
['loadById', () => ['an-id']],
|
||||||
|
['loadByIndex', () => [0]],
|
||||||
|
['loadByCue', () => ['a-cue']],
|
||||||
|
['loadPrevious', () => []],
|
||||||
|
['loadNext', () => []],
|
||||||
|
['start', () => []],
|
||||||
|
['startPrevious', () => []],
|
||||||
|
['startNext', () => []],
|
||||||
|
['pause', () => []],
|
||||||
|
['stop', () => []],
|
||||||
|
['reload', () => []],
|
||||||
|
['roll', () => []],
|
||||||
|
['addTime', () => [1000]],
|
||||||
|
['setOffsetMode', () => [OffsetMode.Relative]],
|
||||||
|
] as const)('%s broadcasts', async (method, makeArgs) => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
// @ts-expect-error -- arguments are provided per method by the table above
|
||||||
|
service[method](...makeArgs());
|
||||||
|
|
||||||
|
expect(mocks.batches).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['getRuntimeState', () => []],
|
||||||
|
['getLoadedEventId', () => []],
|
||||||
|
['shutdown', () => []],
|
||||||
|
] as const)('%s does not broadcast', async (method, makeArgs) => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
// @ts-expect-error -- arguments are provided per method by the table above
|
||||||
|
service[method](...makeArgs());
|
||||||
|
|
||||||
|
expect(mocks.batches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('return values are forwarded', () => {
|
||||||
|
it('returns the result of the original method', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
// no event matches, the original method returns false
|
||||||
|
expect(service.loadById('missing')).toBe(false);
|
||||||
|
expect(service.startById('missing')).toBe(false);
|
||||||
|
expect(service.loadNext()).toBe(false);
|
||||||
|
expect(service.loadPrevious()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for methods that do not return', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
expect(service.setOffsetMode(OffsetMode.Relative)).toBeUndefined();
|
||||||
|
expect(mocks.setOffsetMode).toHaveBeenCalledWith(OffsetMode.Relative);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards a truthy result', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
// playback needs to be armed or paused for a start to be accepted
|
||||||
|
mocks.state.timer = { ...mocks.state.timer, playback: Playback.Armed };
|
||||||
|
|
||||||
|
expect(service.start()).toBe(true);
|
||||||
|
expect(mocks.timer.start).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batch contents', () => {
|
||||||
|
it('forces a full update on the first call', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
// there is no previous state, so everything is considered changed
|
||||||
|
expect(Object.keys(lastSentPatch() ?? {}).sort()).toEqual(['clock', 'offset', 'rundown', 'timer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing when the state has not changed', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(mocks.batches).toHaveLength(2);
|
||||||
|
expect(mocks.batches[1].sent).toBe(false);
|
||||||
|
expect(mocks.batches[1].patch).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the timer and clock when playback changes', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
mocks.state.timer = { ...mocks.state.timer, playback: Playback.Play };
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(Object.keys(lastSentPatch() ?? {}).sort()).toEqual(['clock', 'timer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the offset when the offset mode changes', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Absolute);
|
||||||
|
mocks.state.offset = { ...mocks.state.offset, mode: OffsetMode.Relative };
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(lastSentPatch()).toHaveProperty('offset');
|
||||||
|
expect(lastSentPatch()?.offset).toMatchObject({ mode: OffsetMode.Relative });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the loaded entries when they change', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
mocks.state.eventNow = { id: 'event-1', title: 'first', type: SupportedEntry.Event } as RuntimeState['eventNow'];
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(lastSentPatch()).toHaveProperty('eventNow');
|
||||||
|
expect(lastSentPatch()?.eventNow).toMatchObject({ id: 'event-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the rundown when it changes', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
mocks.state.rundown = { ...mocks.state.rundown, numEvents: 3 };
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(lastSentPatch()).toHaveProperty('rundown');
|
||||||
|
expect(lastSentPatch()?.rundown).toMatchObject({ numEvents: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the clock when a second rolls over', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
mocks.state.clock = 2000;
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(lastSentPatch()).toEqual({ clock: 2000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restore point side effect', () => {
|
||||||
|
it('saves a restore point when there are immediate changes', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
// the first call is always treated as an immediate change
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(mocks.save).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
playback: Playback.Stop,
|
||||||
|
selectedEventId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save a restore point when nothing of note changed', async () => {
|
||||||
|
const service = await loadService();
|
||||||
|
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
mocks.save.mockClear();
|
||||||
|
|
||||||
|
// only the clock moved, which is not an immediate change
|
||||||
|
mocks.state.clock = 2000;
|
||||||
|
service.setOffsetMode(OffsetMode.Relative);
|
||||||
|
|
||||||
|
expect(mocks.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user