diff --git a/apps/server/src/services/runtime-service/__tests__/runtime.service.broadcast.test.ts b/apps/server/src/services/runtime-service/__tests__/runtime.service.broadcast.test.ts new file mode 100644 index 000000000..41510423d --- /dev/null +++ b/apps/server/src/services/runtime-service/__tests__/runtime.service.broadcast.test.ts @@ -0,0 +1,251 @@ +import { MessageTag, OffsetMode, TimerLifeCycle } from 'ontime-types'; +/** + * Characterisation tests for the broadcast contract of the RuntimeService + * + * These tests lock down WHICH top-level keys of the runtime store are + * emitted to clients for each action and tick. This is the wire contract + * consumed by the client (which shallow-merges top-level keys) and by + * user-facing automation templates. + * + * The tests drive the real production path: fake timers fire the + * EventTimer interval, which runs update() and the broadcastResult + * decorator diffing, down to the (spied) websocket fan-out. + */ +import type { MockInstance } from 'vitest'; + +import { makeFlatRundown } from '../../../stores/__tests__/harness/scenario.fixtures.js'; + +vi.mock('../../../classes/data-provider/DataProvider.js', () => { + return { + getDataProvider: vi.fn().mockImplementation(() => { + return { + setCustomFields: vi.fn().mockImplementation((newData) => newData), + setRundown: vi.fn().mockImplementation((newData) => newData), + }; + }), + }; +}); + +vi.mock('../../restore-service/restore.service.js', () => { + return { + restoreService: { + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn(), + clear: vi.fn(), + create: vi.fn(), + shutdown: vi.fn(), + }, + }; +}); + +vi.mock('../../../api-data/automation/automation.service.js', () => { + return { + triggerAutomations: vi.fn(), + testOutput: vi.fn(), + testConditions: vi.fn(), + }; +}); + +vi.mock('../../../api-data/report/report.service.js', () => { + return { + generate: vi.fn(), + clear: vi.fn(), + triggerReportEntry: vi.fn(), + }; +}); + +type RuntimeServiceModule = typeof import('../runtime.service.js'); +type AutomationModule = typeof import('../../../api-data/automation/automation.service.js'); +type RestoreModule = typeof import('../../restore-service/restore.service.js'); + +let runtimeService: RuntimeServiceModule['runtimeService']; +let triggerAutomations: AutomationModule['triggerAutomations']; +let restoreService: RestoreModule['restoreService']; +let sendSpy: MockInstance; + +/** RuntimeData patches sent over the websocket since the last spy reset */ +function runtimePatches(): Record[] { + return sendSpy.mock.calls + .filter(([tag]) => tag === MessageTag.RuntimeData) + .map(([, payload]) => payload as Record); +} + +/** the sorted top-level keys of every emitted patch */ +function emittedKeys(): string[][] { + return runtimePatches().map((patch) => Object.keys(patch).sort()); +} + +/** drains the process.nextTick queue where automation triggers are scheduled */ +async function flushNextTicks() { + await new Promise((resolve) => process.nextTick(resolve)); +} + +beforeEach(async () => { + // the runtime service starts its EventTimer interval at import time: + // reset modules and install fake timers BEFORE importing, so the + // interval is fake and controlled by the test + vi.resetModules(); + vi.useFakeTimers(); + vi.setSystemTime('jan 5 09:59'); + + const websocketModule = await import('../../../adapters/WebsocketAdapter.js'); + sendSpy = vi.spyOn(websocketModule.socket, 'sendAsJson'); + + const automationModule = await import('../../../api-data/automation/automation.service.js'); + triggerAutomations = automationModule.triggerAutomations; + + const restoreModule = await import('../../restore-service/restore.service.js'); + restoreService = restoreModule.restoreService; + + const serviceModule = await import('../runtime.service.js'); + runtimeService = serviceModule.runtimeService; + runtimeService.init(null); + + const { initRundown } = await import('../../../api-data/rundown/rundown.service.js'); + await initRundown(makeFlatRundown(), {}); + // flush the scheduled rundown side effects + // (we cannot use runAllTimers here, the EventTimer interval never stops) + vi.runOnlyPendingTimers(); + + sendSpy.mockClear(); + vi.mocked(restoreService.save).mockClear(); +}); + +afterEach(() => { + runtimeService.shutdown(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +/** + * !!! characterised bug: the decorator combines the entry diffing with + * `entryChanged ||= updateMaybeEntryIfChanged(key)`. The logical-or + * assignment short-circuits: once one entry has changed, the remaining + * entries are neither diffed nor emitted in that broadcast. Changed + * entries therefore drip out one per broadcast cycle + * (load emits eventNow; eventNext rides along on the next broadcast, + * eventFlag on the one after) + */ +describe('characterisation: broadcast contract', () => { + test('loading an event emits eventNow, timer, clock and rundown', () => { + runtimeService.loadById('flat1'); + + // eventNext and eventFlag also changed, but are held back by the short-circuit + expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'rundown', 'timer']]); + // an immediate change also saves a restore point + expect(restoreService.save).toHaveBeenCalledTimes(1); + }); + + test('starting emits timer, clock, offset and rundown', () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + sendSpy.mockClear(); + + runtimeService.start(); + + // offset is included because the expected times are computed on start + // eventNext is the pending entry dripping out from the load + expect(emittedKeys()).toStrictEqual([['clock', 'eventNext', 'offset', 'rundown', 'timer']]); + }); + + test('ticks within the same second only flush the pending entry', () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + runtimeService.start(); + sendSpy.mockClear(); + + // the EventTimer interval fires every 32ms: run 5 production ticks + vi.advanceTimersByTime(5 * 32); + + // nothing tick-related is emitted within the same second: the only + // patch is the eventFlag entry still dripping out from the load + expect(emittedKeys()).toStrictEqual([['eventFlag']]); + }); + + test('a tick crossing the second boundary emits timer and clock together', () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + runtimeService.start(); + sendSpy.mockClear(); + + // 32 interval ticks: the seconds boundary is crossed at the 1024ms tick + vi.advanceTimersByTime(1024); + + // first tick flushes the dripping eventFlag; the boundary tick emits + // timer and clock; offset is not included since it did not change + expect(emittedKeys()).toStrictEqual([['eventFlag'], ['clock', 'timer']]); + }); + + test('addTime emits timer, clock and offset immediately', () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + runtimeService.start(); + sendSpy.mockClear(); + vi.mocked(restoreService.save).mockClear(); + + runtimeService.addTime(60_000); + + expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'offset', 'timer']]); + expect(restoreService.save).toHaveBeenCalledTimes(1); + }); + + test('setOffsetMode emits the offset mid-second', () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + runtimeService.start(); + sendSpy.mockClear(); + vi.mocked(restoreService.save).mockClear(); + + runtimeService.setOffsetMode(OffsetMode.Relative); + + expect(emittedKeys()).toStrictEqual([['eventFlag', 'offset']]); + // the mode change counts as an immediate change and saves a restore point + expect(restoreService.save).toHaveBeenCalledTimes(1); + }); + + test('pause emits timer and clock; stop emits the clearing state', () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + runtimeService.start(); + sendSpy.mockClear(); + + runtimeService.pause(); + expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'timer']]); + + sendSpy.mockClear(); + runtimeService.stop(); + // eventNow clears to null; the other cleared entries are again held + // back by the entry short-circuit + expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'offset', 'rundown', 'timer']]); + }); + + test('integration automations are throttled to the seconds rollover', async () => { + runtimeService.loadById('flat1'); + vi.setSystemTime('jan 5 10:00'); + runtimeService.start(); + await flushNextTicks(); + vi.mocked(triggerAutomations).mockClear(); + + // the first tick initialises the integration trackers and fires both cycles + vi.advanceTimersByTime(32); + await flushNextTicks(); + expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([ + [TimerLifeCycle.onUpdate], + [TimerLifeCycle.onClock], + ]); + vi.mocked(triggerAutomations).mockClear(); + + // ticks within the same second do not trigger integrations + vi.advanceTimersByTime(5 * 32); + await flushNextTicks(); + expect(triggerAutomations).not.toHaveBeenCalled(); + + // crossing into the next second triggers onUpdate and onClock once + vi.advanceTimersByTime(832); + await flushNextTicks(); + expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([ + [TimerLifeCycle.onUpdate], + [TimerLifeCycle.onClock], + ]); + }); +}); diff --git a/apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts b/apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts new file mode 100644 index 000000000..16188b768 --- /dev/null +++ b/apps/server/src/services/runtime-service/__tests__/runtime.utils.test.ts @@ -0,0 +1,144 @@ +/** + * Characterisation tests for the change-detection predicates that gate + * what gets broadcast to clients on every tick + */ +import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types'; + +import { getShouldClockUpdate, getShouldOffsetUpdate, getShouldTimerUpdate, isNewSecond } from '../runtime.utils.js'; + +const baseTimer: TimerState = { + addedTime: 0, + current: 10_000, + duration: 60_000, + elapsed: 50_000, + expectedFinish: 100_000, + phase: TimerPhase.Default, + playback: Playback.Play, + secondaryTimer: null, + startedAt: 1000, +}; + +function makeTimer(patch: Partial): TimerState { + return { ...baseTimer, ...patch }; +} + +const baseOffset: Offset = { + absolute: 0, + relative: 0, + mode: OffsetMode.Absolute, + expectedGroupEnd: null, + expectedRundownEnd: null, + expectedFlagStart: null, +}; + +describe('isNewSecond()', () => { + describe('count down (default): seconds are rounded up', () => { + test.each([ + // [previous, current, expected] + [1500, 1200, false], // both round to 2 + [1500, 1000, true], // 2 -> 1 + [1000, 999, false], // both round to 1 + [999, 1, false], // both round to 1 + [1, 0, true], // 1 -> 0 + [0, -999, false], // both round to 0 + [-1, -1000, true], // 0 -> -1 + ])('%s -> %s is a new second: %s', (previous, current, expected) => { + expect(isNewSecond(previous, current)).toBe(expected); + }); + + test('null and undefined values are coerced to second 0', () => { + expect(isNewSecond(undefined, 0)).toBe(false); + expect(isNewSecond(null, 0)).toBe(false); + expect(isNewSecond(undefined, 1000)).toBe(true); + expect(isNewSecond(0, null)).toBe(false); + }); + }); + + describe('count up: seconds are rounded down', () => { + test.each([ + [999, 1, false], // both round to 0 + [999, 1000, true], // 0 -> 1 + [1000, 1999, false], // both round to 1 + ])('%s -> %s is a new second: %s', (previous, current, expected) => { + expect(isNewSecond(previous, current, TimerType.CountUp)).toBe(expected); + }); + }); +}); + +describe('getShouldClockUpdate()', () => { + test('updates when the clock rolls into a new second', () => { + expect(getShouldClockUpdate(500, 999)).toBe(false); + expect(getShouldClockUpdate(999, 1000)).toBe(true); + expect(getShouldClockUpdate(1000, 1032)).toBe(false); + }); + + test('updates when the clock wraps around midnight', () => { + expect(getShouldClockUpdate(86_399_999, 0)).toBe(true); + }); +}); + +describe('getShouldTimerUpdate()', () => { + test('always updates when there is no previous state', () => { + expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true); + }); + + test('does not update when nothing changed', () => { + expect(getShouldTimerUpdate(baseTimer, makeTimer({}))).toBe(false); + }); + + test('current triggers only on a new second', () => { + // 10_000 and 9_001 both round up to second 10 + expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9001 }))).toBe(false); + expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9000 }))).toBe(true); + }); + + test('secondaryTimer triggers only on a new second', () => { + const previous = makeTimer({ secondaryTimer: 5000 }); + expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4001 }))).toBe(false); + expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4000 }))).toBe(true); + }); + + test.each([ + ['addedTime', { addedTime: 1000 }], + ['duration', { duration: 61_000 }], + ['phase', { phase: TimerPhase.Warning }], + ['playback', { playback: Playback.Pause }], + ['startedAt', { startedAt: 2000 }], + ] as const)('%s triggers on any change', (_field, patch) => { + expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(true); + }); + + test.each([ + ['elapsed', { elapsed: 50_500 }], + ['expectedFinish', { expectedFinish: 100_500 }], + ] as const)('%s is deliberately not checked', (_field, patch) => { + expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(false); + }); +}); + +describe('getShouldOffsetUpdate()', () => { + test('always updates when there is no previous state', () => { + expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true); + }); + + test('mode change triggers regardless of dependencies', () => { + const current = { ...baseOffset, mode: OffsetMode.Relative }; + expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(true); + }); + + test('value changes are gated by the dependency update', () => { + const current = { ...baseOffset, absolute: 1000 }; + // a changed offset without a timer/clock update is not emitted + expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(false); + expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true); + }); + + test('does not update when deep-equal, even with a dependency update', () => { + expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false); + }); + + test('expected times participate in the deep comparison', () => { + const current = { ...baseOffset, expectedRundownEnd: 1000 }; + expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true); + }); +}); diff --git a/apps/server/src/stores/__tests__/__snapshots__/runtimeState.characterisation.test.ts.snap b/apps/server/src/stores/__tests__/__snapshots__/runtimeState.characterisation.test.ts.snap new file mode 100644 index 000000000..9c3a9aa6c --- /dev/null +++ b/apps/server/src/stores/__tests__/__snapshots__/runtimeState.characterisation.test.ts.snap @@ -0,0 +1,313 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`characterisation: playback across midnight > playing across midnight increments currentDay and keeps offsets 1`] = ` +{ + "_end": { + "accumulatedGap": 0, + "event": { + "dayOffset": 1, + "delay": 0, + "duration": 1800000, + "gap": 0, + "id": "night3", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 3600000, + "timeStart": 1800000, + "type": "event", + }, + "isLinkedToLoaded": undefined, + }, + "_flag": null, + "_group": null, + "_rundown": { + "totalDelay": 0, + }, + "_startDayOffset": 0, + "_startEpoch": 978734100000, + "_timer": { + "forceFinish": null, + "hasFinished": false, + "pausedAt": null, + "secondaryTarget": null, + }, + "clock": 600000, + "eventFlag": null, + "eventNext": { + "dayOffset": 1, + "delay": 0, + "duration": 1800000, + "gap": 0, + "id": "night3", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 3600000, + "timeStart": 1800000, + "type": "event", + }, + "eventNow": { + "dayOffset": 0, + "delay": 0, + "duration": 3600000, + "gap": 0, + "id": "night2", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 1800000, + "timeStart": 84600000, + "type": "event", + }, + "groupNow": null, + "offset": { + "absolute": 300000, + "expectedFlagStart": null, + "expectedGroupEnd": null, + "expectedRundownEnd": 3900000, + "mode": "absolute", + "relative": -5400000, + }, + "rundown": { + "actualGroupStart": null, + "actualStart": 84900000, + "currentDay": 1, + "numEvents": 3, + "plannedEnd": 90000000, + "plannedStart": 79200000, + "selectedEventIndex": 1, + }, + "timer": { + "addedTime": 0, + "current": 1500000, + "duration": 3600000, + "elapsed": 2100000, + "expectedFinish": 2100000, + "phase": "default", + "playback": "play", + "secondaryTimer": null, + "startedAt": 84900000, + }, +} +`; + +exports[`characterisation: roll mode > roll into overnight event after midnight backdates start metadata 1`] = ` +{ + "_end": { + "accumulatedGap": 0, + "event": { + "dayOffset": 1, + "delay": 0, + "duration": 1800000, + "gap": 0, + "id": "night3", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 3600000, + "timeStart": 1800000, + "type": "event", + }, + "isLinkedToLoaded": undefined, + }, + "_flag": null, + "_group": null, + "_rundown": { + "totalDelay": 0, + }, + "_startDayOffset": 0, + "_startEpoch": 978733800000, + "_timer": { + "forceFinish": null, + "hasFinished": false, + "pausedAt": null, + "secondaryTarget": null, + }, + "clock": 300032, + "eventFlag": null, + "eventNext": { + "dayOffset": 1, + "delay": 0, + "duration": 1800000, + "gap": 0, + "id": "night3", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 3600000, + "timeStart": 1800000, + "type": "event", + }, + "eventNow": { + "dayOffset": 0, + "delay": 0, + "duration": 3600000, + "gap": 0, + "id": "night2", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 1800000, + "timeStart": 84600000, + "type": "event", + }, + "groupNow": null, + "offset": { + "absolute": 0, + "expectedFlagStart": null, + "expectedGroupEnd": null, + "expectedRundownEnd": 3600000, + "mode": "absolute", + "relative": -5400000, + }, + "rundown": { + "actualGroupStart": null, + "actualStart": 84600000, + "currentDay": 1, + "numEvents": 3, + "plannedEnd": 90000000, + "plannedStart": 79200000, + "selectedEventIndex": 1, + }, + "timer": { + "addedTime": 0, + "current": 1499968, + "duration": 3600000, + "elapsed": 2100032, + "expectedFinish": 1800000, + "phase": "default", + "playback": "roll", + "secondaryTimer": null, + "startedAt": 84600000, + }, +} +`; + +exports[`characterisation: timed playback > late start: absolute and relative offsets with gap compensation 1`] = ` +{ + "_end": { + "accumulatedGap": 1200000, + "event": { + "dayOffset": 0, + "delay": 0, + "duration": 600000, + "gap": 600000, + "id": "flat4", + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 39600000, + "timeStart": 39000000, + "type": "event", + }, + "isLinkedToLoaded": undefined, + }, + "_flag": { + "accumulatedGap": 0, + "event": { + "dayOffset": 0, + "delay": 0, + "duration": 600000, + "flag": true, + "gap": 0, + "id": "flat2", + "linkStart": true, + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 37200000, + "timeStart": 36600000, + "type": "event", + }, + "isLinkedToLoaded": true, + }, + "_group": null, + "_rundown": { + "totalDelay": 0, + }, + "_startDayOffset": 0, + "_startEpoch": 978685500000, + "_timer": { + "forceFinish": null, + "hasFinished": false, + "pausedAt": null, + "secondaryTarget": null, + }, + "clock": 36300000, + "eventFlag": { + "dayOffset": 0, + "delay": 0, + "duration": 600000, + "flag": true, + "gap": 0, + "id": "flat2", + "linkStart": true, + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 37200000, + "timeStart": 36600000, + "type": "event", + }, + "eventNext": { + "dayOffset": 0, + "delay": 0, + "duration": 600000, + "flag": true, + "gap": 0, + "id": "flat2", + "linkStart": true, + "parent": null, + "revision": 1, + "skip": false, + "timeEnd": 37200000, + "timeStart": 36600000, + "type": "event", + }, + "eventNow": { + "dayOffset": 0, + "delay": 0, + "duration": 600000, + "gap": 0, + "id": "flat1", + "parent": null, + "revision": 1, + "skip": false, + "timeDanger": 60000, + "timeEnd": 36600000, + "timeStart": 36000000, + "timeWarning": 120000, + "type": "event", + }, + "groupNow": null, + "offset": { + "absolute": 300000, + "expectedFlagStart": 36900000, + "expectedGroupEnd": null, + "expectedRundownEnd": 39900000, + "mode": "relative", + "relative": 0, + }, + "rundown": { + "actualGroupStart": null, + "actualStart": 36300000, + "currentDay": 0, + "numEvents": 4, + "plannedEnd": 39600000, + "plannedStart": 36000000, + "selectedEventIndex": 0, + }, + "timer": { + "addedTime": 0, + "current": 600000, + "duration": 600000, + "elapsed": 0, + "expectedFinish": 36900000, + "phase": "default", + "playback": "play", + "secondaryTimer": null, + "startedAt": 36300000, + }, +} +`; diff --git a/apps/server/src/stores/__tests__/harness/scenario.fixtures.ts b/apps/server/src/stores/__tests__/harness/scenario.fixtures.ts new file mode 100644 index 000000000..b118ca8ce --- /dev/null +++ b/apps/server/src/stores/__tests__/harness/scenario.fixtures.ts @@ -0,0 +1,191 @@ +import { Rundown } from 'ontime-types'; +import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; + +import { + makeOntimeDelay, + makeOntimeEvent, + makeOntimeGroup, + makeRundown, +} from '../../../api-data/rundown/__mocks__/rundown.mocks.js'; + +const h = MILLIS_PER_HOUR; +const m = MILLIS_PER_MINUTE; + +export const time = { h, m }; + +/** + * Flat rundown exercising gaps, linked events and flags + * - flat1: 10:00 - 10:10 + * - flat2: 10:10 - 10:20 (linked to flat1, flagged) + * - flat3: 10:30 - 10:40 (10min gap, flagged) + * - flat4: 10:50 - 11:00 (10min gap) + * + * total gap from flat1: 20min, planned 10:00 - 11:00 + */ +export function makeFlatRundown(): Rundown { + return makeRundown({ + entries: { + flat1: makeOntimeEvent({ + id: 'flat1', + timeStart: 10 * h, + timeEnd: 10 * h + 10 * m, + duration: 10 * m, + timeWarning: 2 * m, + timeDanger: 1 * m, + parent: null, + }), + flat2: makeOntimeEvent({ + id: 'flat2', + timeStart: 10 * h + 10 * m, + timeEnd: 10 * h + 20 * m, + duration: 10 * m, + linkStart: true, + flag: true, + parent: null, + }), + flat3: makeOntimeEvent({ + id: 'flat3', + timeStart: 10 * h + 30 * m, + timeEnd: 10 * h + 40 * m, + duration: 10 * m, + flag: true, + parent: null, + }), + flat4: makeOntimeEvent({ + id: 'flat4', + timeStart: 10 * h + 50 * m, + timeEnd: 11 * h, + duration: 10 * m, + parent: null, + }), + }, + order: ['flat1', 'flat2', 'flat3', 'flat4'], + }); +} + +/** + * Rundown with a group and a trailing event + * - group: [grouped1: 10:00 - 10:30, grouped2: 10:30 - 11:00] + * - after: 11:30 - 12:00 (30min gap) + */ +export function makeGroupedRundown(): Rundown { + return makeRundown({ + entries: { + group: makeOntimeGroup({ id: 'group', entries: ['grouped1', 'grouped2'] }), + grouped1: makeOntimeEvent({ + id: 'grouped1', + timeStart: 10 * h, + timeEnd: 10 * h + 30 * m, + duration: 30 * m, + parent: 'group', + }), + grouped2: makeOntimeEvent({ + id: 'grouped2', + timeStart: 10 * h + 30 * m, + timeEnd: 11 * h, + duration: 30 * m, + parent: 'group', + }), + after: makeOntimeEvent({ + id: 'after', + timeStart: 11 * h + 30 * m, + timeEnd: 12 * h, + duration: 30 * m, + parent: null, + }), + }, + order: ['group', 'after'], + }); +} + +/** + * Overnight rundown + * - night1: 22:00 - 23:30 + * - night2: 23:30 - 00:30 (crosses midnight) + * - night3: 00:30 - 01:00 (next day) + */ +export function makeOvernightRundown(): Rundown { + return makeRundown({ + entries: { + night1: makeOntimeEvent({ + id: 'night1', + timeStart: 22 * h, + timeEnd: 23 * h + 30 * m, + duration: 90 * m, + parent: null, + }), + night2: makeOntimeEvent({ + id: 'night2', + timeStart: 23 * h + 30 * m, + timeEnd: 30 * m, + duration: 60 * m, + parent: null, + }), + night3: makeOntimeEvent({ + id: 'night3', + timeStart: 30 * m, + timeEnd: 1 * h, + duration: 30 * m, + parent: null, + }), + }, + order: ['night1', 'night2', 'night3'], + }); +} + +/** + * Rundown with a count-to-end event + * - lead: 10:00 - 10:30 + * - toEnd: 10:30 - 11:00 (countToEnd) + */ +export function makeCountToEndRundown(): Rundown { + return makeRundown({ + entries: { + lead: makeOntimeEvent({ + id: 'lead', + timeStart: 10 * h, + timeEnd: 10 * h + 30 * m, + duration: 30 * m, + parent: null, + }), + toEnd: makeOntimeEvent({ + id: 'toEnd', + timeStart: 10 * h + 30 * m, + timeEnd: 11 * h, + duration: 30 * m, + countToEnd: true, + parent: null, + }), + }, + order: ['lead', 'toEnd'], + }); +} + +/** + * Rundown with a delay entry + * - delayed1: 10:00 - 10:10 + * - (delay 5min) + * - delayed2: 10:20 - 10:30 (10min gap) + */ +export function makeDelayedRundown(): Rundown { + return makeRundown({ + entries: { + delayed1: makeOntimeEvent({ + id: 'delayed1', + timeStart: 10 * h, + timeEnd: 10 * h + 10 * m, + duration: 10 * m, + parent: null, + }), + delay: makeOntimeDelay({ id: 'delay', duration: 5 * m, parent: null }), + delayed2: makeOntimeEvent({ + id: 'delayed2', + timeStart: 10 * h + 20 * m, + timeEnd: 10 * h + 30 * m, + duration: 10 * m, + parent: null, + }), + }, + order: ['delayed1', 'delay', 'delayed2'], + }); +} diff --git a/apps/server/src/stores/__tests__/harness/scenario.utils.ts b/apps/server/src/stores/__tests__/harness/scenario.utils.ts new file mode 100644 index 000000000..2f3038c92 --- /dev/null +++ b/apps/server/src/stores/__tests__/harness/scenario.utils.ts @@ -0,0 +1,157 @@ +import { EntryId, MaybeString, OffsetMode, PlayableEvent, Rundown } from 'ontime-types'; +import { vi } from 'vitest'; + +import { rundownCache } from '../../../api-data/rundown/rundown.dao.js'; +import { initRundown } from '../../../api-data/rundown/rundown.service.js'; +import { RundownMetadata } from '../../../api-data/rundown/rundown.types.js'; +import { timerConfig } from '../../../setup/config.js'; +import { + type RuntimeState, + type UpdateResult, + addTime, + clearState, + getState, + load, + pause, + roll, + setOffsetMode, + start, + stop, + update, + updateRundownData, +} from '../../runtimeState.js'; + +/** + * A compact, review-friendly projection of the runtime state + * This is the primary characterisation contract: it contains every field + * whose semantics are consumed by clients (offsets, expected times, playback) + * plus the private fields that drive those calculations + */ +export function digest() { + const state = getState(); + return { + clock: state.clock, + playback: state.timer.playback, + phase: state.timer.phase, + current: state.timer.current, + duration: state.timer.duration, + addedTime: state.timer.addedTime, + startedAt: state.timer.startedAt, + secondaryTimer: state.timer.secondaryTimer, + expectedFinish: state.timer.expectedFinish, + absolute: state.offset.absolute, + relative: state.offset.relative, + mode: state.offset.mode, + expectedGroupEnd: state.offset.expectedGroupEnd, + expectedFlagStart: state.offset.expectedFlagStart, + expectedRundownEnd: state.offset.expectedRundownEnd, + plannedStart: state.rundown.plannedStart, + plannedEnd: state.rundown.plannedEnd, + actualStart: state.rundown.actualStart, + actualGroupStart: state.rundown.actualGroupStart, + currentDay: state.rundown.currentDay, + selectedEventIndex: state.rundown.selectedEventIndex, + eventNow: state.eventNow?.id ?? null, + eventNext: state.eventNext?.id ?? null, + eventFlag: state.eventFlag?.id ?? null, + groupNow: state.groupNow?.id ?? null, + pausedAt: state._timer.pausedAt, + secondaryTarget: state._timer.secondaryTarget, + hasFinished: state._timer.hasFinished, + startDayOffset: state._startDayOffset, + }; +} + +export type Scenario = { + /** the processed rundown, after going through the rundown cache */ + rundown: Rundown; + metadata: RundownMetadata; + load: (eventId: EntryId) => boolean; + start: () => boolean; + pause: () => boolean; + stop: () => boolean; + addTime: (amount: number) => boolean; + /** calls roll, optionally passing the current offset for roll-continuation */ + roll: (keepOffset?: boolean) => { eventId: MaybeString; didStart: boolean }; + setOffsetMode: (mode: OffsetMode) => void; + updateRundownData: (data: Parameters[0]) => void; + /** + * advances fake time and calls update() in chunks, exactly as EventTimer would + * @param ms total time to advance + * @param stepMs chunk size, defaults to the production update rate (32ms) + * @returns the result of every update call, so tests can assert on finish flags + */ + tick: (ms?: number, stepMs?: number) => UpdateResult[]; + /** jumps the wall clock to an absolute time and runs a single update */ + setTime: (time: string) => UpdateResult; + digest: () => ReturnType; + state: () => Readonly; +}; + +/** + * Creates a test scenario around the real runtimeState singleton + * - initialises the rundown through the production rundown service + * - resets playback state and offset mode between scenarios + * + * Requires vi.useFakeTimers() to be active + */ +export async function createScenario(initialRundown: Rundown, startTime: string): Promise { + vi.setSystemTime(startTime); + clearState(); + // clearState intentionally preserves offset mode, reset it for test isolation + setOffsetMode(OffsetMode.Absolute); + + await initRundown(initialRundown, {}); + // flush the debounced rundown processing + vi.runAllTimers(); + + const { rundown, metadata } = rundownCache.get(); + + const getPlayableEvent = (eventId: EntryId): PlayableEvent => { + const event = rundown.entries[eventId]; + if (event === undefined) { + throw new Error(`Test scenario: event ${eventId} not found in rundown`); + } + return event as PlayableEvent; + }; + + return { + rundown, + metadata, + load: (eventId) => load(getPlayableEvent(eventId), rundown, metadata), + start, + pause, + stop, + addTime, + roll: (keepOffset) => roll(rundown, metadata, keepOffset ? getState().offset : undefined), + setOffsetMode, + updateRundownData, + tick: (ms = timerConfig.updateRate, stepMs = timerConfig.updateRate) => { + const results: UpdateResult[] = []; + let remaining = ms; + while (remaining > 0) { + const step = Math.min(stepMs, remaining); + vi.advanceTimersByTime(step); + results.push(update()); + remaining -= step; + } + return results; + }, + setTime: (time) => { + vi.setSystemTime(time); + return update(); + }, + digest, + state: getState, + }; +} + +/** + * Utility to assert on the update results collected during a tick + */ +export function countFinishes(results: UpdateResult[]) { + return { + timerFinished: results.filter((result) => result.hasTimerFinished).length, + secondaryFinished: results.filter((result) => result.hasSecondaryTimerFinished).length, + }; +} diff --git a/apps/server/src/stores/__tests__/runtimeState.characterisation.test.ts b/apps/server/src/stores/__tests__/runtimeState.characterisation.test.ts new file mode 100644 index 000000000..9fa093240 --- /dev/null +++ b/apps/server/src/stores/__tests__/runtimeState.characterisation.test.ts @@ -0,0 +1,603 @@ +/** + * Characterisation tests for the runtimeState machinery + * + * These tests lock down the CURRENT behaviour of the runtime state hot path: + * playback transitions, tick updates, absolute and relative offsets, + * expected times (rundown end, group end, flag start) and roll mode. + * + * They drive the real runtimeState singleton through scripted scenarios + * with fake timers, exactly as the EventTimer would in production. + * + * !!! These tests are a behavioural baseline for refactors: if one of these + * assertions fails, production behaviour has changed !!! + */ +import { OffsetMode, Playback, TimerPhase } from 'ontime-types'; +import { dayInMs } from 'ontime-utils'; + +import { + makeCountToEndRundown, + makeDelayedRundown, + makeFlatRundown, + makeGroupedRundown, + makeOvernightRundown, + time, +} from './harness/scenario.fixtures.js'; +import { countFinishes, createScenario } from './harness/scenario.utils.js'; + +const { h, m } = time; + +vi.mock('../../classes/data-provider/DataProvider.js', () => { + return { + getDataProvider: vi.fn().mockImplementation(() => { + return { + setCustomFields: vi.fn().mockImplementation((newData) => newData), + setRundown: vi.fn().mockImplementation((newData) => newData), + }; + }), + }; +}); + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('characterisation: loading and rundown data', () => { + test('initialising a rundown populates planned times', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:00'); + + expect(scenario.digest()).toMatchObject({ + playback: Playback.Stop, + plannedStart: 10 * h, + plannedEnd: 11 * h, + absolute: 0, + relative: 0, + expectedRundownEnd: null, + expectedFlagStart: null, + expectedGroupEnd: null, + }); + }); + + test('loading an event arms the timer and resolves now/next/flag', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59'); + scenario.load('flat1'); + + expect(scenario.digest()).toMatchObject({ + playback: Playback.Armed, + eventNow: 'flat1', + eventNext: 'flat2', + // the loaded event cannot be the flag, so the first flag after flat1 is flat2 + eventFlag: 'flat2', + groupNow: null, + current: 10 * m, + duration: 10 * m, + selectedEventIndex: 0, + actualStart: null, + currentDay: null, + }); + }); + + test('setOffsetMode while stopped only changes the mode', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59'); + scenario.setOffsetMode(OffsetMode.Relative); + + expect(scenario.digest()).toMatchObject({ + mode: OffsetMode.Relative, + expectedRundownEnd: null, + expectedFlagStart: null, + }); + }); +}); + +describe('characterisation: timed playback', () => { + test('on-time start ticks through phases and finishes exactly once', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59'); + scenario.load('flat1'); + + scenario.setTime('jan 5 10:00'); + expect(scenario.start()).toBe(true); + + expect(scenario.digest()).toMatchObject({ + playback: Playback.Play, + phase: TimerPhase.Default, + startedAt: 10 * h, + actualStart: 10 * h, + currentDay: 0, + startDayOffset: 0, + current: 10 * m, + expectedFinish: 10 * h + 10 * m, + absolute: 0, + relative: 0, + // flag (flat2) is linked to the loaded event: it follows the offset directly + expectedFlagStart: 10 * h + 10 * m, + // rundown ends on schedule + expectedRundownEnd: 11 * h, + expectedGroupEnd: null, + }); + + // tick to the warning threshold (2min) + let results = scenario.tick(8 * m, 1000); + expect(countFinishes(results).timerFinished).toBe(0); + expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Warning, current: 2 * m }); + + // tick to the danger threshold (1min) + results = scenario.tick(1 * m, 1000); + expect(countFinishes(results).timerFinished).toBe(0); + expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Danger, current: 1 * m }); + + // tick to the end: the finish flag is raised exactly once, when current <= triggerAhead + results = scenario.tick(1 * m, 1000); + expect(countFinishes(results).timerFinished).toBe(1); + expect(scenario.digest()).toMatchObject({ current: 0, hasFinished: true }); + + // continuing into overtime does not re-trigger the finish + results = scenario.tick(30 * 1000, 1000); + expect(countFinishes(results).timerFinished).toBe(0); + expect(scenario.digest()).toMatchObject({ + phase: TimerPhase.Overtime, + current: -30 * 1000, + // overtime pushes the rundown behind schedule + absolute: 30 * 1000, + relative: 30 * 1000, + }); + }); + + test('late start: absolute and relative offsets with gap compensation', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59'); + scenario.load('flat1'); + + scenario.setTime('jan 5 10:05'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + startedAt: 10 * h + 5 * m, + actualStart: 10 * h + 5 * m, + // positive offset: we are 5min behind schedule + absolute: 5 * m, + // relative to our actual start, we are on time + relative: 0, + // flat2 is linked to the loaded event, so it follows the offset: 10:10 + 5min + expectedFlagStart: 10 * h + 15 * m, + // the accumulated gap (20min) is larger than the offset (5min): rundown still ends on schedule + expectedRundownEnd: 11 * h, + }); + + // switching to relative mode re-anchors the schedule to the actual start + scenario.setOffsetMode(OffsetMode.Relative); + expect(scenario.digest()).toMatchObject({ + mode: OffsetMode.Relative, + absolute: 5 * m, + relative: 0, + // linked flag: same wall time as in absolute mode + expectedFlagStart: 10 * h + 15 * m, + // in relative mode the whole schedule shifts by the late start: gaps are kept, not consumed + expectedRundownEnd: 11 * h + 5 * m, + }); + + // full state checkpoint + expect(scenario.state()).toMatchSnapshot(); + }); + + test('early start in relative mode', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:50'); + scenario.load('flat1'); + + scenario.setTime('jan 5 09:56'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + // negative offset: we are 4min ahead of schedule + absolute: -4 * m, + relative: 0, + // linked flag follows the offset + expectedFlagStart: 10 * h + 6 * m, + // ahead of schedule: unlinked events are still expected at their scheduled time + expectedRundownEnd: 11 * h, + }); + + scenario.setOffsetMode(OffsetMode.Relative); + expect(scenario.digest()).toMatchObject({ + // the schedule shifts 4min earlier + expectedFlagStart: 10 * h + 6 * m, + expectedRundownEnd: 10 * h + 56 * m, + }); + }); + + test('partial gap compensation consumes the gap', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + scenario.load('flat2'); + + // start flat2 15min late (planned 10:10) + scenario.setTime('jan 5 10:25'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + eventNow: 'flat2', + eventNext: 'flat3', + // first flag after the loaded event is flat3 + eventFlag: 'flat3', + absolute: 15 * m, + // relative to the actual start, flat2 began 10min early + // (the rundown started at flat2, skipping flat1's 10min) + relative: -10 * m, + // flag flat3: gap (10min) only partially compensates the offset (15min) + // expected start = scheduled (10:30) + offset (15min) - gap (10min) = 10:35 + expectedFlagStart: 10 * h + 35 * m, + // rundown end: gap (20min) fully compensates the offset (15min) + expectedRundownEnd: 11 * h, + }); + }); + + test('addTime adjusts offsets, can force finish and un-finish', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + scenario.load('flat1'); + scenario.start(); + + scenario.tick(1 * m, 1000); + expect(scenario.digest()).toMatchObject({ current: 9 * m }); + + // adding time puts us behind schedule + scenario.addTime(2 * m); + expect(scenario.digest()).toMatchObject({ + addedTime: 2 * m, + current: 11 * m, + absolute: 2 * m, + relative: 2 * m, + expectedFinish: 10 * h + 12 * m, + expectedRundownEnd: 11 * h, + }); + + // removing more time than the timer has forces a finish + scenario.addTime(-12 * m); + expect(scenario.digest()).toMatchObject({ + addedTime: -10 * m, + current: -1 * m, + hasFinished: false, + }); + + // the finish is reported on the next update + let results = scenario.tick(); + expect(countFinishes(results).timerFinished).toBe(1); + expect(scenario.digest()).toMatchObject({ hasFinished: true, phase: TimerPhase.Overtime }); + + // !!! characterised quirk: the forced finish flag (_timer.forceFinish) is never + // cleared by update(), so every subsequent update keeps reporting a finished timer + results = scenario.tick(); + expect(countFinishes(results).timerFinished).toBe(1); + + // adding time back over zero un-finishes the timer + scenario.addTime(5 * m); + expect(scenario.digest()).toMatchObject({ + addedTime: -5 * m, + hasFinished: false, + // net added time: we are now 5min ahead of schedule + absolute: -5 * m, + }); + }); + + test('pause freezes the timer and accumulates paused time into the offset', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + scenario.load('flat1'); + scenario.start(); + + scenario.tick(2 * m, 1000); + expect(scenario.pause()).toBe(true); + expect(scenario.digest()).toMatchObject({ + playback: Playback.Pause, + pausedAt: 10 * h + 2 * m, + current: 8 * m, + }); + + // while paused, the timer freezes but the offset grows with the pause + scenario.setTime('jan 5 10:04'); + expect(scenario.digest()).toMatchObject({ + current: 8 * m, + absolute: 2 * m, + relative: 2 * m, + expectedFinish: 10 * h + 12 * m, + }); + + // restarting folds the paused time into addedTime + expect(scenario.start()).toBe(true); + expect(scenario.digest()).toMatchObject({ + playback: Playback.Play, + pausedAt: null, + addedTime: 2 * m, + current: 8 * m, + absolute: 2 * m, + }); + + // pause then stop clears everything + scenario.pause(); + expect(scenario.stop()).toBe(true); + expect(scenario.digest()).toMatchObject({ + playback: Playback.Stop, + eventNow: null, + current: null, + startedAt: null, + actualStart: null, + absolute: 0, + relative: 0, + expectedRundownEnd: null, + currentDay: null, + }); + }); + + test('countToEnd: absolute offset is overtime only', async () => { + const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:35'); + scenario.load('toEnd'); + + // start the count-to-end event 10min late + scenario.setTime('jan 5 10:40'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + // !!! characterised: start() does not recompute current, the value + // set at load time (11:00 - 10:35) sticks until the next update() + current: 25 * m, + expectedFinish: 11 * h, + // count-to-end absorbs the late start: absolute offset is overtime only + absolute: 0, + // the relative offset still reflects the raw event start offset + relative: -30 * m, + }); + + // the next update recalculates current against the scheduled end + scenario.tick(1000, 1000); + expect(scenario.digest()).toMatchObject({ current: 20 * m - 1000 }); + + // going into overtime + scenario.setTime('jan 5 11:05'); + expect(scenario.digest()).toMatchObject({ + current: -5 * m, + phase: TimerPhase.Overtime, + absolute: 5 * m, + relative: -25 * m, + expectedFinish: 11 * h, + }); + }); + + test('group: expectedGroupEnd and actualGroupStart', async () => { + const scenario = await createScenario(makeGroupedRundown(), 'jan 5 10:00'); + scenario.load('grouped1'); + + scenario.setTime('jan 5 10:05'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + groupNow: 'group', + actualGroupStart: 10 * h + 5 * m, + absolute: 5 * m, + // group end: grouped2 is not linked and has no gap to absorb the 5min offset + // expected start = 10:30 + 5min, group ends 30min later + expectedGroupEnd: 11 * h + 5 * m, + // rundown end: the 30min gap before 'after' absorbs the offset + expectedRundownEnd: 12 * h, + }); + }); + + test('updateRundownData recomputes expected times while playing', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + scenario.load('flat1'); + scenario.setTime('jan 5 10:05'); + scenario.start(); + scenario.setOffsetMode(OffsetMode.Relative); + + expect(scenario.digest()).toMatchObject({ expectedRundownEnd: 11 * h + 5 * m }); + + // a rundown edit moves the planned start earlier + scenario.updateRundownData({ + numEvents: 4, + firstStart: 9 * h + 30 * m, + lastEnd: 10 * h + 30 * m, + totalDelay: 0, + totalDuration: 1 * h, + }); + + expect(scenario.digest()).toMatchObject({ + plannedStart: 9 * h + 30 * m, + plannedEnd: 10 * h + 30 * m, + // in relative mode the expected end shifts with the (actualStart - plannedStart) delta + expectedRundownEnd: 11 * h + 35 * m, + }); + }); + + test('updateRundownData while stopped does not produce expected times', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + + scenario.updateRundownData({ + numEvents: 4, + firstStart: 9 * h + 30 * m, + lastEnd: 10 * h + 30 * m, + totalDelay: 0, + totalDuration: 1 * h, + }); + + expect(scenario.digest()).toMatchObject({ + plannedStart: 9 * h + 30 * m, + plannedEnd: 10 * h + 30 * m, + expectedRundownEnd: null, + absolute: 0, + }); + }); + + test('delay entries push the expected start of unlinked events', async () => { + const scenario = await createScenario(makeDelayedRundown(), 'jan 5 10:00'); + scenario.load('delayed1'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + absolute: 0, + // delayed2 is delayed by 5min: expected start 10:25, ends 10min later + expectedRundownEnd: 10 * h + 35 * m, + }); + }); +}); + +describe('characterisation: playback across midnight', () => { + test('playing across midnight increments currentDay and keeps offsets', async () => { + const scenario = await createScenario(makeOvernightRundown(), 'jan 5 23:30'); + scenario.load('night2'); + + scenario.setTime('jan 5 23:35'); + scenario.start(); + + expect(scenario.digest()).toMatchObject({ + startedAt: 23 * h + 35 * m, + actualStart: 23 * h + 35 * m, + currentDay: 0, + absolute: 5 * m, + // relative offset re-anchors to the rundown start (planned 22:00, actual 23:35) + relative: -90 * m, + // before midnight, next-day events are normalised over 24h: + // night3 expected start = 24:30 + 5min offset, ends 30min later + expectedRundownEnd: 25 * h + 5 * m, + }); + + // cross midnight in 1min steps + scenario.tick(35 * m, 1 * m); + + expect(scenario.digest()).toMatchObject({ + clock: 10 * m, + currentDay: 1, + // started 23:35 with 60min duration: ends 00:35, 25min left at 00:10 + current: 25 * m, + absolute: 5 * m, + relative: -90 * m, + // after midnight the same expected end is expressed in today's time + expectedRundownEnd: 1 * h + 5 * m, + }); + + // full state checkpoint + expect(scenario.state()).toMatchSnapshot(); + }); +}); + +describe('characterisation: roll mode', () => { + test('roll: pending then start on schedule', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:55'); + + const result = scenario.roll(); + expect(result).toStrictEqual({ eventId: 'flat1', didStart: false }); + expect(scenario.digest()).toMatchObject({ + playback: Playback.Roll, + phase: TimerPhase.Pending, + secondaryTimer: 5 * m, + secondaryTarget: 10 * h, + actualStart: null, + currentDay: null, + }); + + // the secondary timer counts down to the event start + const results = scenario.tick(5 * m, 1000); + expect(countFinishes(results).secondaryFinished).toBe(1); + expect(scenario.digest()).toMatchObject({ secondaryTimer: 0 }); + + // the runtime service reacts to the secondary timer finishing by calling roll again + const startResult = scenario.roll(); + expect(startResult).toStrictEqual({ eventId: 'flat1', didStart: true }); + expect(scenario.digest()).toMatchObject({ + playback: Playback.Roll, + startedAt: 10 * h, + actualStart: 10 * h, + currentDay: 0, + absolute: 0, + secondaryTimer: null, + }); + }); + + test('roll: takeover from play keeps the running state', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + scenario.load('flat1'); + scenario.setTime('jan 5 10:05'); + scenario.start(); + + const result = scenario.roll(); + expect(result).toStrictEqual({ eventId: 'flat1', didStart: false }); + expect(scenario.digest()).toMatchObject({ + playback: Playback.Roll, + startedAt: 10 * h + 5 * m, + absolute: 5 * m, + }); + }); + + test('roll: continuation carries the offset to the next event', async () => { + const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00'); + scenario.load('flat1'); + scenario.setTime('jan 5 10:05'); + scenario.start(); + scenario.roll(); + + // flat1 (started 10:05, 10min duration) is finished at 10:16 + // the update pushes flat1 1min into overtime, growing the offset to 6min + scenario.setTime('jan 5 10:16'); + expect(scenario.digest()).toMatchObject({ absolute: 6 * m }); + + // the runtime service rolls into the next event passing the current offset + const result = scenario.roll(true); + // with a 6min offset, the offset-clock is 10:10 which is inside flat2 (10:10-10:20) + expect(result).toStrictEqual({ eventId: 'flat2', didStart: true }); + expect(scenario.digest()).toMatchObject({ + eventNow: 'flat2', + // start times are backdated to the planned start + startedAt: 10 * h + 10 * m, + // the offset passed by the service is kept + absolute: 6 * m, + // !!! characterised: unlike the pending-roll branch, the roll-continuation + // branch overwrites actualStart with the new event's planned start + actualStart: 10 * h + 10 * m, + }); + }); + + test('roll into overnight event after midnight backdates start metadata', async () => { + const scenario = await createScenario(makeOvernightRundown(), 'jan 6 00:05'); + + const result = scenario.roll(); + expect(result).toStrictEqual({ eventId: 'night2', didStart: true }); + + scenario.tick(); + + expect(scenario.digest()).toMatchObject({ + eventNow: 'night2', + // started mid-event: times are backdated to the planned start + startedAt: 23 * h + 30 * m, + actualStart: 23 * h + 30 * m, + startDayOffset: 0, + // the backdated epoch is yesterday: we are on day 1 + currentDay: 1, + absolute: 0, + // night3 is expected on schedule + expectedRundownEnd: 1 * h, + }); + + // full state checkpoint + expect(scenario.state()).toMatchSnapshot(); + }); + + test('roll: pending across midnight renormalises the secondary target', async () => { + const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 23:50'); + + // all events are in the past: roll pends for tomorrow's first event (10:00) + const result = scenario.roll(); + expect(result).toStrictEqual({ eventId: 'lead', didStart: false }); + expect(scenario.digest()).toMatchObject({ + phase: TimerPhase.Pending, + // 10h10min until tomorrow 10:00, target normalised over 24h + secondaryTimer: 10 * h + 10 * m, + secondaryTarget: dayInMs + 10 * h, + currentDay: null, + }); + + // crossing midnight renormalises the target to today's time + scenario.setTime('jan 6 00:10'); + expect(scenario.digest()).toMatchObject({ + secondaryTimer: 9 * h + 50 * m, + secondaryTarget: 10 * h, + currentDay: null, + }); + }); +});