test: add characterisation harness for the runtimeState machinery

Locks down the current behaviour of the runtime state hot path before
any refactoring work:

- scenario driver and fixtures that script playback sequences against
  the real runtimeState singleton with fake timers, mirroring the
  EventTimer tick loop
- characterisation catalogue covering absolute/relative offsets,
  expected group/flag/rundown times, gap compensation, delays,
  countToEnd, addTime edge cases, pause accounting, roll modes and
  playback across midnight
- broadcast-contract tests locking which top-level store keys are
  emitted per action and tick, including the per-second gating and
  integration throttling
- unit tests for the previously untested change-detection predicates
  in runtime.utils.ts

The tests characterise current behaviour, including quirks (documented
inline): the entry diffing short-circuit that drips changed entries one
per broadcast, the sticky forceFinish flag after negative addTime, and
roll-continuation overwriting actualStart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
This commit is contained in:
Claude
2026-07-03 17:05:30 +00:00
parent 42147d4d5b
commit 80cb1f96e7
6 changed files with 1659 additions and 0 deletions
@@ -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<string, unknown>[] {
return sendSpy.mock.calls
.filter(([tag]) => tag === MessageTag.RuntimeData)
.map(([, payload]) => payload as Record<string, unknown>);
}
/** 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],
]);
});
});
@@ -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>): 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);
});
});
@@ -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,
},
}
`;
@@ -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'],
});
}
@@ -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<typeof updateRundownData>[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<typeof digest>;
state: () => Readonly<RuntimeState>;
};
/**
* 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<Scenario> {
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,
};
}
@@ -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,
});
});
});