mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-24 00:19:21 +00:00
refactor(timer): elapsed is active time from start
This commit is contained in:
committed by
Carlos Valente
parent
4f4626a163
commit
161ab3fe13
@@ -5,6 +5,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
|
|||||||
import {
|
import {
|
||||||
findDayOffset,
|
findDayOffset,
|
||||||
getCurrent,
|
getCurrent,
|
||||||
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
@@ -15,6 +16,56 @@ import {
|
|||||||
|
|
||||||
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
|
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()', () => {
|
describe('getExpectedFinish()', () => {
|
||||||
it('is null if we havent started', () => {
|
it('is null if we havent started', () => {
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ describe('isRestorePoint()', () => {
|
|||||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts optional paused duration', () => {
|
||||||
|
const restorePoint: RestorePoint = {
|
||||||
|
playback: Playback.Roll,
|
||||||
|
selectedEventId: '123',
|
||||||
|
startedAt: 1,
|
||||||
|
addedTime: 2,
|
||||||
|
pausedAt: null,
|
||||||
|
pausedDuration: 3000,
|
||||||
|
firstStart: 1,
|
||||||
|
startEpoch: asInstant(1),
|
||||||
|
currentDay: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||||
|
expect(isRestorePoint({ ...restorePoint, pausedDuration: '3000' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
describe('rejects a badly formatted file', () => {
|
describe('rejects a badly formatted file', () => {
|
||||||
it('with invalid playback value', () => {
|
it('with invalid playback value', () => {
|
||||||
const restorePoint = {
|
const restorePoint = {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('pausedDuration' in restorePoint && !is.number(restorePoint.pausedDuration)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
|
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type RestorePoint = {
|
|||||||
startedAt: MaybeNumber;
|
startedAt: MaybeNumber;
|
||||||
addedTime: number;
|
addedTime: number;
|
||||||
pausedAt: MaybeNumber;
|
pausedAt: MaybeNumber;
|
||||||
|
pausedDuration?: number;
|
||||||
firstStart: MaybeNumber;
|
firstStart: MaybeNumber;
|
||||||
startEpoch: Maybe<Instant>;
|
startEpoch: Maybe<Instant>;
|
||||||
currentDay: MaybeNumber;
|
currentDay: MaybeNumber;
|
||||||
|
|||||||
@@ -760,6 +760,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
startedAt: state.timer.startedAt,
|
startedAt: state.timer.startedAt,
|
||||||
addedTime: state.timer.addedTime,
|
addedTime: state.timer.addedTime,
|
||||||
pausedAt: state._timer.pausedAt,
|
pausedAt: state._timer.pausedAt,
|
||||||
|
pausedDuration: state._timer.pausedDuration,
|
||||||
firstStart: state.rundown.actualStart,
|
firstStart: state.rundown.actualStart,
|
||||||
startEpoch: state._startEpoch,
|
startEpoch: state._startEpoch,
|
||||||
currentDay: state.rundown.currentDay,
|
currentDay: state.rundown.currentDay,
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
|
|||||||
* Checks whether we should update the timer values
|
* Checks whether we should update the timer values
|
||||||
* - `current` and `secondaryTimer` trigger on seconds roll over
|
* - `current` and `secondaryTimer` trigger on seconds roll over
|
||||||
* - the rest trigger on any change
|
* - the rest trigger on any change
|
||||||
* - `elapsed` and `expectedFinish` is not checked
|
* - `elapsed` changes alongside `current`
|
||||||
|
* - `expectedFinish` is not checked
|
||||||
*/
|
*/
|
||||||
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
|
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
|
||||||
if (previousValue === undefined) return true;
|
if (previousValue === undefined) return true;
|
||||||
@@ -48,7 +49,7 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
|
|||||||
previousValue.phase !== currentValue.phase ||
|
previousValue.phase !== currentValue.phase ||
|
||||||
previousValue.playback !== currentValue.playback ||
|
previousValue.playback !== currentValue.playback ||
|
||||||
previousValue.startedAt !== currentValue.startedAt
|
previousValue.startedAt !== currentValue.startedAt
|
||||||
// elapsed - this would be the direct invert of current value so no need to check
|
// elapsed - this is derived from current / duration / addedTime, so the fields above cover it
|
||||||
// expectedFinish - this will be moved out by the current value going into over time, no need to check
|
// expectedFinish - this will be moved out by the current value going into over time, no need to check
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,33 @@ export function getCurrent(state: RuntimeState): number {
|
|||||||
return startedAt + duration + addedTime - clock - correctDay;
|
return startedAt + duration + addedTime - clock - correctDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates active time elapsed since the timer started.
|
||||||
|
*/
|
||||||
|
export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||||
|
const { clock } = state;
|
||||||
|
const { startedAt } = state.timer;
|
||||||
|
const { pausedAt, pausedDuration } = state._timer;
|
||||||
|
|
||||||
|
if (startedAt === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceClock = pausedAt ?? clock;
|
||||||
|
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||||
|
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||||
|
|
||||||
|
return Math.max(0, activeElapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
||||||
|
if (clock < startedAt) {
|
||||||
|
return clock + dayInMs - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clock - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether we have skipped out of the event
|
* Checks whether we have skipped out of the event
|
||||||
* @param {RuntimeState} state runtime state
|
* @param {RuntimeState} state runtime state
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const baseState: RuntimeState = {
|
|||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
pausedDuration: 0,
|
||||||
secondaryTarget: null,
|
secondaryTarget: null,
|
||||||
hasFinished: false,
|
hasFinished: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
|||||||
return {
|
return {
|
||||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||||
|
getAutomation: vi.fn().mockReturnValue({
|
||||||
|
enabledAutomations: false,
|
||||||
|
enabledOscIn: false,
|
||||||
|
oscPortIn: 0,
|
||||||
|
triggers: [],
|
||||||
|
automations: {},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -168,6 +175,79 @@ describe('mutation on runtimeState', () => {
|
|||||||
});
|
});
|
||||||
expect(newState.rundown.actualStart).toBeNull();
|
expect(newState.rundown.actualStart).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('elapsed remains active time when adding and removing time', async () => {
|
||||||
|
clearState();
|
||||||
|
const event = {
|
||||||
|
...mockEvent,
|
||||||
|
id: 'elapsed-add-time',
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10 * MILLIS_PER_MINUTE,
|
||||||
|
duration: 10 * MILLIS_PER_MINUTE,
|
||||||
|
};
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: { [event.id]: event },
|
||||||
|
order: [event.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
const { metadata, rundown } = rundownCache.get();
|
||||||
|
vi.setSystemTime('jan 1 00:00');
|
||||||
|
load(event, rundown, metadata);
|
||||||
|
start();
|
||||||
|
|
||||||
|
vi.setSystemTime('jan 1 00:02');
|
||||||
|
update();
|
||||||
|
|
||||||
|
addTime(5 * MILLIS_PER_MINUTE);
|
||||||
|
let state = getState();
|
||||||
|
expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE);
|
||||||
|
expect(state.timer.duration! - state.timer.current!).toBe(-3 * MILLIS_PER_MINUTE);
|
||||||
|
|
||||||
|
addTime(-8 * MILLIS_PER_MINUTE);
|
||||||
|
state = getState();
|
||||||
|
expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE);
|
||||||
|
expect(state.timer.duration! - state.timer.current!).toBe(5 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('elapsed excludes time spent paused after resume', async () => {
|
||||||
|
clearState();
|
||||||
|
const event = {
|
||||||
|
...mockEvent,
|
||||||
|
id: 'elapsed-pause',
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 10 * MILLIS_PER_MINUTE,
|
||||||
|
duration: 10 * MILLIS_PER_MINUTE,
|
||||||
|
};
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: { [event.id]: event },
|
||||||
|
order: [event.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
const { metadata, rundown } = rundownCache.get();
|
||||||
|
vi.setSystemTime('jan 1 00:00');
|
||||||
|
load(event, rundown, metadata);
|
||||||
|
start();
|
||||||
|
|
||||||
|
vi.setSystemTime('jan 1 00:02');
|
||||||
|
update();
|
||||||
|
pause();
|
||||||
|
|
||||||
|
vi.setSystemTime('jan 1 00:07');
|
||||||
|
start();
|
||||||
|
let state = getState();
|
||||||
|
expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE);
|
||||||
|
|
||||||
|
vi.setSystemTime('jan 1 00:08');
|
||||||
|
update();
|
||||||
|
state = getState();
|
||||||
|
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runtime offset', async () => {
|
test('runtime offset', async () => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
|||||||
import {
|
import {
|
||||||
findDayOffset,
|
findDayOffset,
|
||||||
getCurrent,
|
getCurrent,
|
||||||
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
@@ -62,6 +63,7 @@ export type RuntimeState = {
|
|||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||||
pausedAt: Maybe<TimeOfDay>;
|
pausedAt: Maybe<TimeOfDay>;
|
||||||
|
pausedDuration: number;
|
||||||
secondaryTarget: Maybe<TimeOfDay>;
|
secondaryTarget: Maybe<TimeOfDay>;
|
||||||
hasFinished: boolean;
|
hasFinished: boolean;
|
||||||
};
|
};
|
||||||
@@ -87,6 +89,7 @@ const runtimeState: RuntimeState = {
|
|||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: null,
|
forceFinish: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
pausedDuration: 0,
|
||||||
secondaryTarget: null,
|
secondaryTarget: null,
|
||||||
hasFinished: false,
|
hasFinished: false,
|
||||||
},
|
},
|
||||||
@@ -138,6 +141,7 @@ export function clearEventData() {
|
|||||||
// when clearing, we maintain the total delay from the rundown
|
// when clearing, we maintain the total delay from the rundown
|
||||||
runtimeState._timer.forceFinish = null;
|
runtimeState._timer.forceFinish = null;
|
||||||
runtimeState._timer.pausedAt = null;
|
runtimeState._timer.pausedAt = null;
|
||||||
|
runtimeState._timer.pausedDuration = 0;
|
||||||
runtimeState._timer.secondaryTarget = null;
|
runtimeState._timer.secondaryTarget = null;
|
||||||
runtimeState._timer.hasFinished = false;
|
runtimeState._timer.hasFinished = false;
|
||||||
}
|
}
|
||||||
@@ -170,6 +174,7 @@ export function clearState() {
|
|||||||
// when clearing, we maintain the total delay from the rundown
|
// when clearing, we maintain the total delay from the rundown
|
||||||
runtimeState._timer.forceFinish = null;
|
runtimeState._timer.forceFinish = null;
|
||||||
runtimeState._timer.pausedAt = null;
|
runtimeState._timer.pausedAt = null;
|
||||||
|
runtimeState._timer.pausedDuration = 0;
|
||||||
runtimeState._timer.secondaryTarget = null;
|
runtimeState._timer.secondaryTarget = null;
|
||||||
runtimeState._timer.hasFinished = false;
|
runtimeState._timer.hasFinished = false;
|
||||||
|
|
||||||
@@ -426,6 +431,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
|||||||
if (state._timer.pausedAt) {
|
if (state._timer.pausedAt) {
|
||||||
const timeToAdd = state.clock - state._timer.pausedAt;
|
const timeToAdd = state.clock - state._timer.pausedAt;
|
||||||
state.timer.addedTime += timeToAdd;
|
state.timer.addedTime += timeToAdd;
|
||||||
|
state._timer.pausedDuration += timeToAdd;
|
||||||
state._timer.pausedAt = null;
|
state._timer.pausedAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +441,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
|||||||
|
|
||||||
state.timer.playback = Playback.Play;
|
state.timer.playback = Playback.Play;
|
||||||
state.timer.expectedFinish = getExpectedFinish(state);
|
state.timer.expectedFinish = getExpectedFinish(state);
|
||||||
state.timer.elapsed = 0;
|
state.timer.elapsed = getElapsed(state);
|
||||||
|
|
||||||
if (state.rundown.actualStart === null) {
|
if (state.rundown.actualStart === null) {
|
||||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||||
@@ -520,6 +526,7 @@ export function addTime(amount: number) {
|
|||||||
// we can update the state after handling the side effects
|
// we can update the state after handling the side effects
|
||||||
runtimeState.timer.addedTime += amount;
|
runtimeState.timer.addedTime += amount;
|
||||||
runtimeState.timer.current += amount;
|
runtimeState.timer.current += amount;
|
||||||
|
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||||
|
|
||||||
// update runtime delays: over - under
|
// update runtime delays: over - under
|
||||||
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
||||||
@@ -575,7 +582,7 @@ export function update(): UpdateResult {
|
|||||||
runtimeState.timer.current = getCurrent(runtimeState);
|
runtimeState.timer.current = getCurrent(runtimeState);
|
||||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||||
|
|
||||||
// update runtime, needs up-to-date timer state
|
// update runtime, needs up-to-date timer state
|
||||||
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
|||||||
addedTime: 0,
|
addedTime: 0,
|
||||||
current: null, // changes on every update
|
current: null, // changes on every update
|
||||||
duration: null, // only changes if event changes
|
duration: null, // only changes if event changes
|
||||||
elapsed: null, // changes on every update
|
elapsed: null, // active time from start, changes on every update
|
||||||
expectedFinish: null, // change can only be initiated by user, can roll over midnight
|
expectedFinish: null, // change can only be initiated by user, can roll over midnight
|
||||||
phase: TimerPhase.None, // can change on update or user action
|
phase: TimerPhase.None, // can change on update or user action
|
||||||
playback: Playback.Stop, // change initiated by user
|
playback: Playback.Stop, // change initiated by user
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type TimerState = {
|
|||||||
current: MaybeNumber;
|
current: MaybeNumber;
|
||||||
/** Total duration of the running event */
|
/** Total duration of the running event */
|
||||||
duration: MaybeNumber;
|
duration: MaybeNumber;
|
||||||
/** Time elapsed since the timer started */
|
/** Active time elapsed since the timer started */
|
||||||
elapsed: MaybeNumber;
|
elapsed: MaybeNumber;
|
||||||
/** Timestamp of the expected finish time */
|
/** Timestamp of the expected finish time */
|
||||||
expectedFinish: MaybeNumber;
|
expectedFinish: MaybeNumber;
|
||||||
|
|||||||
Reference in New Issue
Block a user