From b10c38514f2b5e0b2684aef749c23568d6054837 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 09:55:56 +0000 Subject: [PATCH] refactor: parameterize runtimeState mutators on an explicit state Every mutator in runtimeState.ts now has a *Core function that receives the state it operates on and touches nothing else. The module keeps the singleton and exports thin wrappers with the existing names and signatures, so all call sites (EventTimer, runtime service, rundown service) are unchanged and the hot path gains no allocations. This also removes the partial-injection hazards of the previous seams: start(state) wrote timer.phase, offset and expected times to the module singleton rather than the received state, and stop(state) cleared the singleton. Production behaviour is identical since production always operated on the singleton; the characterisation harness and snapshots are untouched and prove it. The cores can now be unit tested against a plain state object without the singleton, module resets or the rundown cache. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD --- .../src/stores/__tests__/runtimeState.test.ts | 13 +- apps/server/src/stores/runtimeState.ts | 615 ++++++++++-------- 2 files changed, 356 insertions(+), 272 deletions(-) diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 8b802e160..f77f43916 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -11,11 +11,12 @@ import { clearState, getState, load, - loadGroupFlagAndEnd, + loadGroupFlagAndEndCore, pause, resume, roll, start, + startCore, stop, update, } from '../runtimeState.js'; @@ -82,7 +83,7 @@ describe('mutation on runtimeState', () => { await initRundown(makeRundown({}), {}); vi.runAllTimers(); - let success = start(mockState); + let success = startCore(mockState); expect(success).toBe(false); success = pause(); @@ -868,7 +869,7 @@ describe('loadGroupFlagAndEnd()', () => { const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata; - loadGroupFlagAndEnd(rundown, metadata, 2, state); + loadGroupFlagAndEndCore(state, rundown, metadata, 2); expect(state).toMatchObject({ groupNow: rundown.entries[1], @@ -896,7 +897,7 @@ describe('loadGroupFlagAndEnd()', () => { const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; - loadGroupFlagAndEnd(rundown, metadata, 1, state); + loadGroupFlagAndEndCore(state, rundown, metadata, 1); expect(state).toMatchObject({ groupNow: rundown.entries[2], @@ -924,7 +925,7 @@ describe('loadGroupFlagAndEnd()', () => { const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; - loadGroupFlagAndEnd(rundown, metadata, 1, state); + loadGroupFlagAndEndCore(state, rundown, metadata, 1); expect(state).toMatchObject({ groupNow: null, @@ -949,7 +950,7 @@ describe('loadGroupFlagAndEnd()', () => { const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata; - loadGroupFlagAndEnd(rundown, metadata, 0, state); + loadGroupFlagAndEndCore(state, rundown, metadata, 0); expect(state).toMatchObject({ groupNow: null, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index c65799c6e..c27042ac2 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -100,6 +100,13 @@ const runtimeState: RuntimeState = { _startDayOffset: null, }; +/** + * The exported functions below operate on the module singleton. + * They delegate to the *Core functions which contain the logic and + * operate exclusively on the state they receive, so the logic can be + * exercised in isolation + */ + export function getState(): Readonly { // create a shallow copy of the state return { @@ -116,118 +123,142 @@ export function getState(): Readonly { }; } +export function clearEventData() { + clearEventDataCore(runtimeState); +} + /* clear data related to the current event, but leave in place data about the global run state * used when loading a new event but the playback is not interrupted */ -export function clearEventData() { - runtimeState.eventNow = null; - runtimeState.eventNext = null; +export function clearEventDataCore(state: RuntimeState) { + state.eventNow = null; + state.eventNext = null; - runtimeState.offset.absolute = 0; - runtimeState.offset.relative = 0; - runtimeState.offset.expectedFlagStart = null; - runtimeState.offset.expectedGroupEnd = null; - runtimeState.offset.expectedRundownEnd = null; + state.offset.absolute = 0; + state.offset.relative = 0; + state.offset.expectedFlagStart = null; + state.offset.expectedGroupEnd = null; + state.offset.expectedRundownEnd = null; - runtimeState.rundown.selectedEventIndex = null; + state.rundown.selectedEventIndex = null; - runtimeState.timer.playback = Playback.Stop; - runtimeState.clock = timeCore.timeOfDayNow(); - runtimeState.timer = { ...runtimeStorePlaceholder.timer }; + state.timer.playback = Playback.Stop; + state.clock = timeCore.timeOfDayNow(); + state.timer = { ...runtimeStorePlaceholder.timer }; // when clearing, we maintain the total delay from the rundown - runtimeState._timer.forceFinish = null; - runtimeState._timer.pausedAt = null; - runtimeState._timer.secondaryTarget = null; - runtimeState._timer.hasFinished = false; + state._timer.forceFinish = null; + state._timer.pausedAt = null; + state._timer.secondaryTarget = null; + state._timer.hasFinished = false; +} + +export function clearState() { + clearStateCore(runtimeState); } // clear all necessary data when doing a full stop and the event is unloaded -export function clearState() { - runtimeState.eventNow = null; - runtimeState.eventNext = null; - runtimeState.eventFlag = null; - runtimeState._flag = null; +export function clearStateCore(state: RuntimeState) { + state.eventNow = null; + state.eventNext = null; + state.eventFlag = null; + state._flag = null; - runtimeState.groupNow = null; - runtimeState._group = null; + state.groupNow = null; + state._group = null; - runtimeState.rundown.actualStart = null; - runtimeState.rundown.selectedEventIndex = null; + state.rundown.actualStart = null; + state.rundown.selectedEventIndex = null; - runtimeState.offset.absolute = 0; - runtimeState.offset.relative = 0; - runtimeState.offset.expectedRundownEnd = null; - runtimeState.offset.expectedGroupEnd = null; - runtimeState.offset.expectedFlagStart = null; + state.offset.absolute = 0; + state.offset.relative = 0; + state.offset.expectedRundownEnd = null; + state.offset.expectedGroupEnd = null; + state.offset.expectedFlagStart = null; - runtimeState._end = null; + state._end = null; - runtimeState.timer.playback = Playback.Stop; - runtimeState.clock = timeCore.timeOfDayNow(); - runtimeState.timer = { ...runtimeStorePlaceholder.timer }; + state.timer.playback = Playback.Stop; + state.clock = timeCore.timeOfDayNow(); + state.timer = { ...runtimeStorePlaceholder.timer }; // when clearing, we maintain the total delay from the rundown - runtimeState._timer.forceFinish = null; - runtimeState._timer.pausedAt = null; - runtimeState._timer.secondaryTarget = null; - runtimeState._timer.hasFinished = false; + state._timer.forceFinish = null; + state._timer.pausedAt = null; + state._timer.secondaryTarget = null; + state._timer.hasFinished = false; - runtimeState._startEpoch = null; - runtimeState._startDayOffset = null; - runtimeState.rundown.currentDay = null; + state._startEpoch = null; + state._startDayOffset = null; + state.rundown.currentDay = null; } /** * Utility to allow modifying the state from the outside * @param newState */ -function patchTimer(newState: Partial) { +function patchTimerCore(state: RuntimeState, newState: Partial) { for (const key in newState) { - if (key in runtimeState.timer) { + if (key in state.timer) { // @ts-expect-error -- not sure how to type this in a sane way - runtimeState.timer[key] = newState[key]; - } else if (key in runtimeState._timer) { + state.timer[key] = newState[key]; + } else if (key in state._timer) { // in case of a RestorePoint we will receive a pausedAt value // which is needed to resume a paused timer // @ts-expect-error -- not sure how to type this in a sane way - runtimeState._timer[key] = newState[key]; + state._timer[key] = newState[key]; } } } +type RundownData = { + numEvents: number; // length of rundown filtered for timed events + firstStart: MaybeNumber; + lastEnd: MaybeNumber; + totalDelay: number; + totalDuration: number; +}; + +export function updateRundownData(rundownData: RundownData) { + updateRundownDataCore(runtimeState, rundownData); +} + /** * Utility, allows updating data derived from the rundown * @param playableRundown */ -export function updateRundownData(rundownData: { - numEvents: number; // length of rundown filtered for timed events - firstStart: MaybeNumber; - lastEnd: MaybeNumber; - totalDelay: number; - totalDuration: number; -}) { +export function updateRundownDataCore(state: RuntimeState, rundownData: RundownData) { // we keep this in private state since there is no UI use case for it - runtimeState._rundown.totalDelay = rundownData.totalDelay; + state._rundown.totalDelay = rundownData.totalDelay; - runtimeState.rundown.numEvents = rundownData.numEvents; - runtimeState.rundown.plannedStart = rundownData.firstStart; - runtimeState.rundown.plannedEnd = + state.rundown.numEvents = rundownData.numEvents; + state.rundown.plannedStart = rundownData.firstStart; + state.rundown.plannedEnd = rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration; - if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes(); + if (isPlaybackActive(state.timer.playback)) getExpectedTimesCore(state); } -/** - * Loads a given event into state - */ export function load( event: PlayableEvent, rundown: Rundown, metadata: RundownMetadata, initialData?: Partial, ): boolean { - clearEventData(); + return loadCore(runtimeState, event, rundown, metadata, initialData); +} + +/** + * Loads a given event into state + */ +export function loadCore( + state: RuntimeState, + event: PlayableEvent, + rundown: Rundown, + metadata: RundownMetadata, + initialData?: Partial, +): boolean { + clearEventDataCore(state); const { timedEventOrder } = metadata; if (timedEventOrder.length === 0) { @@ -241,19 +272,19 @@ export function load( } // load events in memory along with their data - loadNow(rundown, metadata, eventIndex); - loadNext(rundown, metadata, eventIndex); - loadGroupFlagAndEnd(rundown, metadata, eventIndex); + loadNowCore(state, rundown, metadata, eventIndex); + loadNextCore(state, rundown, metadata, eventIndex); + loadGroupFlagAndEndCore(state, rundown, metadata, eventIndex); // update state - runtimeState.timer.playback = Playback.Armed; - runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd); - runtimeState.timer.current = getCurrent(runtimeState); - runtimeState.rundown.numEvents = metadata.timedEventOrder.length; + state.timer.playback = Playback.Armed; + state.timer.duration = calculateDuration(event.timeStart, event.timeEnd); + state.timer.current = getCurrent(state); + state.rundown.numEvents = metadata.timedEventOrder.length; // patch with potential provided data if (initialData) { - patchTimer(initialData); + patchTimerCore(state, initialData); const startEpoch = initialData?.startEpoch; const firstStart = initialData?.firstStart; const currentDay = initialData?.currentDay; @@ -261,154 +292,187 @@ export function load( (firstStart === null || typeof firstStart === 'number') && (startEpoch === null || typeof startEpoch === 'number') ) { - runtimeState.rundown.actualStart = firstStart; - runtimeState._startEpoch = startEpoch; - if (firstStart !== null && runtimeState.rundown.plannedStart !== null) { - runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart); + state.rundown.actualStart = firstStart; + state._startEpoch = startEpoch; + if (firstStart !== null && state.rundown.plannedStart !== null) { + state._startDayOffset = findDayOffset(state.rundown.plannedStart, firstStart); } if (currentDay !== undefined) { - runtimeState.rundown.currentDay = currentDay; + state.rundown.currentDay = currentDay; } - const { absolute, relative } = getRuntimeOffset(runtimeState); - runtimeState.offset.absolute = absolute; - runtimeState.offset.relative = relative; - getExpectedTimes(); + const { absolute, relative } = getRuntimeOffset(state); + state.offset.absolute = absolute; + state.offset.relative = relative; + getExpectedTimesCore(state); } } - return event.id === runtimeState.eventNow?.id; + return event.id === state.eventNow?.id; +} + +export function loadNow(rundown: Rundown, metadata: RundownMetadata, eventIndex?: MaybeNumber) { + loadNowCore(runtimeState, rundown, metadata, eventIndex); } /** * Loads current event and its public counterpart + * @param eventIndex the index to load, defaults to the currently selected index */ -export function loadNow( +export function loadNowCore( + state: RuntimeState, rundown: Rundown, metadata: RundownMetadata, - eventIndex: MaybeNumber = runtimeState.rundown.selectedEventIndex, + eventIndex?: MaybeNumber, ) { - if (eventIndex === null) { + const index = eventIndex === undefined ? state.rundown.selectedEventIndex : eventIndex; + if (index === null) { // reset the state to indicate there is no selection - runtimeState.rundown.selectedEventIndex = null; - runtimeState.eventNow = null; + state.rundown.selectedEventIndex = null; + state.eventNow = null; return; } - const eventId = metadata.timedEventOrder[eventIndex]; + const eventId = metadata.timedEventOrder[index]; if (!eventId) { - runtimeState.eventNow = null; + state.eventNow = null; return; } const event = rundown.entries[eventId] as PlayableEvent; - runtimeState.rundown.selectedEventIndex = eventIndex; - runtimeState.eventNow = event; + state.rundown.selectedEventIndex = index; + state.eventNow = event; +} + +export function loadNext(rundown: Rundown, metadata: RundownMetadata, eventIndex?: MaybeNumber) { + loadNextCore(runtimeState, rundown, metadata, eventIndex); } /** * Loads the next event and its public counterpart + * @param eventIndex the index to load from, defaults to the currently selected index */ -export function loadNext( +export function loadNextCore( + state: RuntimeState, rundown: Rundown, metadata: RundownMetadata, - eventIndex: MaybeNumber = runtimeState.rundown.selectedEventIndex, + eventIndex?: MaybeNumber, ) { - if (eventIndex === null) { + const index = eventIndex === undefined ? state.rundown.selectedEventIndex : eventIndex; + if (index === null) { // reset the state to indicate there is no future event - runtimeState.eventNext = null; + state.eventNext = null; return; } - const nowPlayableIndex = getPlayableIndexFromTimedIndex(metadata, eventIndex); + const nowPlayableIndex = getPlayableIndexFromTimedIndex(metadata, index); if (nowPlayableIndex === null || nowPlayableIndex > metadata.playableEventOrder.length - 2) { // we cound not find the event now or the event now is the last playable event - runtimeState.eventNext = null; + state.eventNext = null; return; } const nextId = metadata.playableEventOrder[nowPlayableIndex + 1]; if (!nextId) { - runtimeState.eventNext = null; + state.eventNext = null; return; } - runtimeState.eventNext = rundown.entries[nextId] as PlayableEvent; + state.eventNext = rundown.entries[nextId] as PlayableEvent; +} + +export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: Rundown, metadata: RundownMetadata) { + resumeCore(runtimeState, restorePoint, event, rundown, metadata); } /** * Resume from restore point */ -export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: Rundown, metadata: RundownMetadata) { - load(event, rundown, metadata, restorePoint); +export function resumeCore( + state: RuntimeState, + restorePoint: RestorePoint, + event: PlayableEvent, + rundown: Rundown, + metadata: RundownMetadata, +) { + loadCore(state, event, rundown, metadata, restorePoint); +} + +export function updateLoaded(event?: PlayableEvent): string | undefined { + return updateLoadedCore(runtimeState, event); } /** * We only pass an event if we are hot reloading * @param {PlayableEvent} event only passed if we are changing the data if a playing timer */ -export function updateLoaded(event?: PlayableEvent): string | undefined { +export function updateLoadedCore(state: RuntimeState, event?: PlayableEvent): string | undefined { // if there is no event loaded, nothing to do - if (runtimeState.eventNow === null) { + if (state.eventNow === null) { return; } // we only pass an event for hot reloading, ie: the event has changed if (event) { - runtimeState.eventNow = event; + state.eventNow = event; // update data which is duplicate between eventNow and timer objects - runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); - runtimeState.timer.current = getCurrent(runtimeState); - runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + state.timer.duration = calculateDuration(state.eventNow.timeStart, state.eventNow.timeEnd); + state.timer.current = getCurrent(state); + state.timer.expectedFinish = getExpectedFinish(state); // handle edge cases with roll - if (runtimeState.timer.playback === Playback.Roll) { - const offsetClock = runtimeState.clock - runtimeState.offset.absolute; + if (state.timer.playback === Playback.Roll) { + const offsetClock = state.clock - state.offset.absolute; // if waiting to roll, we update the targets and potentially start the timer - if (runtimeState._timer.secondaryTarget !== null) { - if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) { + if (state._timer.secondaryTarget !== null) { + if (state.eventNow.timeStart < offsetClock && offsetClock < state.eventNow.timeEnd) { // if the event is now, we queue a start - runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay; - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; + state._timer.secondaryTarget = state.eventNow.timeStart as TimeOfDay; + state.timer.secondaryTimer = state._timer.secondaryTarget - offsetClock; } else { - runtimeState._timer.secondaryTarget = normaliseRollStart( - runtimeState.eventNow.timeStart, - offsetClock, - ) as TimeOfDay; + state._timer.secondaryTarget = normaliseRollStart(state.eventNow.timeStart, offsetClock) as TimeOfDay; } } } - return runtimeState.eventNow.id; + return state.eventNow.id; } // reset changes to timer progress - runtimeState.timer.playback = Playback.Armed; + state.timer.playback = Playback.Armed; - runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); - runtimeState.timer.current = runtimeState.timer.duration; + state.timer.duration = calculateDuration(state.eventNow.timeStart, state.eventNow.timeEnd); + state.timer.current = state.timer.duration; - runtimeState.timer.startedAt = null; - runtimeState._timer.hasFinished = false; - runtimeState.timer.addedTime = 0; - runtimeState._timer.pausedAt = null; + state.timer.startedAt = null; + state._timer.hasFinished = false; + state.timer.addedTime = 0; + state._timer.pausedAt = null; // this could be looked after by the timer - runtimeState.timer.elapsed = null; - runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + state.timer.elapsed = null; + state.timer.expectedFinish = getExpectedFinish(state); - return runtimeState.eventNow.id; + return state.eventNow.id; +} + +export function updateAll(rundown: Rundown, metadata: RundownMetadata) { + updateAllCore(runtimeState, rundown, metadata); } /** * Used in situations when we want to hot-reload all events without interrupting timer */ -export function updateAll(rundown: Rundown, metadata: RundownMetadata) { +export function updateAllCore(state: RuntimeState, rundown: Rundown, metadata: RundownMetadata) { // event now might have moved so we find the event now id and recalculate the the index again - const eventNowIndex = metadata.timedEventOrder.findIndex((id) => id === runtimeState.eventNow?.id); + const eventNowIndex = metadata.timedEventOrder.findIndex((id) => id === state.eventNow?.id); - loadNow(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); - loadNext(rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); - updateLoaded(runtimeState.eventNow ?? undefined); - loadGroupFlagAndEnd(rundown, metadata, eventNowIndex); + loadNowCore(state, rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); + loadNextCore(state, rundown, metadata, eventNowIndex >= 0 ? eventNowIndex : undefined); + updateLoadedCore(state, state.eventNow ?? undefined); + loadGroupFlagAndEndCore(state, rundown, metadata, eventNowIndex); } -export function start(state: RuntimeState = runtimeState): boolean { +export function start(): boolean { + return startCore(runtimeState); +} + +export function startCore(state: RuntimeState): boolean { if (state.eventNow === null) { return false; } @@ -449,12 +513,12 @@ export function start(state: RuntimeState = runtimeState): boolean { } // update timer phase - runtimeState.timer.phase = getTimerPhase(runtimeState); + state.timer.phase = getTimerPhase(state); // update offset - const { absolute, relative } = getRuntimeOffset(runtimeState); - runtimeState.offset.absolute = absolute; - runtimeState.offset.relative = relative; + const { absolute, relative } = getRuntimeOffset(state); + state.offset.absolute = absolute; + state.offset.relative = relative; // as long as there is a timer, we need an planned end // eslint-disable-next-line no-unused-labels -- dev code path @@ -464,11 +528,15 @@ export function start(state: RuntimeState = runtimeState): boolean { } } - getExpectedTimes(); + getExpectedTimesCore(state); return true; } -export function pause(state: RuntimeState = runtimeState): boolean { +export function pause(): boolean { + return pauseCore(runtimeState); +} + +export function pauseCore(state: RuntimeState): boolean { if (state.timer.playback !== Playback.Play) { return false; } @@ -479,54 +547,62 @@ export function pause(state: RuntimeState = runtimeState): boolean { return true; } -export function stop(state: RuntimeState = runtimeState): boolean { +export function stop(): boolean { + return stopCore(runtimeState); +} + +export function stopCore(state: RuntimeState): boolean { if (state.timer.playback === Playback.Stop) { return false; } - clearState(); + clearStateCore(state); return true; } +export function addTime(amount: number): boolean { + return addTimeCore(runtimeState, amount); +} + /** * Exposes functionality to add user time to the timer externally */ -export function addTime(amount: number) { - if (runtimeState.timer.current === null) { +export function addTimeCore(state: RuntimeState, amount: number): boolean { + if (state.timer.current === null) { return false; } // as long as there is a timer, we need an expected finish // eslint-disable-next-line no-unused-labels -- dev code path DEV: { - if (runtimeState.timer.expectedFinish === null) { + if (state.timer.expectedFinish === null) { throw new Error('runtimeState.addTime: invalid state received'); } } // handle edge cases // !!! we need to handle side effects before updating the state - const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current; + const willGoNegative = amount < 0 && Math.abs(amount) > state.timer.current; - if (willGoNegative && !runtimeState._timer.hasFinished) { + if (willGoNegative && !state._timer.hasFinished) { // set finished time so side effects are triggered - runtimeState._timer.forceFinish = timeCore.timeOfDayNow(); + state._timer.forceFinish = timeCore.timeOfDayNow(); } else { - const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; + const willGoPositive = state.timer.current < 0 && state.timer.current + amount > 0; if (willGoPositive) { - runtimeState._timer.hasFinished = false; + state._timer.hasFinished = false; } } // we can update the state after handling the side effects - runtimeState.timer.addedTime += amount; - runtimeState.timer.current += amount; + state.timer.addedTime += amount; + state.timer.current += amount; // update runtime delays: over - under - const { absolute, relative } = getRuntimeOffset(runtimeState); - runtimeState.offset.absolute = absolute; - runtimeState.offset.relative = relative; - runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); - getExpectedTimes(); + const { absolute, relative } = getRuntimeOffset(state); + state.offset.absolute = absolute; + state.offset.relative = relative; + state.timer.expectedFinish = getExpectedFinish(state); + getExpectedTimesCore(state); return true; } @@ -537,62 +613,65 @@ export type UpdateResult = { }; export function update(): UpdateResult { + return updateCore(runtimeState); +} + +export function updateCore(state: RuntimeState): UpdateResult { // 0. there are some things we always do - const previousClock = runtimeState.clock; + const previousClock = state.clock; const epoch = timeCore.now(); const now = timeCore.toTimeOfDay(epoch); - runtimeState.clock = now; // we update the clock on every update call + state.clock = now; // we update the clock on every update call // 1. is playback idle? - if (!isPlaybackActive(runtimeState.timer.playback)) { + if (!isPlaybackActive(state.timer.playback)) { return updateIfIdle(); } // calculate currentDay from epoch (days elapsed since playback was started) - if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) { - const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch); - runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart; + if (state._startEpoch !== null && state._startDayOffset !== null) { + const daysSinceStart = timeCore.daysSinceStart(state._startEpoch, epoch); + state.rundown.currentDay = state._startDayOffset + daysSinceStart; } // 2. are we waiting to roll? - if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { + if (state.timer.playback === Playback.Roll && state.timer.secondaryTimer !== null) { const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now); return updateIfWaitingToRoll(clockHasCrossedMidnight); } // 3. at this point we know that we are playing an event // reset data - runtimeState.timer.secondaryTimer = null; + state.timer.secondaryTimer = null; // eslint-disable-next-line no-unused-labels -- dev code path DEV: { - if (runtimeState.timer.duration === null) { + if (state.timer.duration === null) { throw new Error('runtimeState.update: invalid state received'); } } // update timer state - runtimeState.timer.current = getCurrent(runtimeState); - runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); - runtimeState.timer.phase = getTimerPhase(runtimeState); - runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current; + state.timer.current = getCurrent(state); + state.timer.expectedFinish = getExpectedFinish(state); + state.timer.phase = getTimerPhase(state); + state.timer.elapsed = state.timer.duration - state.timer.current; // update runtime, needs up-to-date timer state - const { absolute, relative } = getRuntimeOffset(runtimeState); - runtimeState.offset.absolute = absolute; - runtimeState.offset.relative = relative; + const { absolute, relative } = getRuntimeOffset(state); + state.offset.absolute = absolute; + state.offset.relative = relative; const finishedNow = - Boolean(runtimeState._timer.forceFinish) || - (runtimeState.timer.current <= timerConfig.triggerAhead && !runtimeState._timer.hasFinished); + Boolean(state._timer.forceFinish) || (state.timer.current <= timerConfig.triggerAhead && !state._timer.hasFinished); if (finishedNow) { - runtimeState._timer.hasFinished = true; + state._timer.hasFinished = true; } else { - runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); + state.timer.expectedFinish = getExpectedFinish(state); } - getExpectedTimes(); + getExpectedTimesCore(state); return { hasTimerFinished: finishedNow, hasSecondaryTimerFinished: false }; @@ -604,28 +683,25 @@ export function update(): UpdateResult { function updateIfWaitingToRoll(hasCrossedMidnight: boolean) { // eslint-disable-next-line no-unused-labels -- dev code path DEV: { - if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) { + if (state.eventNow === null || state._timer.secondaryTarget === null) { throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received'); } } // account for offset - const offsetClock = runtimeState.clock + runtimeState.offset.absolute; - runtimeState.timer.phase = TimerPhase.Pending; + const offsetClock = state.clock + state.offset.absolute; + state.timer.phase = TimerPhase.Pending; if (hasCrossedMidnight) { // if we crossed midnight, we need to update the target // this is the same logic from the roll function - runtimeState._timer.secondaryTarget = normaliseRollStart( - runtimeState.eventNow.timeStart, - offsetClock, - ) as TimeOfDay; + state._timer.secondaryTarget = normaliseRollStart(state.eventNow.timeStart, offsetClock) as TimeOfDay; } - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock; + state.timer.secondaryTimer = state._timer.secondaryTarget! - offsetClock; return { hasTimerFinished: false, - hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0, + hasSecondaryTimerFinished: state.timer.secondaryTimer <= 0, }; } } @@ -634,89 +710,92 @@ export function roll( rundown: Rundown, metadata: RundownMetadata, offset?: Offset, +): { eventId: MaybeString; didStart: boolean } { + return rollCore(runtimeState, rundown, metadata, offset); +} + +export function rollCore( + state: RuntimeState, + rundown: Rundown, + metadata: RundownMetadata, + offset?: Offset, ): { eventId: MaybeString; didStart: boolean } { // 1. if an event is running, we simply take over the playback - if (runtimeState.timer.playback === Playback.Play && runtimeState.rundown.selectedEventIndex !== null) { - runtimeState.timer.playback = Playback.Roll; - return { eventId: runtimeState.eventNow?.id ?? null, didStart: false }; + if (state.timer.playback === Playback.Play && state.rundown.selectedEventIndex !== null) { + state.timer.playback = Playback.Roll; + return { eventId: state.eventNow?.id ?? null, didStart: false }; } // we will need to do some calculations, update the time first const epoch = timeCore.now(); const now = timeCore.toTimeOfDay(epoch); - runtimeState.clock = now; + state.clock = now; // 2. if there is an event armed, we use it - if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) { + if (state.timer.playback === Playback.Armed || state.timer.phase === TimerPhase.Pending) { // eslint-disable-next-line no-unused-labels -- dev code path DEV: { - if (runtimeState.eventNow === null) { + if (state.eventNow === null) { throw new Error('runtimeState.roll: invalid state received'); } } if (offset) { - runtimeState.offset = { ...offset }; + state.offset = { ...offset }; } - runtimeState.timer.playback = Playback.Roll; + state.timer.playback = Playback.Roll; // account for event that finishes the day after const normalisedEndTime = - runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart - ? runtimeState.eventNow.timeEnd + dayInMs - : runtimeState.eventNow.timeEnd; - runtimeState.timer.expectedFinish = normalisedEndTime; + state.eventNow.timeEnd < state.eventNow.timeStart ? state.eventNow.timeEnd + dayInMs : state.eventNow.timeEnd; + state.timer.expectedFinish = normalisedEndTime; // account for offset - const offsetClock = runtimeState.clock - runtimeState.offset.absolute; + const offsetClock = state.clock - state.offset.absolute; // state catch up - runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); - runtimeState.timer.current = runtimeState.timer.duration; - runtimeState.timer.elapsed = 0; + state.timer.duration = calculateDuration(state.eventNow.timeStart, normalisedEndTime); + state.timer.current = state.timer.duration; + state.timer.elapsed = 0; // check if the event is ready to start or if needs to be pending - const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock); + const isNow = checkIsNow(state.eventNow.timeStart, state.eventNow.timeEnd, offsetClock); if (isNow) { /** * If we are starting an event in roll mode * we backtrace all the start times to the supposed start time of the event */ - const plannedStart = runtimeState.eventNow.timeStart; - runtimeState.timer.startedAt = plannedStart; + const plannedStart = state.eventNow.timeStart; + state.timer.startedAt = plannedStart; // reset the secondary timer to cancel any countdowns - runtimeState.timer.secondaryTimer = null; + state.timer.secondaryTimer = null; - if (runtimeState.groupNow !== null && runtimeState.rundown.actualGroupStart === null) { - runtimeState.rundown.actualGroupStart = plannedStart; + if (state.groupNow !== null && state.rundown.actualGroupStart === null) { + state.rundown.actualGroupStart = plannedStart; } /** * we need to backdate the actual start and start metadata * to prevent adding unintended offset */ - if (runtimeState.rundown.actualStart === null) { - runtimeState.rundown.actualStart = plannedStart; + if (state.rundown.actualStart === null) { + state.rundown.actualStart = plannedStart; // use plannedStart (not clock) because actualStart is backdated to plannedStart - runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) + - runtimeState.eventNow.dayOffset) as Day; + state._startDayOffset = (findDayOffset(state.eventNow.timeStart, plannedStart) + + state.eventNow.dayOffset) as Day; // backdate _startEpoch to when the event conceptually started - const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay); - runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration); + const timeElapsed = timeCore.elapsedTime(state.clock, plannedStart as TimeOfDay); + state._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration); // calculate currentDay from the backdated epoch - runtimeState.rundown.currentDay = - runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch); + state.rundown.currentDay = state._startDayOffset + timeCore.daysSinceStart(state._startEpoch, epoch); } } else { - runtimeState._timer.secondaryTarget = normaliseRollStart( - runtimeState.eventNow.timeStart, - offsetClock, - ) as TimeOfDay; - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; - runtimeState.timer.phase = TimerPhase.Pending; + state._timer.secondaryTarget = normaliseRollStart(state.eventNow.timeStart, offsetClock) as TimeOfDay; + state.timer.secondaryTimer = state._timer.secondaryTarget - offsetClock; + state.timer.phase = TimerPhase.Pending; } - return { eventId: runtimeState.eventNow.id, didStart: isNow }; + return { eventId: state.eventNow.id, didStart: isNow }; } // 3. if there is no event running, we need to find the next event @@ -725,52 +804,52 @@ export function roll( } // we need to persist the current group state across loads - clearEventData(); + clearEventDataCore(state); // account for offset but we only keep it if passed to us if (offset) { - runtimeState.offset = { ...offset }; + state.offset = { ...offset }; } - const offsetClock = runtimeState.clock - runtimeState.offset.absolute; + const offsetClock = state.clock - state.offset.absolute; const { index, isPending } = loadRoll(rundown, metadata, offsetClock); // load events in memory along with their data - loadNow(rundown, metadata, index); - loadNext(rundown, metadata, index); - loadGroupFlagAndEnd(rundown, metadata, index); + loadNowCore(state, rundown, metadata, index); + loadNextCore(state, rundown, metadata, index); + loadGroupFlagAndEndCore(state, rundown, metadata, index); // update roll state - runtimeState.timer.playback = Playback.Roll; - runtimeState.rundown.numEvents = metadata.timedEventOrder.length; + state.timer.playback = Playback.Roll; + state.rundown.numEvents = metadata.timedEventOrder.length; // in roll mode spec, there should always be something to load // as long as playableEvents is not empty // eslint-disable-next-line no-unused-labels -- dev code path DEV: { - if (runtimeState.eventNow === null) { + if (state.eventNow === null) { throw new Error('runtimeState.roll: invalid state received'); } } if (isPending) { // there is nothing now, but something coming up - runtimeState.timer.phase = TimerPhase.Pending; + state.timer.phase = TimerPhase.Pending; // we need to normalise start time in case it is the day after - runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay; - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; + state._timer.secondaryTarget = normaliseRollStart(state.eventNow.timeStart, offsetClock) as TimeOfDay; + state.timer.secondaryTimer = state._timer.secondaryTarget - offsetClock; // preload timer properties - runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); - runtimeState.timer.current = runtimeState.timer.duration; + state.timer.duration = calculateDuration(state.eventNow.timeStart, state.eventNow.timeEnd); + state.timer.current = state.timer.duration; // reset rundown state - the rundown will start fresh when the event begins - runtimeState.rundown.actualStart = null; - runtimeState.rundown.currentDay = null; - runtimeState._startDayOffset = null; - runtimeState._startEpoch = null; + state.rundown.actualStart = null; + state.rundown.currentDay = null; + state._startDayOffset = null; + state._startEpoch = null; - return { eventId: runtimeState.eventNow.id, didStart: false }; + return { eventId: state.eventNow.id, didStart: false }; } /** @@ -781,37 +860,33 @@ export function roll( * when we start in roll mode * we need to backtrace all times to the supposed start time of the event */ - const plannedStart = runtimeState.eventNow.timeStart; + const plannedStart = state.eventNow.timeStart; const endTime = - runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart - ? runtimeState.eventNow.timeEnd + dayInMs - : runtimeState.eventNow.timeEnd; - runtimeState.timer.startedAt = plannedStart; - runtimeState.timer.expectedFinish = endTime; + state.eventNow.timeEnd < state.eventNow.timeStart ? state.eventNow.timeEnd + dayInMs : state.eventNow.timeEnd; + state.timer.startedAt = plannedStart; + state.timer.expectedFinish = endTime; // state catch up - runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime); - runtimeState.timer.current = getCurrent(runtimeState); - runtimeState.timer.elapsed = 0; + state.timer.duration = calculateDuration(state.eventNow.timeStart, endTime); + state.timer.current = getCurrent(state); + state.timer.elapsed = 0; // update runtime - runtimeState.rundown.actualStart = plannedStart; - if (runtimeState.groupNow !== null && runtimeState.rundown.actualGroupStart === null) { - runtimeState.rundown.actualGroupStart = plannedStart; + state.rundown.actualStart = plannedStart; + if (state.groupNow !== null && state.rundown.actualGroupStart === null) { + state.rundown.actualGroupStart = plannedStart; } // update metadata // use plannedStart (not clock) because actualStart is backdated to plannedStart - runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) + - runtimeState.eventNow.dayOffset) as Day; + state._startDayOffset = (findDayOffset(state.eventNow.timeStart, plannedStart) + state.eventNow.dayOffset) as Day; // backdate _startEpoch to when the event conceptually started - const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay); - runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration); + const timeElapsed = timeCore.elapsedTime(state.clock, plannedStart as TimeOfDay); + state._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration); // calculate currentDay from the backdated epoch - runtimeState.rundown.currentDay = (runtimeState._startDayOffset + - timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day; + state.rundown.currentDay = (state._startDayOffset + timeCore.daysSinceStart(state._startEpoch, epoch)) as Day; - return { eventId: runtimeState.eventNow.id, didStart: true }; + return { eventId: state.eventNow.id, didStart: true }; } /** @@ -820,7 +895,7 @@ export function roll( * - offset.expectedGroupEnd * - offset.expectedFlagStart */ -function getExpectedTimes(state = runtimeState) { +export function getExpectedTimesCore(state: RuntimeState) { state.offset.expectedRundownEnd = null; state.offset.expectedGroupEnd = null; state.offset.expectedFlagStart = null; @@ -881,11 +956,15 @@ function getExpectedTimes(state = runtimeState) { } } -export function loadGroupFlagAndEnd( +export function loadGroupFlagAndEnd(rundown: Rundown, metadata: RundownMetadata, currentIndex: MaybeNumber) { + loadGroupFlagAndEndCore(runtimeState, rundown, metadata, currentIndex); +} + +export function loadGroupFlagAndEndCore( + state: RuntimeState, rundown: Rundown, metadata: RundownMetadata, currentIndex: MaybeNumber, - state = runtimeState, // used for testing ) { const previousGroup = state.groupNow?.id; state.groupNow = null; @@ -953,6 +1032,10 @@ export function loadGroupFlagAndEnd( } export function setOffsetMode(mode: OffsetMode) { - runtimeState.offset.mode = mode; - if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes(); + setOffsetModeCore(runtimeState, mode); +} + +export function setOffsetModeCore(state: RuntimeState, mode: OffsetMode) { + state.offset.mode = mode; + if (isPlaybackActive(state.timer.playback)) getExpectedTimesCore(state); }