mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 03:43:50 +00:00
test: cover hot reload and add direct calculation tests for runtimeState cores
- characterisation scenarios for the hot-reload path (updateAll and updateLoaded), which was previously untested: editing the running event, removing the running event, and re-arming the loaded event - direct tests for the parameterized cores against plain state objects with no module mocks: the update tick arithmetic and finish trigger window, added-time edge cases, and a documented matrix of the expected-times projection (offset sign, gap absorption, linked events, relative mode, delays and day offsets) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
This commit is contained in:
@@ -18,6 +18,8 @@ import {
|
||||
start,
|
||||
stop,
|
||||
update,
|
||||
updateAll,
|
||||
updateLoaded,
|
||||
updateRundownData,
|
||||
} from '../../runtimeState.js';
|
||||
|
||||
@@ -35,6 +37,7 @@ export function digest() {
|
||||
phase: state.timer.phase,
|
||||
current: state.timer.current,
|
||||
duration: state.timer.duration,
|
||||
elapsed: state.timer.elapsed,
|
||||
addedTime: state.timer.addedTime,
|
||||
startedAt: state.timer.startedAt,
|
||||
secondaryTimer: state.timer.secondaryTimer,
|
||||
@@ -75,6 +78,14 @@ export type Scenario = {
|
||||
roll: (keepOffset?: boolean) => { eventId: MaybeString; didStart: boolean };
|
||||
setOffsetMode: (mode: OffsetMode) => void;
|
||||
updateRundownData: (data: Parameters<typeof updateRundownData>[0]) => void;
|
||||
/**
|
||||
* emulates a rundown edit while playback continues: re-processes the
|
||||
* rundown through the cache and hot-reloads the loaded events, as the
|
||||
* rundown service does on a committed mutation
|
||||
*/
|
||||
hotReload: (newRundown: Rundown) => void;
|
||||
/** re-arms the loaded event, resetting timer progress, as used by reload() */
|
||||
reloadLoaded: () => string | undefined;
|
||||
/**
|
||||
* advances fake time and calls update() in chunks, exactly as EventTimer would
|
||||
* @param ms total time to advance
|
||||
@@ -126,6 +137,12 @@ export async function createScenario(initialRundown: Rundown, startTime: string)
|
||||
roll: (keepOffset) => roll(rundown, metadata, keepOffset ? getState().offset : undefined),
|
||||
setOffsetMode,
|
||||
updateRundownData,
|
||||
hotReload: (newRundown) => {
|
||||
rundownCache.init(newRundown, {});
|
||||
const { rundown: processedRundown, metadata: processedMetadata } = rundownCache.get();
|
||||
updateAll(processedRundown, processedMetadata);
|
||||
},
|
||||
reloadLoaded: () => updateLoaded(),
|
||||
tick: (ms = timerConfig.updateRate, stepMs = timerConfig.updateRate) => {
|
||||
const results: UpdateResult[] = [];
|
||||
let remaining = ms;
|
||||
|
||||
@@ -438,6 +438,91 @@ describe('characterisation: timed playback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('characterisation: hot reload during playback', () => {
|
||||
test('editing the running event recomputes the timer without interrupting playback', async () => {
|
||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
|
||||
scenario.load('lead');
|
||||
scenario.start();
|
||||
scenario.tick(2 * m, 1000);
|
||||
|
||||
// the running event (10:00 - 10:30) is shortened to 10:00 - 10:20
|
||||
const editedRundown = makeCountToEndRundown();
|
||||
// @ts-expect-error -- fixture entries are events
|
||||
editedRundown.entries.lead.timeEnd = 10 * h + 20 * m;
|
||||
// @ts-expect-error -- fixture entries are events
|
||||
editedRundown.entries.lead.duration = 20 * m;
|
||||
scenario.hotReload(editedRundown);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'lead',
|
||||
// playback is not interrupted
|
||||
playback: Playback.Play,
|
||||
startedAt: 10 * h,
|
||||
actualStart: 10 * h,
|
||||
// the timer is recomputed against the new duration
|
||||
duration: 20 * m,
|
||||
current: 18 * m,
|
||||
expectedFinish: 10 * h + 20 * m,
|
||||
// expected times are recomputed: the 10min gap before toEnd absorbs
|
||||
// nothing (offset 0), the rundown still ends on schedule
|
||||
expectedRundownEnd: 11 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('removing the running event slides the selection to the event at the same index', async () => {
|
||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
|
||||
scenario.load('lead');
|
||||
scenario.start();
|
||||
scenario.tick(2 * m, 1000);
|
||||
|
||||
// the running event is deleted from the rundown
|
||||
const editedRundown = makeCountToEndRundown();
|
||||
delete editedRundown.entries.lead;
|
||||
editedRundown.order = ['toEnd'];
|
||||
scenario.hotReload(editedRundown);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
// the event at the previously selected index is loaded in its place
|
||||
eventNow: 'toEnd',
|
||||
eventNext: null,
|
||||
selectedEventIndex: 0,
|
||||
playback: Playback.Play,
|
||||
// !!! characterised: the original start time is kept, the replacement
|
||||
// event plays as if it had started with the removed event
|
||||
startedAt: 10 * h,
|
||||
actualStart: 10 * h,
|
||||
duration: 30 * m,
|
||||
// toEnd counts to its scheduled end: 11:00 - 10:02
|
||||
current: 58 * m,
|
||||
expectedFinish: 11 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('reloading the loaded event re-arms the timer and clears progress', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.start();
|
||||
scenario.tick(2 * m, 1000);
|
||||
scenario.addTime(1 * m);
|
||||
|
||||
const reloadedId = scenario.reloadLoaded();
|
||||
|
||||
expect(reloadedId).toBe('flat1');
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'flat1',
|
||||
playback: Playback.Armed,
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
current: 10 * m,
|
||||
duration: 10 * m,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
hasFinished: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('characterisation: playback across midnight', () => {
|
||||
test('playing across midnight increments currentDay and keeps offsets', async () => {
|
||||
const scenario = await createScenario(makeOvernightRundown(), 'jan 5 23:30');
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Direct tests for the parameterized runtimeState cores
|
||||
*
|
||||
* These run the calculation logic against plain state objects: no module
|
||||
* mocks, no singleton, no rundown cache. They document the arithmetic of
|
||||
* the tick, the added-time edge cases and the expected-times projection.
|
||||
*/
|
||||
import { OffsetMode, OntimeGroup, PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStateData } from '../__mocks__/runtimeState.mocks.js';
|
||||
import { type RuntimeState, addTimeCore, getExpectedTimesCore, updateCore } from '../runtimeState.js';
|
||||
|
||||
const h = MILLIS_PER_HOUR;
|
||||
const m = MILLIS_PER_MINUTE;
|
||||
|
||||
/** a 10min event, planned 10:00 - 10:10 */
|
||||
function makeRunningEvent(): PlayableEvent {
|
||||
return makeOntimeEvent({
|
||||
id: 'running',
|
||||
timeStart: 10 * h,
|
||||
timeEnd: 10 * h + 10 * m,
|
||||
duration: 10 * m,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
gap: 0,
|
||||
countToEnd: false,
|
||||
}) as PlayableEvent;
|
||||
}
|
||||
|
||||
/** state as it would be after starting the running event on time at 10:00 */
|
||||
function makePlayingState(): RuntimeState {
|
||||
return makeRuntimeStateData({
|
||||
eventNow: makeRunningEvent(),
|
||||
timer: {
|
||||
playback: Playback.Play,
|
||||
duration: 10 * m,
|
||||
current: 10 * m,
|
||||
elapsed: 0,
|
||||
startedAt: 10 * h,
|
||||
expectedFinish: 10 * h + 10 * m,
|
||||
},
|
||||
rundown: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
plannedStart: 10 * h,
|
||||
plannedEnd: 10 * h + 10 * m,
|
||||
actualStart: 10 * h,
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_startEpoch: new Date('2026-01-05T10:00:00').getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('updateCore()', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('recomputes the timer against the wall clock', () => {
|
||||
const state = makePlayingState();
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
|
||||
const result = updateCore(state);
|
||||
|
||||
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
|
||||
expect(state.timer.current).toBe(6 * m);
|
||||
expect(state.timer.elapsed).toBe(4 * m);
|
||||
expect(state.timer.expectedFinish).toBe(10 * h + 10 * m);
|
||||
expect(state.offset.absolute).toBe(0);
|
||||
});
|
||||
|
||||
test('the finish triggers once, when current enters the trigger-ahead window (10ms)', () => {
|
||||
const state = makePlayingState();
|
||||
|
||||
// 11ms left on the timer: not finished yet
|
||||
vi.setSystemTime(new Date('2026-01-05T10:09:59.989'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(false);
|
||||
expect(state._timer.hasFinished).toBe(false);
|
||||
|
||||
// 10ms left: the finish is triggered and latched
|
||||
vi.setSystemTime(new Date('2026-01-05T10:09:59.990'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(true);
|
||||
expect(state._timer.hasFinished).toBe(true);
|
||||
|
||||
// subsequent updates do not re-trigger, the timer runs into overtime
|
||||
vi.setSystemTime(new Date('2026-01-05T10:10:30'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(false);
|
||||
expect(state.timer.current).toBe(-30_000);
|
||||
expect(state.timer.phase).toBe(TimerPhase.Overtime);
|
||||
// overtime pushes the rundown behind schedule
|
||||
expect(state.offset.absolute).toBe(30_000);
|
||||
});
|
||||
|
||||
test('a pending forceFinish reports the timer as finished', () => {
|
||||
const state = makePlayingState();
|
||||
state._timer.forceFinish = 10 * h + 2 * m;
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-05T10:02:00'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(true);
|
||||
expect(state._timer.hasFinished).toBe(true);
|
||||
});
|
||||
|
||||
test('does nothing but update the clock when playback is idle', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
|
||||
const result = updateCore(state);
|
||||
|
||||
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
|
||||
expect(state.clock).toBe(10 * h + 4 * m);
|
||||
expect(state.timer.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTimeCore()', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-05T10:05:00'));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('added time moves the offset and the expected finish', () => {
|
||||
const state = makePlayingState();
|
||||
state.timer.current = 5 * m; // 10:05, halfway through
|
||||
|
||||
expect(addTimeCore(state, 2 * m)).toBe(true);
|
||||
|
||||
expect(state.timer.addedTime).toBe(2 * m);
|
||||
expect(state.timer.current).toBe(7 * m);
|
||||
// positive offset: we are now 2min behind schedule
|
||||
expect(state.offset.absolute).toBe(2 * m);
|
||||
expect(state.timer.expectedFinish).toBe(10 * h + 12 * m);
|
||||
});
|
||||
|
||||
test('removing more time than remains forces a finish', () => {
|
||||
const state = makePlayingState();
|
||||
state.timer.current = 5 * m;
|
||||
|
||||
addTimeCore(state, -8 * m);
|
||||
|
||||
// the force flag is raised for the next update to report
|
||||
expect(state._timer.forceFinish).not.toBeNull();
|
||||
expect(state.timer.current).toBe(-3 * m);
|
||||
// 3min overtime minus 8min removed: 5min ahead of schedule
|
||||
expect(state.offset.absolute).toBe(-5 * m);
|
||||
});
|
||||
|
||||
test('adding time back over zero un-finishes the timer', () => {
|
||||
const state = makePlayingState();
|
||||
state.timer.current = -1 * m;
|
||||
state.timer.addedTime = -6 * m;
|
||||
state._timer.hasFinished = true;
|
||||
|
||||
addTimeCore(state, 5 * m);
|
||||
|
||||
expect(state.timer.current).toBe(4 * m);
|
||||
expect(state._timer.hasFinished).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses when there is no timer', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
expect(addTimeCore(state, 1 * m)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedTimesCore()', () => {
|
||||
/** a downstream 10min event, planned 11:00 - 11:10 */
|
||||
function makeDownstreamEvent(patch?: Record<string, unknown>) {
|
||||
return makeOntimeEvent({
|
||||
id: 'downstream',
|
||||
timeStart: 11 * h,
|
||||
timeEnd: 11 * h + 10 * m,
|
||||
duration: 10 * m,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* playing state with a downstream rundown end
|
||||
* @param offset current absolute offset (positive = behind schedule)
|
||||
* @param accumulatedGap total gap between the running and the downstream event
|
||||
* @param isLinkedToLoaded whether the downstream event links back to the running one
|
||||
*/
|
||||
function makeStateWithEnd(args: {
|
||||
offset: number;
|
||||
accumulatedGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
mode?: OffsetMode;
|
||||
actualStart?: number;
|
||||
downstream?: ReturnType<typeof makeOntimeEvent>;
|
||||
}): RuntimeState {
|
||||
const state = makePlayingState();
|
||||
state.offset.absolute = args.offset;
|
||||
state.offset.mode = args.mode ?? OffsetMode.Absolute;
|
||||
if (args.actualStart !== undefined) state.rundown.actualStart = args.actualStart;
|
||||
state._end = {
|
||||
event: args.downstream ?? makeDownstreamEvent(),
|
||||
accumulatedGap: args.accumulatedGap,
|
||||
isLinkedToLoaded: args.isLinkedToLoaded,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
test('on schedule: the rundown ends at the planned time', () => {
|
||||
const state = makeStateWithEnd({ offset: 0, accumulatedGap: 0, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
// 11:00 + 10min duration
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('behind schedule: a larger gap absorbs the whole offset', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
// gap (20min) > offset (5min): the downstream event still starts as scheduled
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('behind schedule: a smaller gap absorbs part of the offset', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 2 * m, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
// expected start 11:00 + 5min offset - 2min gap = 11:03, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 13 * m);
|
||||
});
|
||||
|
||||
test('behind schedule: linked events follow the offset, gaps do not apply', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: true });
|
||||
getExpectedTimesCore(state);
|
||||
// expected start 11:00 + 5min offset = 11:05, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('ahead of schedule: unlinked events wait for their scheduled time', () => {
|
||||
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('ahead of schedule: linked events pull in with the offset', () => {
|
||||
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: true });
|
||||
getExpectedTimesCore(state);
|
||||
// expected start 10:56, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 6 * m);
|
||||
});
|
||||
|
||||
test('relative mode: the schedule is re-anchored to the actual start', () => {
|
||||
// started 5min late (actual 10:05, planned 10:00) with no accrued drift
|
||||
const state = makeStateWithEnd({
|
||||
offset: 0,
|
||||
accumulatedGap: 20 * m,
|
||||
isLinkedToLoaded: false,
|
||||
mode: OffsetMode.Relative,
|
||||
actualStart: 10 * h + 5 * m,
|
||||
});
|
||||
state.offset.relative = 0;
|
||||
getExpectedTimesCore(state);
|
||||
// the whole schedule shifts by the 5min late start: 11:05 + 10min
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('delays push the expected start', () => {
|
||||
const state = makeStateWithEnd({
|
||||
offset: 0,
|
||||
accumulatedGap: 10 * m,
|
||||
isLinkedToLoaded: false,
|
||||
downstream: makeDownstreamEvent({ delay: 5 * m }),
|
||||
});
|
||||
getExpectedTimesCore(state);
|
||||
// delayed start 11:05, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('events on a later day are normalised over 24h', () => {
|
||||
const state = makeStateWithEnd({
|
||||
offset: 0,
|
||||
accumulatedGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
downstream: makeDownstreamEvent({ dayOffset: 1 }),
|
||||
});
|
||||
getExpectedTimesCore(state);
|
||||
// tomorrow 11:00 relative to today: 24h + 11:00, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(dayInMs + 11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('flag start and group end use the same projection', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 0, isLinkedToLoaded: false });
|
||||
const downstream = makeDownstreamEvent() as PlayableEvent;
|
||||
|
||||
state.eventFlag = downstream;
|
||||
state._flag = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
|
||||
state.groupNow = makeOntimeGroup({ id: 'group' }) as OntimeGroup;
|
||||
state._group = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
|
||||
|
||||
getExpectedTimesCore(state);
|
||||
|
||||
// no gap to absorb the 5min offset: expected start 11:05
|
||||
expect(state.offset.expectedFlagStart).toBe(11 * h + 5 * m);
|
||||
// the group ends when its last event finishes: 11:05 + 10min
|
||||
expect(state.offset.expectedGroupEnd).toBe(11 * h + 15 * m);
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('without a loaded event there are no expected times', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
getExpectedTimesCore(state);
|
||||
|
||||
expect(state.offset.expectedRundownEnd).toBeNull();
|
||||
expect(state.offset.expectedFlagStart).toBeNull();
|
||||
expect(state.offset.expectedGroupEnd).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user