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,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,
});
});
});