mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
1fad99f505
The group timer was derived in the client, which meant every view that wanted it had to depend on the rundown query. That coupling was already awkward for the PiP timer, whose separate react root needed its own query client, and it would have spread to every remaining view. Moving the derivation to the server removes the coupling. The value is still the running event timer offset by the content scheduled around it, so the group keeps behaving as if it were a single event containing all its children. Because the group timer is the event timer offset by a constant, it changes exactly when the event timer does and can share its broadcast throttling. - add RuntimeStore.groupTimer, null unless the running group opted in - split the group around the loaded event when the group is loaded, so the per tick cost is an addition rather than a walk of the rundown - derive on the getState() projection so it cannot drift from the timer it is built on - reduce the client to a plain selector, dropping the PiP query client workaround Also fixes elapsed time, which was calculated from the group duration and so clamped to zero for as long as time added to an event kept the group in credit. It is now derived symmetrically with the remaining time, and the two always add up to the total. Timer and PiP shared eight identical branches for choosing between the two timers. These now go through a single resolver. A group has no warning or danger thresholds, so it only reports as running or overtime, and feeding that phase through the existing modifiers makes the suppression of warning and danger a consequence of what a group is rather than something each view has to remember. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
1604 lines
42 KiB
TypeScript
1604 lines
42 KiB
TypeScript
import {
|
|
EndAction,
|
|
MaybeNumber,
|
|
OntimeEvent,
|
|
OntimeGroup,
|
|
Playback,
|
|
Rundown,
|
|
SupportedEntry,
|
|
TimeOfDay,
|
|
TimeStrategy,
|
|
TimerPhase,
|
|
TimerType,
|
|
} from 'ontime-types';
|
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
|
|
|
import type { RuntimeState } from '../../stores/runtimeState.js';
|
|
import {
|
|
findDayOffset,
|
|
getCurrent,
|
|
getElapsed,
|
|
getExpectedFinish,
|
|
getGroupTimer,
|
|
getGroupTiming,
|
|
getRuntimeOffset,
|
|
getTimerPhase,
|
|
hasCrossedMidnight,
|
|
normaliseEndTime,
|
|
skippedOutOfEvent,
|
|
} from '../timerUtils.js';
|
|
|
|
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
|
|
|
|
describe('getElapsed()', () => {
|
|
it('returns active elapsed time from startedAt without add-time adjustments', () => {
|
|
const state = {
|
|
clock: 5 * MILLIS_PER_MINUTE,
|
|
timer: {
|
|
addedTime: -10 * MILLIS_PER_MINUTE,
|
|
current: 15 * MILLIS_PER_MINUTE,
|
|
duration: 20 * MILLIS_PER_MINUTE,
|
|
startedAt: 2 * MILLIS_PER_MINUTE,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
pausedDuration: 0,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
|
|
});
|
|
|
|
it('subtracts accumulated pause time', () => {
|
|
const state = {
|
|
clock: 10 * MILLIS_PER_MINUTE,
|
|
timer: {
|
|
startedAt: 2 * MILLIS_PER_MINUTE,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
pausedDuration: 5 * MILLIS_PER_MINUTE,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
|
|
});
|
|
|
|
it('uses the current pause start while paused', () => {
|
|
const state = {
|
|
clock: 10 * MILLIS_PER_MINUTE,
|
|
timer: {
|
|
startedAt: 2 * MILLIS_PER_MINUTE,
|
|
},
|
|
_timer: {
|
|
pausedAt: 7 * MILLIS_PER_MINUTE,
|
|
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
|
|
});
|
|
});
|
|
|
|
describe('getExpectedFinish()', () => {
|
|
it('is null if we havent started', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 10,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 10,
|
|
startedAt: null,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(null);
|
|
});
|
|
it('is hasFinished if defined', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 20,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 10,
|
|
startedAt: 10,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: true,
|
|
},
|
|
} as RuntimeState;
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(20);
|
|
});
|
|
it('calculates the finish time', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 11,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 10,
|
|
startedAt: 1,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(11);
|
|
});
|
|
it('adds paused and added times', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 11,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: 20,
|
|
duration: 10,
|
|
startedAt: 1,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(31);
|
|
});
|
|
it('added time could be negative', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 11,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: -10,
|
|
duration: 10,
|
|
startedAt: 1,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(1);
|
|
});
|
|
it('user could add enough time for it to be negative', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 11,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: -100,
|
|
duration: 10,
|
|
startedAt: 1,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(1);
|
|
});
|
|
it('timer can have no duration', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 0,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 0,
|
|
startedAt: 1,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(1);
|
|
});
|
|
it('finish can be the day after', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 10,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: dayInMs,
|
|
startedAt: 10,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(10);
|
|
});
|
|
describe('on timers of type time-to-end', () => {
|
|
it('finish time is as schedule + added time', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 30,
|
|
countToEnd: true,
|
|
},
|
|
timer: {
|
|
addedTime: 10,
|
|
duration: dayInMs,
|
|
startedAt: 10,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
expect(calculatedFinish).toBe(40);
|
|
});
|
|
it('handles events that finish the day after', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 600000, // 00:10:00
|
|
countToEnd: true,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
startedAt: 79200000, // 22:00:00
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
rundown: {
|
|
actualStart: 79200000,
|
|
plannedEnd: 600000,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const calculatedFinish = getExpectedFinish(state);
|
|
// expected finish is not a duration but a point in time
|
|
expect(calculatedFinish).toBe(600000);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getCurrent()', () => {
|
|
it('is duration if it hasnt started', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 30,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 0,
|
|
timer: {
|
|
addedTime: 10,
|
|
duration: 111, // <-- we take the duration value
|
|
startedAt: null,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(111);
|
|
});
|
|
it('is the remaining time in clock', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 10,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 1,
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 10,
|
|
startedAt: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(9);
|
|
});
|
|
it('accounts for added times', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 10,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 1,
|
|
timer: {
|
|
addedTime: 10,
|
|
duration: 10,
|
|
startedAt: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(19);
|
|
});
|
|
it('counts over midnight', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 20,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 10,
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: dayInMs + 10,
|
|
startedAt: 10,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(dayInMs + 10);
|
|
});
|
|
it('rolls over midnight', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 20,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 5,
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: dayInMs + 10,
|
|
startedAt: 10,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(15);
|
|
});
|
|
it('midnight holds delays', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 20,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 5,
|
|
timer: {
|
|
addedTime: 20,
|
|
duration: dayInMs + 10,
|
|
startedAt: 10,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(35);
|
|
});
|
|
|
|
describe('on timers of type count-to-end', () => {
|
|
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 100,
|
|
countToEnd: true,
|
|
},
|
|
clock: 30,
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 100,
|
|
startedAt: null,
|
|
},
|
|
rundown: {
|
|
plannedEnd: null,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(70);
|
|
});
|
|
|
|
it('current time is the time to end', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 100,
|
|
countToEnd: true,
|
|
},
|
|
clock: 30,
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: 100,
|
|
startedAt: 10,
|
|
},
|
|
rundown: {
|
|
plannedEnd: 100,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(70);
|
|
});
|
|
|
|
it('current time is the time to end + added time', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 100,
|
|
countToEnd: true,
|
|
},
|
|
clock: 30,
|
|
timer: {
|
|
addedTime: 7,
|
|
duration: 100,
|
|
startedAt: 10,
|
|
},
|
|
rundown: {
|
|
plannedEnd: 100,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(77);
|
|
});
|
|
|
|
it('handles events that finish the day after', () => {
|
|
const state = {
|
|
eventNow: {
|
|
timeStart: 79200000, // 22:00:00
|
|
timeEnd: 600000, // 00:10:00
|
|
countToEnd: true,
|
|
},
|
|
clock: 79500000, // 22:05:00
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: Infinity, // not relevant,
|
|
startedAt: 79200000, // 22:00:00
|
|
},
|
|
rundown: {
|
|
actualStart: 79200000,
|
|
plannedEnd: 600000,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(dayInMs - 79500000 + 600000);
|
|
});
|
|
|
|
it('handles events that were started late', () => {
|
|
const state = {
|
|
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
|
eventNow: {
|
|
timeStart: 77400000, // 21:30:00
|
|
timeEnd: 81000000, // 22:30:00
|
|
duration: 3600000, // 01:00:00
|
|
countToEnd: true,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
duration: Infinity, // not relevant,
|
|
startedAt: 79200000, // 22:00:00
|
|
},
|
|
rundown: {
|
|
actualStart: 82000000, // 22:46:40 <--- started now
|
|
plannedEnd: 81000000, // 22:30:00
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const current = getCurrent(state);
|
|
expect(current).toBe(81000000 - 82000000); // <-- planned end - now
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getExpectedFinish() and getCurrentTime() combined', () => {
|
|
it('without added times, they combine to be duration', () => {
|
|
const duration = 10;
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 10,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 0,
|
|
timer: {
|
|
addedTime: 0,
|
|
duration,
|
|
startedAt: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const expectedFinish = getExpectedFinish(state);
|
|
const current = getCurrent(state);
|
|
|
|
const elapsed = duration - current;
|
|
expect(expectedFinish).toBe(10);
|
|
expect(elapsed).toBe(0);
|
|
expect(current).toBe(10);
|
|
expect(elapsed + current).toBe(10);
|
|
});
|
|
it('added times influence expected finish', () => {
|
|
const duration = 10;
|
|
const state = {
|
|
eventNow: {
|
|
timeEnd: 10,
|
|
timerType: TimerType.CountDown,
|
|
},
|
|
clock: 5,
|
|
timer: {
|
|
addedTime: 3,
|
|
duration,
|
|
startedAt: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
hasFinished: false,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const expectedFinish = getExpectedFinish(state);
|
|
const current = getCurrent(state);
|
|
|
|
const elapsed = duration - current;
|
|
expect(expectedFinish).toBe(13);
|
|
expect(elapsed).toBe(2);
|
|
expect(current).toBe(8);
|
|
});
|
|
});
|
|
|
|
describe('hasCrossedMidnight()', () => {
|
|
it('returns true when clock wraps from late to early', () => {
|
|
const previous = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
|
|
const current = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
|
|
expect(hasCrossedMidnight(previous, current)).toBe(true);
|
|
});
|
|
|
|
it('returns false when clock moves forward on the same day', () => {
|
|
const previous = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
|
|
const current = (10 * MILLIS_PER_HOUR + 5 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:05
|
|
expect(hasCrossedMidnight(previous, current)).toBe(false);
|
|
});
|
|
|
|
it('returns false when clock value is unchanged', () => {
|
|
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
|
|
expect(hasCrossedMidnight(time, time)).toBe(false);
|
|
});
|
|
|
|
describe('DST transitions', () => {
|
|
it('returns false during DST fall back (~1h backward jump)', () => {
|
|
// During fall back, clock goes from 02:59 → 02:00 (1h backward)
|
|
const previous = (2 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 02:59
|
|
const current = (2 * MILLIS_PER_HOUR) as TimeOfDay; // 02:00
|
|
expect(hasCrossedMidnight(previous, current)).toBe(false);
|
|
});
|
|
|
|
it('returns true at actual midnight (~23h backward jump)', () => {
|
|
// Actual midnight crossing: 23:59 → 00:01 (~23h58m backward)
|
|
const previous = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
|
|
const current = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
|
|
expect(hasCrossedMidnight(previous, current)).toBe(true);
|
|
});
|
|
|
|
it('returns false for small backward jumps near midnight boundary', () => {
|
|
// Edge case: 12h backward is NOT a midnight cross
|
|
const previous = (12 * MILLIS_PER_HOUR) as TimeOfDay; // 12:00
|
|
const current = (0 * MILLIS_PER_HOUR) as TimeOfDay; // 00:00
|
|
expect(hasCrossedMidnight(previous, current)).toBe(false);
|
|
});
|
|
|
|
it('returns true for backward jumps exceeding 12h', () => {
|
|
// 12h + 1ms backward IS a midnight cross
|
|
const previous = (12 * MILLIS_PER_HOUR + 1) as TimeOfDay; // 12:00:00.001
|
|
const current = (0 * MILLIS_PER_HOUR) as TimeOfDay; // 00:00
|
|
expect(hasCrossedMidnight(previous, current)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('skippedOutOfEvent()', () => {
|
|
const testSkipLimit = 32;
|
|
it('does not consider an event end as a skip', () => {
|
|
const startedAt = 1000;
|
|
const duration = 1000;
|
|
const expectedFinish = startedAt + duration;
|
|
const previousTime = expectedFinish - testSkipLimit / 2;
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(state.clock + testSkipLimit);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
});
|
|
|
|
it('allows rolling backwards in an event', () => {
|
|
const startedAt = 1000;
|
|
const duration = 1000;
|
|
const expectedFinish = startedAt + duration;
|
|
const previousTime = startedAt + testSkipLimit / 2;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(state.clock + testSkipLimit);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
});
|
|
|
|
it('accounts for crossing midnight', () => {
|
|
const startedAt = dayInMs - testSkipLimit;
|
|
const expectedFinish = 10;
|
|
const previousTime = dayInMs - 1;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(testSkipLimit - 2);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
});
|
|
|
|
it('allows rolling backwards in an event across midnight', () => {
|
|
const startedAt = dayInMs - testSkipLimit;
|
|
const expectedFinish = 10;
|
|
const previousTime = startedAt + 1;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(state.clock - testSkipLimit);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
});
|
|
|
|
it('finds skip forwards out of event', () => {
|
|
const startedAt = 1000;
|
|
const duration = 1000;
|
|
const expectedFinish = startedAt + duration;
|
|
const previousTime = expectedFinish - testSkipLimit / 2;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(state.clock + testSkipLimit + 1);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
|
|
});
|
|
|
|
it('finds skip backwards out of event', () => {
|
|
const startedAt = 1000;
|
|
const duration = 1000;
|
|
const expectedFinish = startedAt + duration;
|
|
const previousTime = startedAt + testSkipLimit / 2;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(state.clock - testSkipLimit - 1);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
|
|
});
|
|
|
|
it('finds skip forwards out of event across midnight', () => {
|
|
const startedAt = dayInMs - testSkipLimit;
|
|
const expectedFinish = 10;
|
|
const previousTime = dayInMs - 3;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(testSkipLimit - 2);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
|
|
});
|
|
|
|
it('finds skip backwards out of event across midnight', () => {
|
|
const startedAt = dayInMs - testSkipLimit;
|
|
const expectedFinish = 10;
|
|
const previousTime = startedAt + 1;
|
|
|
|
const state = {
|
|
clock: previousTime,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(false);
|
|
|
|
state.clock = asTimeOfDay(state.clock - testSkipLimit - 1);
|
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
|
|
});
|
|
|
|
it('handles events that cross midnight', () => {
|
|
const startedAt = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
|
const expectedFinish = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
|
const clock = 14 * MILLIS_PER_MINUTE + 47 * MILLIS_PER_SECOND; // 00:14:47
|
|
const previousTime = clock - 1021; // ~1 second ago
|
|
|
|
const state = {
|
|
clock,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
// Even though clock < startedAt numerically (00:14 < 23:50),
|
|
// we're inside the overnight event, so this should NOT be a skip
|
|
expect(skippedOutOfEvent(state, previousTime, 1000)).toBe(false);
|
|
});
|
|
|
|
it('correctly detects skip out of an event that crosses midnight', () => {
|
|
// Event 23:50-01:50, clock jumps to 02:00 (outside event)
|
|
const startedAt = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
|
const expectedFinish = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
|
const previousTime = 1 * MILLIS_PER_HOUR + 49 * MILLIS_PER_MINUTE; // 01:49
|
|
const clock = 2 * MILLIS_PER_HOUR; // 02:00 (outside event)
|
|
|
|
const state = {
|
|
clock,
|
|
timer: {
|
|
expectedFinish,
|
|
startedAt,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
// Clock jumped from 01:49 to 02:00 (11 min skip) and is now outside the event
|
|
expect(skippedOutOfEvent(state, previousTime, 1000)).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('normaliseEndTime()', () => {
|
|
const t1 = {
|
|
start: 10,
|
|
end: 20,
|
|
};
|
|
const t1_expected = 20;
|
|
|
|
expect(normaliseEndTime(t1.start, t1.end)).toBe(t1_expected);
|
|
|
|
const t2 = {
|
|
start: 10 + dayInMs,
|
|
end: 20,
|
|
};
|
|
const t2_expected = 20 + dayInMs;
|
|
|
|
expect(normaliseEndTime(t2.start, t2.end)).toBe(t2_expected);
|
|
|
|
const t3 = {
|
|
start: 10,
|
|
end: 10,
|
|
};
|
|
const t3_expected = 10;
|
|
|
|
expect(normaliseEndTime(t3.start, t3.end)).toBe(t3_expected);
|
|
});
|
|
|
|
describe('getRuntimeOffset()', () => {
|
|
it('is the difference between scheduled and when we actually started', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 100,
|
|
dayOffset: 0,
|
|
},
|
|
timer: {
|
|
startedAt: 150,
|
|
addedTime: 0,
|
|
current: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
},
|
|
rundown: {
|
|
actualStart: 150,
|
|
plannedStart: 100,
|
|
currentDay: 0,
|
|
},
|
|
clock: 150,
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(50);
|
|
});
|
|
|
|
it('added time subtracts time offset (positive offset)', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 100,
|
|
dayOffset: 0,
|
|
},
|
|
timer: {
|
|
startedAt: 150, // we started 50ms delayed
|
|
addedTime: 10, // we compensated with 10ms
|
|
current: 10, // we are 10ms into the timer
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
},
|
|
rundown: {
|
|
actualStart: 150,
|
|
plannedStart: 100,
|
|
currentDay: 0,
|
|
},
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(60);
|
|
});
|
|
|
|
it('considers running overtime (negative offset)', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 100,
|
|
timeEnd: 140,
|
|
dayOffset: 0,
|
|
},
|
|
timer: {
|
|
startedAt: 100, // we started ontime
|
|
current: -10, // we are 10 seconds over
|
|
addedTime: 0, // we have not compensated with added time
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
},
|
|
rundown: {
|
|
actualStart: 100,
|
|
plannedStart: 100,
|
|
currentDay: 0,
|
|
},
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(10);
|
|
});
|
|
|
|
it('paused time is delayed time (negative offset)', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 100,
|
|
timeEnd: 150,
|
|
dayOffset: 0,
|
|
},
|
|
clock: 150,
|
|
timer: {
|
|
startedAt: 100, // started on time
|
|
current: 25, // are 25ms into it
|
|
addedTime: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: 125, // we have been paused for 25ms (see clock)
|
|
},
|
|
rundown: {
|
|
actualStart: 100,
|
|
plannedStart: 100,
|
|
currentDay: 0,
|
|
},
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(25);
|
|
});
|
|
|
|
it('offset doesnt exist if we havent started', () => {
|
|
const state = {
|
|
clock: 78480789,
|
|
eventNow: {
|
|
id: 'd6a2ce',
|
|
timeStart: 77400000,
|
|
timeEnd: 81000000,
|
|
duration: 3600000,
|
|
timeStrategy: 'lock-duration',
|
|
linkStart: false,
|
|
dayOffset: 0,
|
|
},
|
|
rundown: {
|
|
selectedEventIndex: 0,
|
|
numEvents: 2,
|
|
plannedStart: 77400000,
|
|
plannedEnd: 84600000,
|
|
actualStart: null,
|
|
currentDay: 0,
|
|
},
|
|
offset: {
|
|
absolute: -77400000,
|
|
expectedRundownEnd: null,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
current: 3600000,
|
|
duration: 3600000,
|
|
elapsed: null,
|
|
expectedFinish: null,
|
|
playback: 'armed',
|
|
secondaryTimer: null,
|
|
startedAt: null,
|
|
},
|
|
_startDayOffset: 0,
|
|
_timer: { pausedAt: null },
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(0);
|
|
});
|
|
|
|
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
|
|
const state = {
|
|
clock: 80000000, // 22:13:20
|
|
eventNow: {
|
|
id: 'd6a2ce',
|
|
type: 'event',
|
|
title: '',
|
|
timeStart: 77400000, // 21:30:00
|
|
timeEnd: 81000000, // 22:30:00
|
|
duration: 3600000, // 01:00:00
|
|
timeStrategy: TimeStrategy.LockEnd,
|
|
linkStart: false,
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: true,
|
|
dayOffset: 0,
|
|
skip: false,
|
|
note: '',
|
|
colour: '',
|
|
cue: '1',
|
|
revision: 0,
|
|
timeWarning: 120000,
|
|
timeDanger: 60000,
|
|
custom: {},
|
|
delay: 0,
|
|
},
|
|
rundown: {
|
|
selectedEventIndex: 0,
|
|
numEvents: 1,
|
|
plannedStart: 77400000, // 21:30:00
|
|
plannedEnd: 81000000, // 22:30:00
|
|
actualStart: 78000000, // 21:40:00
|
|
currentDay: 0,
|
|
},
|
|
offset: {
|
|
absolute: 0,
|
|
expectedRundownEnd: 81600000, // 22:40:00
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
current: 1600000,
|
|
duration: 3600000,
|
|
elapsed: 2000000,
|
|
expectedFinish: 81600000,
|
|
playback: Playback.Play,
|
|
secondaryTimer: null,
|
|
startedAt: 78000000,
|
|
},
|
|
_startDayOffset: 0,
|
|
_timer: { pausedAt: null },
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(0);
|
|
});
|
|
|
|
it('with time-to-end, offset is the overtime', () => {
|
|
const state = {
|
|
clock: 82000000, // 22:46:40
|
|
eventNow: {
|
|
id: 'd6a2ce',
|
|
type: 'event',
|
|
title: '',
|
|
timeStart: 77400000, // 21:30:00
|
|
timeEnd: 81000000, // 22:30:00
|
|
duration: 3600000, // 01:00:00
|
|
dayOffset: 0,
|
|
timeStrategy: TimeStrategy.LockEnd,
|
|
linkStart: false,
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: true,
|
|
skip: false,
|
|
note: '',
|
|
colour: '',
|
|
cue: '1',
|
|
revision: 0,
|
|
timeWarning: 120000,
|
|
timeDanger: 60000,
|
|
custom: {},
|
|
delay: 0,
|
|
},
|
|
rundown: {
|
|
selectedEventIndex: 0,
|
|
numEvents: 1,
|
|
plannedStart: 77400000, // 21:30:00
|
|
plannedEnd: 81000000, // 22:30:00
|
|
actualStart: 78000000, // 21:40:00
|
|
currentDay: 0,
|
|
},
|
|
offset: {
|
|
absolute: 0,
|
|
expectedRundownEnd: 81600000, // 22:40:00
|
|
},
|
|
timer: {
|
|
addedTime: -200000,
|
|
current: -400000,
|
|
duration: 3600000,
|
|
elapsed: 4000000,
|
|
expectedFinish: 81600000,
|
|
playback: Playback.Play,
|
|
secondaryTimer: null,
|
|
startedAt: 78000000,
|
|
},
|
|
_startDayOffset: 0,
|
|
_timer: { pausedAt: null },
|
|
} as RuntimeState;
|
|
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
|
});
|
|
|
|
it('handles time-to-end started after the end time', () => {
|
|
const state = {
|
|
clock: 82000000, // 22:46:40 <--- starting 16m 40s after the scheduled end
|
|
eventNow: {
|
|
id: 'd6a2ce',
|
|
timeStart: 77400000, // 21:30:00
|
|
timeEnd: 81000000, // 22:30:00
|
|
duration: 3600000, // 01:00:00
|
|
timeStrategy: TimeStrategy.LockEnd,
|
|
linkStart: false,
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: true,
|
|
dayOffset: 0,
|
|
},
|
|
rundown: {
|
|
selectedEventIndex: 0,
|
|
numEvents: 1,
|
|
plannedStart: 77400000, // 21:30:00
|
|
plannedEnd: 81000000, // 22:30:00
|
|
actualStart: 82000000, // 22:46:40 <--- started now
|
|
currentDay: 0,
|
|
},
|
|
offset: {
|
|
absolute: 0,
|
|
expectedRundownEnd: 82000000 + 3600000, // <--- now + duration
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
current: 0,
|
|
duration: 3600000,
|
|
elapsed: 0,
|
|
expectedFinish: 82000000 + 3600000, // <--- now + duration
|
|
playback: Playback.Play,
|
|
secondaryTimer: null,
|
|
startedAt: 82000000, // <--- started now
|
|
},
|
|
_startDayOffset: 0,
|
|
_timer: { pausedAt: null },
|
|
} as RuntimeState;
|
|
|
|
const updateCurrent = getCurrent(state);
|
|
state.timer.current = updateCurrent;
|
|
const { absolute } = getRuntimeOffset(state);
|
|
expect(millisToString(absolute)).toBe('00:16:40');
|
|
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
|
|
});
|
|
});
|
|
|
|
describe('getRuntimeOffset() relative', () => {
|
|
it('relative offset is 0 when starting at the planed time', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 150,
|
|
dayOffset: 0,
|
|
},
|
|
timer: {
|
|
startedAt: 150,
|
|
addedTime: 0,
|
|
current: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
},
|
|
rundown: {
|
|
actualStart: 150,
|
|
plannedStart: 150,
|
|
currentDay: 0,
|
|
},
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute, relative } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(0);
|
|
expect(relative).toBe(0);
|
|
});
|
|
it('relative offset is 0 when starting after the planed time', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 100,
|
|
dayOffset: 0,
|
|
},
|
|
timer: {
|
|
startedAt: 150,
|
|
addedTime: 0,
|
|
current: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
},
|
|
rundown: {
|
|
actualStart: 150,
|
|
plannedStart: 100,
|
|
currentDay: 0,
|
|
},
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute, relative } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(50);
|
|
expect(relative).toBe(0);
|
|
});
|
|
it('relative offset is 0 when starting before the planed time', () => {
|
|
const state = {
|
|
eventNow: {
|
|
id: '1',
|
|
timeStart: 150,
|
|
dayOffset: 0,
|
|
},
|
|
timer: {
|
|
startedAt: 100,
|
|
addedTime: 0,
|
|
current: 0,
|
|
},
|
|
_timer: {
|
|
pausedAt: null,
|
|
},
|
|
rundown: {
|
|
actualStart: 100,
|
|
plannedStart: 150,
|
|
currentDay: 0,
|
|
},
|
|
_startDayOffset: 0,
|
|
} as RuntimeState;
|
|
|
|
const { absolute, relative } = getRuntimeOffset(state);
|
|
expect(absolute).toBe(-50);
|
|
expect(relative).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('getTimerPhase()', () => {
|
|
it('should be None if the timer is not running', () => {
|
|
const state = {
|
|
timer: {
|
|
addedTime: 0,
|
|
current: null,
|
|
duration: null,
|
|
elapsed: null,
|
|
expectedFinish: null,
|
|
playback: Playback.Stop,
|
|
phase: TimerPhase.None,
|
|
secondaryTimer: null,
|
|
startedAt: null,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.None);
|
|
});
|
|
|
|
it('can be in overtime', () => {
|
|
const state = {
|
|
timer: {
|
|
addedTime: 0,
|
|
current: -50,
|
|
duration: 1000,
|
|
playback: Playback.Play,
|
|
},
|
|
eventNow: {
|
|
timeDanger: 100,
|
|
timeWarning: 200,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.Overtime);
|
|
});
|
|
|
|
it('can be danger', () => {
|
|
const state = {
|
|
timer: {
|
|
addedTime: 0,
|
|
current: 0,
|
|
duration: 1000,
|
|
playback: Playback.Play,
|
|
},
|
|
eventNow: {
|
|
timeDanger: 100,
|
|
timeWarning: 200,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.Danger);
|
|
});
|
|
|
|
it('can be warning', () => {
|
|
const state = {
|
|
timer: {
|
|
addedTime: 0,
|
|
current: 150,
|
|
duration: 1000,
|
|
playback: Playback.Play,
|
|
},
|
|
eventNow: {
|
|
timeDanger: 100,
|
|
timeWarning: 200,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.Warning);
|
|
});
|
|
|
|
it('is default if the timer is playing and there is none of the above', () => {
|
|
const state = {
|
|
timer: {
|
|
addedTime: 0,
|
|
current: 250,
|
|
duration: 1000,
|
|
playback: Playback.Play,
|
|
},
|
|
eventNow: {
|
|
timeDanger: 100,
|
|
timeWarning: 200,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.Default);
|
|
});
|
|
|
|
it('#1042 identifies waiting to roll', () => {
|
|
const state = {
|
|
clock: 55691050,
|
|
eventNow: null,
|
|
eventNext: null,
|
|
rundown: {
|
|
selectedEventIndex: null,
|
|
numEvents: 1,
|
|
plannedStart: 55860000,
|
|
plannedEnd: 55880000,
|
|
actualStart: null,
|
|
},
|
|
offset: {
|
|
absolute: 0,
|
|
expectedRundownEnd: null,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
current: null,
|
|
duration: null,
|
|
elapsed: 0,
|
|
expectedFinish: null,
|
|
phase: 'none',
|
|
playback: 'roll',
|
|
secondaryTimer: 168950,
|
|
startedAt: null,
|
|
},
|
|
_timer: {
|
|
forceFinish: null,
|
|
pausedAt: null,
|
|
},
|
|
_rundown: {
|
|
totalDelay: 0,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.Pending);
|
|
});
|
|
|
|
it('#1042 identifies waiting to roll', () => {
|
|
const state = {
|
|
clock: 55691050,
|
|
eventNow: null,
|
|
eventNext: null,
|
|
rundown: {
|
|
selectedEventIndex: null,
|
|
numEvents: 1,
|
|
plannedStart: 55860000,
|
|
plannedEnd: 55880000,
|
|
actualStart: null,
|
|
},
|
|
offset: {
|
|
absolute: 0,
|
|
expectedRundownEnd: null,
|
|
},
|
|
timer: {
|
|
addedTime: 0,
|
|
current: null,
|
|
duration: null,
|
|
elapsed: 0,
|
|
expectedFinish: null,
|
|
phase: 'none',
|
|
playback: 'roll',
|
|
secondaryTimer: 168950,
|
|
startedAt: null,
|
|
},
|
|
_timer: {
|
|
forceFinish: null,
|
|
pausedAt: null,
|
|
},
|
|
_rundown: {
|
|
totalDelay: 0,
|
|
},
|
|
} as RuntimeState;
|
|
|
|
const phase = getTimerPhase(state);
|
|
expect(phase).toBe(TimerPhase.Pending);
|
|
});
|
|
});
|
|
|
|
describe('findDay()', () => {
|
|
test('finds dayOffset', () => {
|
|
//both have 1 hour offset but the clock are on different days
|
|
expect(findDayOffset(0, 23 * MILLIS_PER_HOUR)).toBe(-1); // -> 23
|
|
expect(findDayOffset(0, 13 * MILLIS_PER_HOUR)).toBe(-1); // -> 13
|
|
expect(findDayOffset(0, 12 * MILLIS_PER_HOUR)).toBe(-1); // -> 12
|
|
expect(findDayOffset(0, 11 * MILLIS_PER_HOUR)).toBe(0); // -> 11
|
|
expect(findDayOffset(1 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -1
|
|
|
|
//both have 1 hour offset but the clock are on different days
|
|
expect(findDayOffset(23 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -23
|
|
expect(findDayOffset(13 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -13
|
|
expect(findDayOffset(12 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -12
|
|
expect(findDayOffset(11 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -11
|
|
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
|
|
});
|
|
});
|
|
|
|
describe('getGroupTiming()', () => {
|
|
const makeEvent = (id: string, duration: number, patch: object = {}) =>
|
|
({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent;
|
|
|
|
const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup;
|
|
|
|
const makeEntries = (...entries: OntimeEvent[]) =>
|
|
Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries'];
|
|
|
|
it('splits the scheduled content around the loaded event', () => {
|
|
const group = makeGroup(['1', '2', '3']);
|
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30));
|
|
|
|
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 });
|
|
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 });
|
|
expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 });
|
|
});
|
|
|
|
it('accounts for the gaps between events', () => {
|
|
const group = makeGroup(['1', '2', '3']);
|
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 }));
|
|
|
|
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 });
|
|
// the gap before the loaded event has already passed, so it counts as time spent
|
|
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 });
|
|
});
|
|
|
|
it('splits into values which add up to the group duration', () => {
|
|
const group = makeGroup(['1', '2', '3']);
|
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30));
|
|
const groupDuration = 10 + 20 + 5 + 30;
|
|
|
|
for (const id of ['1', '2', '3']) {
|
|
const timing = getGroupTiming(group, entries, id)!;
|
|
expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration);
|
|
}
|
|
});
|
|
|
|
it('skips entries which are not playable events', () => {
|
|
const group = makeGroup(['1', '2', '3', '4']);
|
|
const entries = {
|
|
...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)),
|
|
'3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' },
|
|
} as Rundown['entries'];
|
|
|
|
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 });
|
|
});
|
|
|
|
it('returns null when the loaded event is not part of the group', () => {
|
|
const group = makeGroup(['1', '2']);
|
|
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20));
|
|
|
|
expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getGroupTimer()', () => {
|
|
const makeState = (patch: {
|
|
useGroupTimer?: boolean;
|
|
duration?: number;
|
|
timing?: { before: number; after: number } | null;
|
|
current?: MaybeNumber;
|
|
elapsed?: MaybeNumber;
|
|
addedTime?: number;
|
|
}) =>
|
|
({
|
|
groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true },
|
|
_groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing,
|
|
timer: {
|
|
current: patch.current ?? null,
|
|
elapsed: patch.elapsed ?? null,
|
|
addedTime: patch.addedTime ?? 0,
|
|
},
|
|
}) as RuntimeState;
|
|
|
|
it('is null when there is no group running', () => {
|
|
expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull();
|
|
});
|
|
|
|
it('is null when the group has not opted in', () => {
|
|
expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull();
|
|
});
|
|
|
|
it('is null when the loaded event is not part of the group', () => {
|
|
expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull();
|
|
});
|
|
|
|
it('offsets the event timer by the content scheduled around it', () => {
|
|
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 });
|
|
|
|
expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 });
|
|
});
|
|
|
|
it('keeps elapsed and current adding up to the total', () => {
|
|
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 });
|
|
const groupTimer = getGroupTimer(state)!;
|
|
|
|
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
|
|
});
|
|
|
|
it('grows the total with the time added to the running event', () => {
|
|
const state = makeState({
|
|
duration: 100,
|
|
timing: { before: 0, after: 0 },
|
|
current: 160,
|
|
elapsed: 0,
|
|
addedTime: 60,
|
|
});
|
|
const groupTimer = getGroupTimer(state)!;
|
|
|
|
expect(groupTimer.duration).toBe(160);
|
|
// elapsed keeps counting up rather than clamping at zero once time is added
|
|
expect(groupTimer.elapsed).toBe(0);
|
|
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
|
|
});
|
|
|
|
it('reports elapsed time while the group is in credit from added time', () => {
|
|
const state = makeState({
|
|
duration: 100,
|
|
timing: { before: 0, after: 0 },
|
|
current: 150,
|
|
elapsed: 10,
|
|
addedTime: 60,
|
|
});
|
|
|
|
expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 });
|
|
});
|
|
|
|
it('goes negative when the group runs into overtime', () => {
|
|
const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 });
|
|
|
|
expect(getGroupTimer(state)?.current).toBe(-15);
|
|
});
|
|
});
|