mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 20:33:47 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe96941737 | |||
| ee9ac492c3 | |||
| 4a0a174eb9 | |||
| 56257adcbd | |||
| b10c38514f | |||
| 80cb1f96e7 |
@@ -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,247 @@
|
||||
/**
|
||||
* Characterisation tests for the change-detection predicates that gate
|
||||
* what gets broadcast to clients on every tick
|
||||
*/
|
||||
import { Offset, OffsetMode, PlayableEvent, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
|
||||
import { makeOntimeEvent } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import type { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import {
|
||||
collectRuntimeStateChanges,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRuntimeStateChanges()', () => {
|
||||
test('the very first run emits everything and counts as an immediate change', () => {
|
||||
const previousState = {} as RuntimeState;
|
||||
const state = makeRuntimeStateData();
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'offset', 'rundown', 'timer']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
// the emitted keys are persisted into the previous state for the next diff
|
||||
expect(previousState.timer).toStrictEqual(state.timer);
|
||||
expect(previousState.offset).toStrictEqual(state.offset);
|
||||
});
|
||||
|
||||
test('an unchanged state emits nothing', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData();
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
|
||||
expect(patch).toStrictEqual({});
|
||||
expect(hasImmediateChanges).toBe(false);
|
||||
});
|
||||
|
||||
test('a playback change emits timer and clock together', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ timer: { playback: Playback.Armed } });
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
});
|
||||
|
||||
test('an offset change is gated on a timer/clock dependency', () => {
|
||||
// nothing else changed: the offset is held back
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ offset: { absolute: 1000 } });
|
||||
|
||||
const gated = collectRuntimeStateChanges(previousState, state);
|
||||
expect(gated.patch).toStrictEqual({});
|
||||
|
||||
// with a clock rollover, the offset rides along
|
||||
const stateWithClock = makeRuntimeStateData({ clock: 1000, offset: { absolute: 1000 } });
|
||||
const emitted = collectRuntimeStateChanges(previousState, stateWithClock);
|
||||
expect(Object.keys(emitted.patch).sort()).toStrictEqual(['clock', 'offset']);
|
||||
});
|
||||
|
||||
test('!!! characterised bug: changed entries drip out one per call', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const eventNow = makeOntimeEvent({ id: 'now' }) as PlayableEvent;
|
||||
const eventNext = makeOntimeEvent({ id: 'next' }) as PlayableEvent;
|
||||
const state = makeRuntimeStateData({ eventNow, eventNext });
|
||||
|
||||
// both entries changed, but the ||= short-circuit only diffs the first
|
||||
const first = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(first.patch).sort()).toStrictEqual(['eventNow']);
|
||||
|
||||
// the second call flushes the next pending entry
|
||||
const second = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(second.patch).sort()).toStrictEqual(['eventNext']);
|
||||
|
||||
// from here on, nothing is pending
|
||||
const third = collectRuntimeStateChanges(previousState, state);
|
||||
expect(third.patch).toStrictEqual({});
|
||||
});
|
||||
|
||||
test('rundown data changes are emitted on deep difference', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ rundown: { actualStart: 1000 } });
|
||||
|
||||
const { patch } = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['rundown']);
|
||||
});
|
||||
|
||||
test('addedTime changes count as immediate and emit the timer', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ timer: { addedTime: 60_000 } });
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
});
|
||||
|
||||
test('an offset mode change is immediate and bypasses the dependency gate', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ offset: { mode: OffsetMode.Relative } });
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['offset']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import {
|
||||
EndAction,
|
||||
EntryId,
|
||||
@@ -30,13 +29,12 @@ import { restoreService } from '../restore-service/restore.service.js';
|
||||
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||
import {
|
||||
collectRuntimeStateChanges,
|
||||
findNextPlayableId,
|
||||
findNextPlayableWithCue,
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
} from './runtime.utils.js';
|
||||
|
||||
@@ -651,8 +649,6 @@ const eventTimer = new EventTimer({
|
||||
});
|
||||
export const runtimeService = new RuntimeService(eventTimer);
|
||||
|
||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||
|
||||
/**
|
||||
* Decorator manages side effects from updating the runtime
|
||||
* This should only be applied to functions that are exposed for consumption
|
||||
@@ -665,90 +661,13 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// call the original method and get the state
|
||||
const result = originalMethod.apply(this, args);
|
||||
const state = runtimeState.getState();
|
||||
|
||||
// diff against the previously broadcast state to find what to send
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(RuntimeService.previousState, state);
|
||||
|
||||
const batch = eventStore.createBatch();
|
||||
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// Update the entry if they have changed
|
||||
let entryChanged = false;
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
||||
|
||||
// for the very fist run there will be nothing in the previousState so we force an update
|
||||
const justStarted = !RuntimeService.previousState?.timer;
|
||||
|
||||
// offset mode has been changed
|
||||
const offsetModeChanged = RuntimeService.previousState?.offset?.mode !== state.offset.mode;
|
||||
|
||||
// if playback changes most things should update
|
||||
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||
|
||||
const addedTimeChanged = !justStarted && RuntimeService.previousState?.timer.addedTime !== state.timer.addedTime;
|
||||
|
||||
// combine all big changes
|
||||
const hasImmediateChanges =
|
||||
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are updated when the seconds roll over
|
||||
*/
|
||||
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
||||
if (updateTimer) {
|
||||
batch.add('timer', state.timer);
|
||||
RuntimeService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
/**
|
||||
* clock has changed by a second or more.
|
||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||
*/
|
||||
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
batch.add('clock', state.clock);
|
||||
RuntimeService.previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||
*/
|
||||
const updateRuntime = getShouldOffsetUpdate(
|
||||
RuntimeService.previousState?.offset,
|
||||
state.offset,
|
||||
updateClock || hasImmediateChanges,
|
||||
);
|
||||
if (updateRuntime) {
|
||||
batch.add('offset', state.offset);
|
||||
RuntimeService.previousState.offset = structuredClone(state.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
*/
|
||||
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
||||
if (updateRundownData) {
|
||||
batch.add('rundown', state.rundown);
|
||||
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
||||
}
|
||||
|
||||
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||
const previousEntry = RuntimeService.previousState[key];
|
||||
const currentEntry = state[key];
|
||||
|
||||
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
||||
|
||||
// if they have the same id the check if the contents have changed
|
||||
if (previousEntry?.id === currentEntry?.id) {
|
||||
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
||||
}
|
||||
// at this point we know that either the id or the contents has changed
|
||||
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
||||
RuntimeService.previousState[key] = structuredClone(currentEntry);
|
||||
return true;
|
||||
for (const key of Object.keys(patch) as (keyof RuntimeStore)[]) {
|
||||
batch.add(key, patch[key] as RuntimeStore[typeof key]);
|
||||
}
|
||||
|
||||
// save the restore state
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Offset,
|
||||
OntimeEvent,
|
||||
Rundown,
|
||||
RuntimeStore,
|
||||
TimerState,
|
||||
TimerType,
|
||||
isOntimeEvent,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { millisToSeconds } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
|
||||
export function isNewSecond(
|
||||
previousValue: MaybeNumber | undefined,
|
||||
currentValue: MaybeNumber | undefined,
|
||||
@@ -68,6 +71,104 @@ export function getShouldOffsetUpdate(
|
||||
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
||||
}
|
||||
|
||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||
|
||||
/**
|
||||
* Diffs a runtime state snapshot against the previously broadcast state
|
||||
* and collects the store keys that should be sent to clients
|
||||
*
|
||||
* !!! mutates previousState in place: emitted keys are persisted so the
|
||||
* next diff compares against what clients last received
|
||||
*/
|
||||
export function collectRuntimeStateChanges(
|
||||
previousState: RuntimeState,
|
||||
state: Readonly<RuntimeState>,
|
||||
): { patch: Partial<RuntimeStore>; hasImmediateChanges: boolean } {
|
||||
const patch: Partial<RuntimeStore> = {};
|
||||
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// Update the entry if they have changed
|
||||
let entryChanged = false;
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
||||
|
||||
// for the very fist run there will be nothing in the previousState so we force an update
|
||||
const justStarted = !previousState?.timer;
|
||||
|
||||
// offset mode has been changed
|
||||
const offsetModeChanged = previousState?.offset?.mode !== state.offset.mode;
|
||||
|
||||
// if playback changes most things should update
|
||||
const hasChangedPlayback = previousState.timer?.playback !== state.timer.playback;
|
||||
|
||||
const addedTimeChanged = !justStarted && previousState?.timer.addedTime !== state.timer.addedTime;
|
||||
|
||||
// combine all big changes
|
||||
const hasImmediateChanges =
|
||||
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are updated when the seconds roll over
|
||||
*/
|
||||
const updateTimer = getShouldTimerUpdate(previousState?.timer, state.timer);
|
||||
if (updateTimer) {
|
||||
patch.timer = state.timer;
|
||||
previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
/**
|
||||
* clock has changed by a second or more.
|
||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||
*/
|
||||
const updateClock = updateTimer || getShouldClockUpdate(previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
patch.clock = state.clock;
|
||||
previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||
*/
|
||||
const updateRuntime = getShouldOffsetUpdate(previousState?.offset, state.offset, updateClock || hasImmediateChanges);
|
||||
if (updateRuntime) {
|
||||
patch.offset = state.offset;
|
||||
previousState.offset = structuredClone(state.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
*/
|
||||
const updateRundownData = !deepEqual(previousState.rundown, state.rundown);
|
||||
if (updateRundownData) {
|
||||
patch.rundown = state.rundown;
|
||||
previousState.rundown = structuredClone(state.rundown);
|
||||
}
|
||||
|
||||
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||
const previousEntry = previousState[key];
|
||||
const currentEntry = state[key];
|
||||
|
||||
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
||||
|
||||
// if they have the same id the check if the contents have changed
|
||||
if (previousEntry?.id === currentEntry?.id) {
|
||||
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
||||
}
|
||||
// at this point we know that either the id or the contents has changed
|
||||
patch[key] = currentEntry as RuntimeStore[K]; // we know that there is the necessary overlap in the types to cast this
|
||||
previousState[key] = structuredClone(currentEntry);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { patch, hasImmediateChanges };
|
||||
}
|
||||
|
||||
/**
|
||||
* finds the previous playable event, if it exists
|
||||
*/
|
||||
|
||||
+313
@@ -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,174 @@
|
||||
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,
|
||||
updateAll,
|
||||
updateLoaded,
|
||||
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,
|
||||
elapsed: state.timer.elapsed,
|
||||
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;
|
||||
/**
|
||||
* 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
|
||||
* @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,
|
||||
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;
|
||||
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,688 @@
|
||||
/**
|
||||
* 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: 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');
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
clearState,
|
||||
getState,
|
||||
load,
|
||||
loadGroupFlagAndEnd,
|
||||
loadGroupFlagAndEndCore,
|
||||
pause,
|
||||
resume,
|
||||
roll,
|
||||
start,
|
||||
startCore,
|
||||
stop,
|
||||
update,
|
||||
} from '../runtimeState.js';
|
||||
@@ -82,7 +83,7 @@ describe('mutation on runtimeState', () => {
|
||||
await initRundown(makeRundown({}), {});
|
||||
vi.runAllTimers();
|
||||
|
||||
let success = start(mockState);
|
||||
let success = startCore(mockState);
|
||||
expect(success).toBe(false);
|
||||
|
||||
success = pause();
|
||||
@@ -868,7 +869,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 2, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 2);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: rundown.entries[1],
|
||||
@@ -896,7 +897,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: rundown.entries[2],
|
||||
@@ -924,7 +925,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: null,
|
||||
@@ -949,7 +950,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 0);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user