mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 01:13:55 +00:00
963 lines
31 KiB
TypeScript
963 lines
31 KiB
TypeScript
import { Instant, PlayableEvent, Playback, SupportedEntry, TimerPhase } from 'ontime-types';
|
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
|
|
|
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
|
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
|
|
|
import {
|
|
type RuntimeState,
|
|
addTime,
|
|
clearState,
|
|
getState,
|
|
load,
|
|
loadGroupFlagAndEnd,
|
|
pause,
|
|
resume,
|
|
roll,
|
|
start,
|
|
stop,
|
|
update,
|
|
} from '../runtimeState.js';
|
|
import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
|
|
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
|
|
|
const mockEvent = {
|
|
type: 'event',
|
|
id: 'mock',
|
|
cue: 'mock',
|
|
timeStart: 0,
|
|
timeEnd: 1000,
|
|
duration: 1000,
|
|
skip: false,
|
|
parent: null,
|
|
} as PlayableEvent;
|
|
|
|
const mockState = {
|
|
clock: 666,
|
|
eventNow: null,
|
|
eventNext: null,
|
|
rundown: {
|
|
selectedEventIndex: null,
|
|
numEvents: 0,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
current: null,
|
|
duration: null,
|
|
elapsed: null,
|
|
expectedFinish: null,
|
|
playback: Playback.Stop,
|
|
secondaryTimer: null,
|
|
startedAt: null,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
beforeAll(() => {
|
|
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),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
});
|
|
|
|
describe('mutation on runtimeState', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime('jan 1 00:01');
|
|
});
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('playback operations', async () => {
|
|
it('refuses if nothing is loaded', async () => {
|
|
// force update
|
|
await initRundown(makeRundown({}), {});
|
|
vi.runAllTimers();
|
|
|
|
let success = start(mockState);
|
|
expect(success).toBe(false);
|
|
|
|
success = pause();
|
|
expect(success).toBe(false);
|
|
});
|
|
|
|
test('normal playback cycle', async () => {
|
|
// 1. Load event
|
|
const mockRundown = makeRundown({
|
|
entries: { [mockEvent.id]: mockEvent, event2: { ...mockEvent, id: 'event2' } },
|
|
order: [mockEvent.id, 'event2'],
|
|
});
|
|
// force update
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
const { metadata, rundown } = rundownCache.get();
|
|
load(mockEvent, rundown, metadata);
|
|
let newState = getState();
|
|
expect(newState.eventNow?.id).toBe(mockEvent.id);
|
|
expect(newState.eventNext?.id).toBe('event2');
|
|
expect(newState.timer.playback).toBe(Playback.Armed);
|
|
expect(newState.clock).not.toBe(666);
|
|
expect(newState.groupNow).toBeNull();
|
|
|
|
// 2. Start event
|
|
vi.setSystemTime('jan 1 00:02');
|
|
let success = start();
|
|
newState = getState();
|
|
expect(success).toBe(true);
|
|
expect(newState.timer).toMatchObject({
|
|
playback: Playback.Play,
|
|
});
|
|
expect(newState.rundown.actualStart).toBe(newState.clock);
|
|
|
|
// 3. Pause event
|
|
vi.setSystemTime('jan 1 00:03');
|
|
success = pause();
|
|
newState = getState();
|
|
expect(success).toBe(true);
|
|
expect(newState.clock).not.toBe(666);
|
|
expect(newState.timer).toMatchObject({
|
|
playback: Playback.Pause,
|
|
addedTime: 0,
|
|
});
|
|
expect(newState._timer.pausedAt).toEqual(newState.clock);
|
|
|
|
success = pause();
|
|
expect(success).toBe(false);
|
|
|
|
// 4. Restart event
|
|
vi.setSystemTime('jan 1 00:04');
|
|
success = start();
|
|
newState = getState();
|
|
expect(success).toBe(true);
|
|
expect(newState.timer).toMatchObject({
|
|
playback: Playback.Play,
|
|
secondaryTimer: null,
|
|
});
|
|
expect(newState.timer).toEqual(
|
|
expect.objectContaining({
|
|
current: expect.any(Number),
|
|
duration: expect.any(Number),
|
|
elapsed: expect.any(Number),
|
|
expectedFinish: expect.any(Number),
|
|
startedAt: expect.any(Number),
|
|
}),
|
|
);
|
|
expect(newState._timer.pausedAt).toBeNull();
|
|
|
|
// 5. Stop event
|
|
vi.setSystemTime('jan 1 00:05');
|
|
success = stop();
|
|
newState = getState();
|
|
expect(success).toBe(true);
|
|
expect(newState.eventNow).toBe(null);
|
|
expect(newState.timer).toMatchObject({
|
|
playback: Playback.Stop,
|
|
duration: null,
|
|
elapsed: null,
|
|
expectedFinish: null,
|
|
startedAt: null,
|
|
});
|
|
expect(newState.rundown.actualStart).toBeNull();
|
|
});
|
|
});
|
|
|
|
test('runtime offset', async () => {
|
|
const entries = {
|
|
event1: { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000, parent: null },
|
|
event2: { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500, parent: null },
|
|
};
|
|
const mockRundown = makeRundown({ entries, order: ['event1', 'event2'] });
|
|
|
|
// force update
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
const { metadata, rundown } = rundownCache.get();
|
|
|
|
// 1. Load event
|
|
vi.setSystemTime('jan 1 00:09');
|
|
load(entries.event1, rundown, metadata);
|
|
let newState = getState();
|
|
expect(newState.rundown.actualStart).toBeNull();
|
|
expect(newState.rundown.plannedStart).toBe(0);
|
|
expect(newState.rundown.plannedEnd).toBe(1500);
|
|
expect(newState.groupNow).toBeNull();
|
|
expect(newState.offset.absolute).toBe(0);
|
|
|
|
// 2. Start event
|
|
vi.setSystemTime('jan 1 00:10');
|
|
start();
|
|
newState = getState();
|
|
const firstStart = newState.clock;
|
|
if (newState.offset.absolute === null) {
|
|
throw new Error('Value cannot be null at this stage');
|
|
}
|
|
|
|
expect(newState.rundown.actualStart).toBe(newState.clock);
|
|
expect(newState.offset.absolute).toBe(newState.clock - entries.event1.timeStart);
|
|
expect(newState.offset.expectedRundownEnd).toBe(entries.event2.timeEnd + newState.offset.absolute);
|
|
|
|
// 3. Next event
|
|
vi.setSystemTime('jan 1 00:12');
|
|
load(entries.event2, rundown, metadata);
|
|
start();
|
|
|
|
newState = getState();
|
|
if (newState.rundown.actualStart === null || newState.offset.absolute === null) {
|
|
throw new Error('Value cannot be null at this stage');
|
|
}
|
|
|
|
// there is a case where the calculation time overflows the millisecond which makes
|
|
// tests fail
|
|
const forgivingActualStart = Math.abs(newState.rundown.actualStart - firstStart);
|
|
expect(forgivingActualStart).toBeLessThanOrEqual(1);
|
|
// we are over-under, the difference between the schedule and the actual start
|
|
const delayBefore = newState.clock - entries.event2.timeStart;
|
|
expect(newState.offset.absolute).toBe(delayBefore);
|
|
// finish is the difference between the runtime and the schedule
|
|
expect(newState.offset.expectedRundownEnd).toBe(newState.offset.absolute + entries.event2.timeEnd);
|
|
expect(newState.groupNow).toBeNull();
|
|
|
|
// 4. Add time
|
|
addTime(10);
|
|
newState = getState();
|
|
if (newState.offset.absolute === null) {
|
|
throw new Error('Value cannot be null at this stage');
|
|
}
|
|
|
|
expect(newState.offset.absolute).toBe(delayBefore + 10);
|
|
expect(newState.offset.expectedRundownEnd).toBe(entries.event2.timeEnd + newState.offset.absolute);
|
|
|
|
// 5. Stop event
|
|
stop();
|
|
newState = getState();
|
|
expect(newState.rundown.actualStart).toBeNull();
|
|
expect(newState.offset.absolute).toBe(0);
|
|
expect(newState.offset.expectedRundownEnd).toBeNull();
|
|
});
|
|
|
|
test('resume restores currentDay from restore point', async () => {
|
|
clearState();
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
event1: { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000, dayOffset: 0 },
|
|
},
|
|
order: ['event1'],
|
|
});
|
|
|
|
const startEpoch = new Date('jan 1 00:00').getTime() as Instant;
|
|
vi.setSystemTime('jan 3 23:59:59');
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
const { rundown, metadata } = rundownCache.get();
|
|
const restorePoint = {
|
|
playback: Playback.Play,
|
|
selectedEventId: 'event1',
|
|
startedAt: 0,
|
|
addedTime: 0,
|
|
pausedAt: null,
|
|
firstStart: 60 * 1000,
|
|
startEpoch,
|
|
currentDay: 2,
|
|
};
|
|
|
|
resume(restorePoint, mockRundown.entries.event1 as PlayableEvent, rundown, metadata);
|
|
|
|
const newState = getState();
|
|
expect(newState.rundown.actualStart).toBe(60 * 1000);
|
|
expect(newState.rundown.currentDay).toBe(2);
|
|
|
|
// crossing midnight increments currentDay
|
|
vi.setSystemTime('jan 4 00:00:01');
|
|
update();
|
|
expect(getState().rundown.currentDay).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('roll mode', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime('jan 1 00:00');
|
|
clearState();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('overnight event sets correct currentDay', () => {
|
|
test('roll into overnight event after midnight sets currentDay correctly', async () => {
|
|
// Event spans 23:30 to 00:30 (overnight)
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
|
|
timeEnd: 30 * 60 * 1000, // 00:30
|
|
duration: 60 * 60 * 1000, // 1 hour
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
// Clock is at 00:10 (morning part of overnight event)
|
|
vi.setSystemTime('jan 1 00:10');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
const result = roll(rundown, metadata);
|
|
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(true);
|
|
|
|
const state = getState();
|
|
// currentDay should be 1 because we're in the "next day" part of the overnight event
|
|
expect(state.rundown.currentDay).toBe(1);
|
|
});
|
|
|
|
test('roll into overnight event before midnight sets currentDay correctly', async () => {
|
|
// Event spans 23:30 to 00:30 (overnight)
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
|
|
timeEnd: 30 * 60 * 1000, // 00:30
|
|
duration: 60 * 60 * 1000, // 1 hour
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
// Clock is at 23:40 (evening part of overnight event)
|
|
vi.setSystemTime('jan 1 23:40');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
const result = roll(rundown, metadata);
|
|
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(true);
|
|
|
|
const state = getState();
|
|
// currentDay should be 0 because we're still on the same day as the event start
|
|
expect(state.rundown.currentDay).toBe(0);
|
|
});
|
|
|
|
test('currentDay increments when crossing midnight during roll', async () => {
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
|
|
timeEnd: 30 * 60 * 1000, // 00:30
|
|
duration: 60 * 60 * 1000, // 1 hour
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
// Start at 23:40
|
|
vi.setSystemTime('jan 1 23:40');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
roll(rundown, metadata);
|
|
|
|
expect(getState().rundown.currentDay).toBe(0);
|
|
|
|
// Cross midnight
|
|
vi.setSystemTime('jan 2 00:10');
|
|
update();
|
|
|
|
expect(getState().rundown.currentDay).toBe(1);
|
|
});
|
|
|
|
test('pending roll crossing midnight keeps currentDay null', async () => {
|
|
// Event starts at 01:00 (future event, will be pending)
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
|
|
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
|
|
duration: 1 * MILLIS_PER_HOUR, // 1 hour
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
// Clock is at 23:50 (before midnight, event is pending for tomorrow 01:00)
|
|
vi.setSystemTime('jan 1 23:50');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
const result = roll(rundown, metadata);
|
|
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(false);
|
|
|
|
const stateBeforeMidnight = getState();
|
|
expect(stateBeforeMidnight.timer.secondaryTimer).not.toBeNull();
|
|
// currentDay is null when pending (rundown hasn't started)
|
|
expect(stateBeforeMidnight.rundown.currentDay).toBeNull();
|
|
|
|
// Cross midnight while still pending
|
|
vi.setSystemTime('jan 2 00:10');
|
|
update();
|
|
|
|
const stateAfterMidnight = getState();
|
|
// currentDay stays null while pending (rundown hasn't started yet)
|
|
expect(stateAfterMidnight.rundown.currentDay).toBeNull();
|
|
// should still be pending (event starts at 01:00)
|
|
expect(stateAfterMidnight.timer.secondaryTimer).not.toBeNull();
|
|
});
|
|
|
|
test('waiting between events crossing midnight increments currentDay', async () => {
|
|
// Event 1: 23:00-23:30, Event 2: 00:30-01:00 (gap over midnight)
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
|
timeEnd: 23 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 23:30
|
|
duration: 30 * MILLIS_PER_MINUTE,
|
|
dayOffset: 0,
|
|
},
|
|
2: {
|
|
...mockEvent,
|
|
id: '2',
|
|
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30
|
|
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
|
duration: 30 * MILLIS_PER_MINUTE,
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1', '2'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
const { rundown, metadata } = rundownCache.get();
|
|
|
|
// Start event 1 at 23:05
|
|
vi.setSystemTime('jan 1 23:05');
|
|
const startEpoch = Date.now() as Instant;
|
|
let result = roll(rundown, metadata);
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(true);
|
|
expect(getState().rundown.currentDay).toBe(0);
|
|
|
|
// Simulate event 1 finishing and loading event 2 (what runtime service does)
|
|
// Load event 2 and call roll to put it in pending state
|
|
vi.setSystemTime('jan 1 23:35');
|
|
load(rundown.entries['2'] as PlayableEvent, rundown, metadata, {
|
|
firstStart: getState().rundown.actualStart,
|
|
startEpoch,
|
|
});
|
|
result = roll(rundown, metadata);
|
|
expect(result.eventId).toBe('2');
|
|
expect(result.didStart).toBe(false); // pending for 00:30
|
|
expect(getState().timer.secondaryTimer).not.toBeNull();
|
|
expect(getState().rundown.currentDay).toBe(0);
|
|
|
|
// Cross midnight while waiting for event 2
|
|
vi.setSystemTime('jan 2 00:10');
|
|
update();
|
|
|
|
// currentDay should increment even while waiting between events
|
|
expect(getState().rundown.currentDay).toBe(1);
|
|
expect(getState().timer.secondaryTimer).not.toBeNull(); // still waiting
|
|
});
|
|
|
|
test('pending roll sets currentDay to 0 when event starts (rundown starts fresh)', async () => {
|
|
// Event starts at 01:00
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
|
|
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
|
|
duration: 1 * MILLIS_PER_HOUR, // 1 hour
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
// Start pending at 23:50
|
|
vi.setSystemTime('jan 1 23:50');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
roll(rundown, metadata);
|
|
|
|
// Cross midnight
|
|
vi.setSystemTime('jan 2 00:10');
|
|
update();
|
|
|
|
// Now the event starts at 01:05 - call roll again to simulate runtime service
|
|
vi.setSystemTime('jan 2 01:05');
|
|
const result = roll(rundown, metadata);
|
|
|
|
expect(result.didStart).toBe(true);
|
|
|
|
const state = getState();
|
|
// currentDay is 0 because the rundown just started (this is day 0)
|
|
// the pending time before midnight doesn't count as rundown time
|
|
expect(state.rundown.currentDay).toBe(0);
|
|
});
|
|
|
|
test('pending roll crossing midnight updates secondaryTimer correctly', async () => {
|
|
// Event starts at 01:00
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
|
|
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
|
|
duration: 1 * MILLIS_PER_HOUR, // 1 hour
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
// Clock is at 23:50
|
|
vi.setSystemTime('jan 1 23:50');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
roll(rundown, metadata);
|
|
|
|
const stateBeforeMidnight = getState();
|
|
// secondaryTimer should be time until 01:00 (1h 10min = 70 min)
|
|
expect(stateBeforeMidnight.timer.secondaryTimer).toBe(70 * MILLIS_PER_MINUTE);
|
|
|
|
// Cross midnight to 00:10
|
|
vi.setSystemTime('jan 2 00:10');
|
|
update();
|
|
|
|
const stateAfterMidnight = getState();
|
|
// secondaryTimer should now be 50 minutes until 01:00
|
|
expect(stateAfterMidnight.timer.secondaryTimer).toBe(50 * MILLIS_PER_MINUTE);
|
|
});
|
|
|
|
test('rolling into overnight event after midnight has correct offset and expected times', async () => {
|
|
// Simulates a rundown with:
|
|
// - A group starting at 13:00, containing an overnight event
|
|
// - Overnight event: 23:50 to 01:50 (2 hours)
|
|
// Roll into the event at 00:21 (31 minutes into the event)
|
|
const groupId = 'group-1';
|
|
const eventId = 'event-1';
|
|
const eventStart = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
|
const eventEnd = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
|
const eventDuration = 2 * MILLIS_PER_HOUR; // 2 hours
|
|
const groupStart = 13 * MILLIS_PER_HOUR; // 13:00
|
|
const groupDuration = 12 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 12h 50m (ends at 01:50)
|
|
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
[groupId]: {
|
|
id: groupId,
|
|
type: SupportedEntry.Group,
|
|
title: 'Test Group',
|
|
timeStart: groupStart,
|
|
timeEnd: eventEnd,
|
|
duration: groupDuration,
|
|
entries: [eventId],
|
|
colour: '',
|
|
note: '',
|
|
custom: {},
|
|
revision: 0,
|
|
isFirstLinked: false,
|
|
targetDuration: null,
|
|
},
|
|
[eventId]: {
|
|
...mockEvent,
|
|
id: eventId,
|
|
timeStart: eventStart,
|
|
timeEnd: eventEnd,
|
|
duration: eventDuration,
|
|
dayOffset: 0,
|
|
parent: groupId,
|
|
},
|
|
},
|
|
order: [groupId],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
const { rundown, metadata } = rundownCache.get();
|
|
|
|
// Roll into the event AFTER midnight at 00:21 (31 minutes into the 2-hour event)
|
|
const rollTime = 21 * MILLIS_PER_MINUTE; // 00:21
|
|
vi.setSystemTime('jan 2 00:21');
|
|
const result = roll(rundown, metadata);
|
|
|
|
expect(result.eventId).toBe(eventId);
|
|
expect(result.didStart).toBe(true);
|
|
|
|
// Call update to recalculate all state (like runtime service does)
|
|
update();
|
|
|
|
const state = getState();
|
|
|
|
// currentDay should be 1 (we're on the next day after midnight)
|
|
expect(state.rundown.currentDay).toBe(1);
|
|
|
|
// offset.absolute should be 0 (event started on time, backdated to planned start)
|
|
expect(state.offset.absolute).toBe(0);
|
|
|
|
// Timer should show correct remaining time
|
|
// Event started at 23:50, duration is 2 hours, clock is 00:21
|
|
// Time elapsed = 31 minutes, time remaining = 2h - 31m = 1h 29m = 89 minutes
|
|
const expectedRemaining = eventDuration - (rollTime + (24 * MILLIS_PER_HOUR - eventStart));
|
|
expect(state.timer.current).toBe(expectedRemaining);
|
|
|
|
// expectedFinish should be 01:50 (event end time)
|
|
expect(state.timer.expectedFinish).toBe(eventEnd);
|
|
|
|
// expectedRundownEnd should be 01:50 (same as event end, only one event)
|
|
expect(state.offset.expectedRundownEnd).toBe(eventEnd);
|
|
|
|
// expectedGroupEnd should be 01:50 (group ends when the event ends)
|
|
expect(state.offset.expectedGroupEnd).toBe(eventEnd);
|
|
|
|
// Verify timer started at correct backdated time
|
|
expect(state.timer.startedAt).toBe(eventStart);
|
|
|
|
// Verify actualStart is backdated to planned start
|
|
expect(state.rundown.actualStart).toBe(eventStart);
|
|
});
|
|
|
|
test('rundown loops back after finishing and resets currentDay when first event starts again', async () => {
|
|
// Rundown: 09:00-23:00, then overnight event 23:00-01:00
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: {
|
|
...mockEvent,
|
|
id: '1',
|
|
timeStart: 9 * MILLIS_PER_HOUR, // 09:00
|
|
timeEnd: 23 * MILLIS_PER_HOUR, // 23:00
|
|
duration: 14 * MILLIS_PER_HOUR, // 14 hours
|
|
dayOffset: 0,
|
|
},
|
|
2: {
|
|
...mockEvent,
|
|
id: '2',
|
|
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
|
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00 (next day)
|
|
duration: 2 * MILLIS_PER_HOUR, // 2 hours
|
|
dayOffset: 0,
|
|
},
|
|
},
|
|
order: ['1', '2'],
|
|
});
|
|
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
|
|
const { rundown, metadata } = rundownCache.get();
|
|
|
|
// Day 1: Start at 09:05 - first event is playing
|
|
vi.setSystemTime('jan 1 09:05');
|
|
let result = roll(rundown, metadata);
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(true);
|
|
expect(getState().rundown.currentDay).toBe(0);
|
|
|
|
// Day 1: Move to 23:05 - second event (overnight) is playing
|
|
vi.setSystemTime('jan 1 23:05');
|
|
result = roll(rundown, metadata);
|
|
expect(result.eventId).toBe('2');
|
|
expect(result.didStart).toBe(true);
|
|
|
|
// Cross midnight
|
|
vi.setSystemTime('jan 2 00:30');
|
|
update();
|
|
expect(getState().rundown.currentDay).toBe(1);
|
|
|
|
// Day 2: 01:05 - rundown finishes, loops back to first event (pending for 09:00)
|
|
vi.setSystemTime('jan 2 01:05');
|
|
result = roll(rundown, metadata);
|
|
// First event is pending (09:00 is in the future)
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(false);
|
|
// currentDay is null while pending (rundown hasn't "restarted" yet)
|
|
expect(getState().rundown.currentDay).toBeNull();
|
|
|
|
// Day 2: 09:05 - first event starts again (fresh rundown cycle)
|
|
vi.setSystemTime('jan 2 09:05');
|
|
result = roll(rundown, metadata);
|
|
expect(result.eventId).toBe('1');
|
|
expect(result.didStart).toBe(true);
|
|
// currentDay resets to 0 for the new cycle
|
|
expect(getState().rundown.currentDay).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('normal roll', async () => {
|
|
beforeEach(async () => {
|
|
vi.useFakeTimers();
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
|
|
2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
|
|
3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
|
|
},
|
|
order: ['1', '2', '3'],
|
|
});
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
});
|
|
|
|
test('pending event', () => {
|
|
const { rundown, metadata } = rundownCache.get();
|
|
const { eventId, didStart } = roll(rundown, metadata);
|
|
const state = getState();
|
|
|
|
expect(eventId).toBe('1');
|
|
expect(didStart).toBe(false);
|
|
expect(state.timer.phase).toBe(TimerPhase.Pending);
|
|
expect(state.timer.secondaryTimer).toBe(1000);
|
|
});
|
|
|
|
test('roll events', () => {
|
|
vi.setSystemTime('jan 1 00:00:01');
|
|
const { rundown, metadata } = rundownCache.get();
|
|
let result = roll(rundown, metadata);
|
|
expect(result).toStrictEqual({ eventId: '1', didStart: true });
|
|
|
|
vi.setSystemTime('jan 1 00:00:02');
|
|
result = roll(rundown, metadata);
|
|
expect(result).toStrictEqual({ eventId: '2', didStart: true });
|
|
|
|
vi.setSystemTime('jan 1 00:00:03:500');
|
|
result = roll(rundown, metadata);
|
|
expect(result).toStrictEqual({ eventId: '3', didStart: true });
|
|
});
|
|
});
|
|
|
|
describe('roll takeover', () => {
|
|
beforeEach(async () => {
|
|
const rundown = makeRundown({
|
|
entries: {
|
|
1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
|
|
2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
|
|
3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
|
|
},
|
|
order: ['1', '2', '3'],
|
|
});
|
|
|
|
// force update
|
|
vi.useFakeTimers();
|
|
await initRundown(rundown, {});
|
|
vi.runAllTimers();
|
|
});
|
|
|
|
test('from load', () => {
|
|
const { rundown, metadata } = rundownCache.get();
|
|
load(rundown.entries[3] as PlayableEvent, rundown, metadata);
|
|
const result = roll(rundown, metadata);
|
|
expect(result).toStrictEqual({ eventId: '3', didStart: false });
|
|
const state = getState();
|
|
expect(state.timer.phase).toBe(TimerPhase.Pending);
|
|
expect(state.timer.secondaryTimer).toBe(3000);
|
|
});
|
|
|
|
test('from play', () => {
|
|
const { rundown, metadata } = rundownCache.get();
|
|
load(rundown.entries[1] as PlayableEvent, rundown, metadata);
|
|
start();
|
|
const result = roll(rundown, metadata);
|
|
expect(result).toStrictEqual({ eventId: '1', didStart: false });
|
|
expect(getState().offset.absolute).toBe(-1000);
|
|
});
|
|
});
|
|
|
|
describe('roll continue with offset', () => {
|
|
test('no gaps', async () => {
|
|
const mockRundown = makeRundown({
|
|
entries: {
|
|
1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
|
|
2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
|
|
3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
|
|
},
|
|
order: ['1', '2', '3'],
|
|
});
|
|
|
|
// force update
|
|
vi.useFakeTimers();
|
|
await initRundown(mockRundown, {});
|
|
vi.runAllTimers();
|
|
const { rundown, metadata } = rundownCache.get();
|
|
|
|
load(rundown.entries[1] as PlayableEvent, rundown, metadata);
|
|
start();
|
|
// the current offset after manual play
|
|
const currentOffset = getState().offset.absolute;
|
|
let result = roll(rundown, metadata, getState().offset);
|
|
expect(result).toStrictEqual({ eventId: '1', didStart: false });
|
|
// the current offset should be maintain by roll mode when taking over from play
|
|
expect(getState().offset.absolute).toBe(currentOffset);
|
|
|
|
vi.setSystemTime('jan 1 00:00:01');
|
|
result = roll(rundown, metadata, getState().offset);
|
|
expect(result).toStrictEqual({ eventId: '2', didStart: true });
|
|
expect(getState().offset.absolute).toBe(-1000);
|
|
|
|
vi.setSystemTime('jan 1 00:00:02');
|
|
result = roll(rundown, metadata, getState().offset);
|
|
expect(result).toStrictEqual({ eventId: '3', didStart: true });
|
|
expect(getState().offset.absolute).toBe(-1000);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('loadGroupFlagAndEnd()', () => {
|
|
test('from no-group to a group will clear groupNow', () => {
|
|
const rundown = makeRundown({
|
|
entries: {
|
|
0: makeOntimeEvent({ id: '0', parent: null }),
|
|
1: makeOntimeGroup({ id: '1', entries: ['11'] }),
|
|
11: makeOntimeEvent({ id: '11', parent: '1' }),
|
|
2: makeOntimeGroup({ id: '2', entries: [] }),
|
|
3: makeOntimeEvent({ id: '3', parent: null }),
|
|
},
|
|
order: ['0', '1', '2', '3'],
|
|
});
|
|
|
|
const state = {
|
|
groupNow: null,
|
|
eventNow: rundown.entries[11],
|
|
rundown: { actualGroupStart: null },
|
|
} as RuntimeState;
|
|
|
|
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
|
|
|
|
loadGroupFlagAndEnd(rundown, metadata, 2, state);
|
|
|
|
expect(state).toMatchObject({
|
|
groupNow: rundown.entries[1],
|
|
eventNow: rundown.entries[11],
|
|
});
|
|
});
|
|
|
|
test('from a group to a different group will clear groupNow', () => {
|
|
const rundown = makeRundown({
|
|
entries: {
|
|
0: makeOntimeEvent({ id: '0', parent: null }),
|
|
1: makeOntimeGroup({ id: '1', entries: ['11'] }),
|
|
11: makeOntimeEvent({ id: '11', parent: '1' }),
|
|
2: makeOntimeGroup({ id: '2', entries: ['22'] }),
|
|
22: makeOntimeEvent({ id: '22', parent: '2' }),
|
|
},
|
|
order: ['0', '1', '2'],
|
|
});
|
|
|
|
const state = {
|
|
groupNow: rundown.entries[1],
|
|
eventNow: rundown.entries[22],
|
|
rundown: { actualGroupStart: null },
|
|
} as RuntimeState;
|
|
|
|
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
|
|
|
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
|
|
|
expect(state).toMatchObject({
|
|
groupNow: rundown.entries[2],
|
|
eventNow: rundown.entries[22],
|
|
});
|
|
});
|
|
|
|
test('from group to a no-group will clear groupNow', () => {
|
|
const rundown = makeRundown({
|
|
entries: {
|
|
0: makeOntimeEvent({ id: '0', parent: null }),
|
|
1: makeOntimeGroup({ id: '1', entries: ['11'] }),
|
|
11: makeOntimeEvent({ id: '11', parent: '1' }),
|
|
2: makeOntimeGroup({ id: '2', entries: ['22'] }),
|
|
22: makeOntimeEvent({ id: '22', parent: '2' }),
|
|
},
|
|
order: ['0', '1', '2'],
|
|
});
|
|
|
|
const state = {
|
|
groupNow: rundown.entries[1],
|
|
eventNow: rundown.entries[0],
|
|
rundown: { actualGroupStart: null },
|
|
} as RuntimeState;
|
|
|
|
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
|
|
|
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
|
|
|
expect(state).toMatchObject({
|
|
groupNow: null,
|
|
eventNow: rundown.entries[0],
|
|
});
|
|
});
|
|
|
|
test('from no-group to no-group will keep startedAt', () => {
|
|
const rundown = makeRundown({
|
|
entries: {
|
|
0: makeOntimeEvent({ id: '0', parent: null }),
|
|
1: makeOntimeEvent({ id: '1', parent: null }),
|
|
},
|
|
order: ['0', '1'],
|
|
});
|
|
|
|
const state = {
|
|
groupNow: null,
|
|
eventNow: rundown.entries[0],
|
|
rundown: { actualGroupStart: null },
|
|
} as RuntimeState;
|
|
|
|
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
|
|
|
|
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
|
|
|
expect(state).toMatchObject({
|
|
groupNow: null,
|
|
eventNow: rundown.entries[0],
|
|
});
|
|
});
|
|
});
|