From b31839debee65cb186561115687bb665c7401028 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:37:08 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01JTztQittreLfXz8cgUeeMs --- .../__tests__/AuxTimerService.test.ts | 235 +++++++++++++ .../__tests__/runtime.service.test.ts | 324 ++++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts create mode 100644 apps/server/src/services/runtime-service/__tests__/runtime.service.test.ts diff --git a/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts b/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts new file mode 100644 index 000000000..0a2659576 --- /dev/null +++ b/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts @@ -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; + +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']); + }); + }); +}); diff --git a/apps/server/src/services/runtime-service/__tests__/runtime.service.test.ts b/apps/server/src/services/runtime-service/__tests__/runtime.service.test.ts new file mode 100644 index 000000000..22bb23622 --- /dev/null +++ b/apps/server/src/services/runtime-service/__tests__/runtime.service.test.ts @@ -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; 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; 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(); + }); + }); +});