From 9a7036b49808429a98ad23d9be15b73bed5613c1 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 15:25:46 +0000 Subject: [PATCH 1/3] Simplify timer state storage Makes calculations of timing consistent for all timer types --- meteor/server/migration/X_X_X.ts | 8 +-- .../corelib/src/dataModel/RundownPlaylist.ts | 57 ++++++++++--------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 7056e6c1e6..b7c1b39de9 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -205,13 +205,13 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ }, migrate: async () => { await RundownPlaylists.mutableCollection.updateAsync( - { tTimers: { $exists: false } }, + { tTimers: { $exists: false } } as any, { $set: { tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }, }, diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 0ea7a83fe8..93c4bb769c 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -98,33 +98,11 @@ export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCoun export interface RundownTTimerModeFreeRun { readonly type: 'freeRun' - /** - * Starting time (unix timestamp) - * This may not be the original start time, if the timer has been paused/resumed - */ - startTime: number - /** - * Set to a timestamp to pause the timer at that timestamp - * When unpausing, the `startTime` should be adjusted to account for the paused duration - */ - pauseTime: number | null - /** The direction to count */ - // direction: 'up' | 'down' // TODO: does this make sense? } export interface RundownTTimerModeCountdown { readonly type: 'countdown' /** - * Starting time (unix timestamp) - * This may not be the original start time, if the timer has been paused/resumed - */ - startTime: number - /** - * Set to a timestamp to pause the timer at that timestamp - * When unpausing, the `targetTime` should be adjusted to account for the paused duration - */ - pauseTime: number | null - /** - * The duration of the countdown in milliseconds + * The original duration of the countdown in milliseconds, so that we know what value to reset to */ readonly duration: number @@ -136,9 +114,6 @@ export interface RundownTTimerModeCountdown { export interface RundownTTimerModeTimeOfDay { readonly type: 'timeOfDay' - /** The target timestamp of the timer, in milliseconds */ - targetTime: number - /** * The raw target string of the timer, as provided when setting the timer * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) @@ -151,6 +126,25 @@ export interface RundownTTimerModeTimeOfDay { readonly stopAtZero: boolean } +/** + * Timing state for a timer, optimized for efficient client rendering. + * When running, the client calculates current time from zeroTime. + * When paused, the duration is frozen and sent directly. + */ +export type TimerState = + | { + /** Whether the timer is paused */ + paused: false + /** The absolute timestamp (ms) when the timer reaches/reached zero */ + zeroTime: number + } + | { + /** Whether the timer is paused */ + paused: true + /** The frozen duration value in milliseconds */ + duration: number + } + export type RundownTTimerIndex = 1 | 2 | 3 export interface RundownTTimer { @@ -159,9 +153,18 @@ export interface RundownTTimer { /** A label for the timer */ label: string - /** The current mode of the timer, or null if not configured */ + /** The current mode of the timer, or null if not configured + * + * This defines how the timer behaves + */ mode: RundownTTimerMode | null + /** The current state of the timer, or null if not configured + * + * This contains the information needed to calculate the current time of the timer + */ + state: TimerState | null + /* * Future ideas: * allowUiControl: boolean From 22ebe4a8b74c57f2b8d79e5dfd788ae54625642f Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 16:17:02 +0000 Subject: [PATCH 2/3] Separate T-Timer mode and state State contains the dynamic how much time is left information . Mode contains more static information about the timer's properties etc. --- meteor/__mocks__/defaultCollectionObjects.ts | 6 +- .../context/services/TTimersService.ts | 51 ++++---- packages/job-worker/src/playout/tTimers.ts | 113 +++++++++--------- packages/job-worker/src/rundownPlaylists.ts | 12 +- .../src/__mocks__/defaultCollectionObjects.ts | 6 +- 5 files changed, 90 insertions(+), 98 deletions(-) diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index faec0a06be..9b605fd2ca 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -53,9 +53,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b8ef3c7e21..80a7e0faf9 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -7,7 +7,6 @@ import { assertNever } from '@sofie-automation/corelib/dist/lib' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { - calculateTTimerCurrentTime, createCountdownTTimer, createFreeRunTTimer, createTimeOfDayTTimer, @@ -60,31 +59,35 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } get state(): IPlaylistTTimerState | null { const rawMode = this.#modelTimer.mode - switch (rawMode?.type) { + const rawState = this.#modelTimer.state + + if (!rawMode || !rawState) return null + + const currentTime = rawState.paused ? rawState.duration : rawState.zeroTime - getCurrentTime() + + switch (rawMode.type) { case 'countdown': return { mode: 'countdown', - currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + currentTime, duration: rawMode.duration, - paused: !!rawMode.pauseTime, + paused: rawState.paused, stopAtZero: rawMode.stopAtZero, } case 'freeRun': return { mode: 'freeRun', - currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), - paused: !!rawMode.pauseTime, + currentTime, + paused: rawState.paused, } case 'timeOfDay': return { mode: 'timeOfDay', - currentTime: rawMode.targetTime - getCurrentTime(), - targetTime: rawMode.targetTime, + currentTime, + targetTime: rawState.paused ? 0 : rawState.zeroTime, targetRaw: rawMode.targetRaw, stopAtZero: rawMode.stopAtZero, } - case undefined: - return null default: assertNever(rawMode) return null @@ -108,12 +111,13 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { this.#playoutModel.updateTTimer({ ...this.#modelTimer, mode: null, + state: null, }) } startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createCountdownTTimer(duration, { + ...createCountdownTTimer(duration, { stopAtZero: options?.stopAtZero ?? true, startPaused: options?.startPaused ?? false, }), @@ -122,7 +126,7 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createTimeOfDayTTimer(targetTime, { + ...createTimeOfDayTTimer(targetTime, { stopAtZero: options?.stopAtZero ?? true, }), }) @@ -130,39 +134,30 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { startFreeRun(options?: { startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createFreeRunTTimer({ + ...createFreeRunTTimer({ startPaused: options?.startPaused ?? false, }), }) } pause(): boolean { - const newTimer = pauseTTimer(this.#modelTimer.mode) + const newTimer = pauseTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } resume(): boolean { - const newTimer = resumeTTimer(this.#modelTimer.mode) + const newTimer = resumeTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } restart(): boolean { - const newTimer = restartTTimer(this.#modelTimer.mode) + const newTimer = restartTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } } diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 5477491d71..af86616f82 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -1,4 +1,9 @@ -import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { + RundownTTimerIndex, + RundownTTimerMode, + RundownTTimer, + TimerState, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' @@ -12,16 +17,16 @@ export function validateTTimerIndex(index: number): asserts index is RundownTTim * @param timer Timer to update * @returns If the timer supports pausing, the timer in paused state, otherwise null */ -export function pauseTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { - if (timer?.type === 'countdown' || timer?.type === 'freeRun') { - if (timer.pauseTime) { +export function pauseTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown' || timer.mode.type === 'freeRun') { + if (timer.state.paused) { // Already paused return timer } - return { ...timer, - pauseTime: getCurrentTime(), + state: { paused: true, duration: timer.state.zeroTime - getCurrentTime() }, } } else { return null @@ -33,20 +38,17 @@ export function pauseTTimer(timer: ReadonlyDeep | null): Read * @param timer Timer to update * @returns If the timer supports pausing, the timer in resumed state, otherwise null */ -export function resumeTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { - if (timer?.type === 'countdown' || timer?.type === 'freeRun') { - if (!timer.pauseTime) { +export function resumeTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown' || timer.mode.type === 'freeRun') { + if (!timer.state.paused) { // Already running return timer } - const pausedOffset = timer.startTime - timer.pauseTime - const newStartTime = getCurrentTime() + pausedOffset - return { ...timer, - startTime: newStartTime, - pauseTime: null, + state: { paused: false, zeroTime: timer.state.duration + getCurrentTime() }, } } else { return null @@ -58,21 +60,23 @@ export function resumeTTimer(timer: ReadonlyDeep | null): Rea * @param timer Timer to update * @returns If the timer supports restarting, the restarted timer, otherwise null */ -export function restartTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { - if (timer?.type === 'countdown') { +export function restartTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown') { return { ...timer, - startTime: getCurrentTime(), - pauseTime: timer.pauseTime ? getCurrentTime() : null, + state: timer.state.paused + ? { paused: true, duration: timer.mode.duration } + : { paused: false, zeroTime: getCurrentTime() + timer.mode.duration }, } - } else if (timer?.type === 'timeOfDay') { - const nextTime = calculateNextTimeOfDayTarget(timer.targetRaw) - // If we can't calculate the next time, we can't restart - if (nextTime === null || nextTime === timer.targetTime) return null + } else if (timer.mode.type === 'timeOfDay') { + const nextTime = calculateNextTimeOfDayTarget(timer.mode.targetRaw) + // If we can't calculate the next time, or it's the same, we can't restart + if (nextTime === null || (timer.state.paused ? false : nextTime === timer.state.zeroTime)) return null return { ...timer, - targetTime: nextTime, + state: { paused: false, zeroTime: nextTime }, } } else { return null @@ -80,11 +84,10 @@ export function restartTTimer(timer: ReadonlyDeep | null): Re } /** - * Create a new countdown T-timer - * @param index Timer index + * Create a new countdown T-timer mode and initial state * @param duration Duration in milliseconds * @param options Options for the countdown - * @returns The created T-timer + * @returns The created T-timer mode and state */ export function createCountdownTTimer( duration: number, @@ -92,16 +95,18 @@ export function createCountdownTTimer( stopAtZero: boolean startPaused: boolean } -): ReadonlyDeep { +): { mode: ReadonlyDeep; state: ReadonlyDeep } { if (duration <= 0) throw new Error('Duration must be greater than zero') - const now = getCurrentTime() return { - type: 'countdown', - startTime: now, - pauseTime: options.startPaused ? now : null, - duration, - stopAtZero: !!options.stopAtZero, + mode: { + type: 'countdown', + duration, + stopAtZero: !!options.stopAtZero, + }, + state: options.startPaused + ? { paused: true, duration: duration } + : { paused: false, zeroTime: getCurrentTime() + duration }, } } @@ -110,43 +115,35 @@ export function createTimeOfDayTTimer( options: { stopAtZero: boolean } -): ReadonlyDeep { +): { mode: ReadonlyDeep; state: ReadonlyDeep } { const nextTime = calculateNextTimeOfDayTarget(targetTime) if (nextTime === null) throw new Error('Unable to parse target time for timeOfDay T-timer') return { - type: 'timeOfDay', - targetTime: nextTime, - targetRaw: targetTime, - stopAtZero: !!options.stopAtZero, + mode: { + type: 'timeOfDay', + targetRaw: targetTime, + stopAtZero: !!options.stopAtZero, + }, + state: { paused: false, zeroTime: nextTime }, } } /** - * Create a new free-running T-timer - * @param index Timer index + * Create a new free-running T-timer mode and initial state * @param options Options for the free-run - * @returns The created T-timer + * @returns The created T-timer mode and state */ -export function createFreeRunTTimer(options: { startPaused: boolean }): ReadonlyDeep { +export function createFreeRunTTimer(options: { startPaused: boolean }): { + mode: ReadonlyDeep + state: ReadonlyDeep +} { const now = getCurrentTime() return { - type: 'freeRun', - startTime: now, - pauseTime: options.startPaused ? now : null, - } -} - -/** - * Calculate the current time of a T-timer - * @param startTime The start time of the timer (unix timestamp) - * @param pauseTime The pause time of the timer (unix timestamp) or null if not paused - */ -export function calculateTTimerCurrentTime(startTime: number, pauseTime: number | null): number { - if (pauseTime) { - return pauseTime - startTime - } else { - return getCurrentTime() - startTime + mode: { + type: 'freeRun', + }, + state: options.startPaused ? { paused: true, duration: 0 } : { paused: false, zeroTime: now }, } } diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index eb61a94b06..33faf33e29 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -237,9 +237,9 @@ export function produceRundownPlaylistInfoFromRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], ...clone(existingPlaylist), @@ -338,9 +338,9 @@ function defaultPlaylistForRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], ...clone(existingPlaylist), diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 161bbec448..6ed4b6031b 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -49,9 +49,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } From 75fc3fd7b7f942693d7400ba5ca6595e85742e3e Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 16:31:34 +0000 Subject: [PATCH 3/3] Fix t-timer tests --- .../src/__mocks__/defaultCollectionObjects.ts | 6 +- .../services/__tests__/TTimersService.test.ts | 113 +++-- .../__tests__/externalMessageQueue.test.ts | 12 +- .../syncChangesToPartInstance.test.ts | 6 +- .../src/ingest/__tests__/updateNext.test.ts | 6 +- .../__snapshots__/mosIngest.test.ts.snap | 45 ++ .../__snapshots__/playout.test.ts.snap | 3 + .../src/playout/__tests__/tTimers.test.ts | 415 ++++++++++-------- .../lib/__tests__/rundownTiming.test.ts | 6 +- 9 files changed, 367 insertions(+), 245 deletions(-) diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 8d705cc1b7..0a7478f109 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -46,9 +46,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index 7943a89592..352a48bde3 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -23,9 +23,9 @@ function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownT function createEmptyTTimers(): [RundownTTimer, RundownTTimer, RundownTTimer] { return [ - { index: 1, label: 'Timer 1', mode: null }, - { index: 2, label: 'Timer 2', mode: null }, - { index: 3, label: 'Timer 3', mode: null }, + { index: 1, label: 'Timer 1', mode: null, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, ] } @@ -91,8 +91,10 @@ describe('TTimersService', () => { describe('clearAllTimers', () => { it('should call clearTimer on all timers', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } - tTimers[1].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[1].state = { paused: false, zeroTime: 65000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const service = new TTimersService(mockPlayoutModel) @@ -151,7 +153,8 @@ describe('PlaylistTTimerImpl', () => { it('should return running freeRun state', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -164,7 +167,8 @@ describe('PlaylistTTimerImpl', () => { it('should return paused freeRun state', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: 3000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -179,11 +183,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: null, duration: 60000, stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -200,11 +203,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: 7000, duration: 60000, stopAtZero: false, } + tTimers[0].state = { paused: true, duration: 2000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -221,10 +223,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, // 10 seconds in the future targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -242,10 +244,10 @@ describe('PlaylistTTimerImpl', () => { const targetTimestamp = 1737331200000 tTimers[0].mode = { type: 'timeOfDay', - targetTime: targetTimestamp, targetRaw: targetTimestamp, stopAtZero: false, } + tTimers[0].state = { paused: false, zeroTime: targetTimestamp } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -271,6 +273,7 @@ describe('PlaylistTTimerImpl', () => { index: 1, label: 'New Label', mode: null, + state: null, }) }) }) @@ -278,7 +281,8 @@ describe('PlaylistTTimerImpl', () => { describe('clearTimer', () => { it('should clear the timer mode', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -288,6 +292,7 @@ describe('PlaylistTTimerImpl', () => { index: 1, label: 'Timer 1', mode: null, + state: null, }) }) }) @@ -305,11 +310,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: null, duration: 60000, stopAtZero: true, }, + state: { paused: false, zeroTime: 70000 }, }) }) @@ -325,11 +329,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: 10000, duration: 30000, stopAtZero: false, }, + state: { paused: true, duration: 30000 }, }) }) }) @@ -347,9 +350,8 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 10000, - pauseTime: null, }, + state: { paused: false, zeroTime: 10000 }, }) }) @@ -365,9 +367,8 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 10000, - pauseTime: 10000, }, + state: { paused: true, duration: 0 }, }) }) }) @@ -385,10 +386,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '15:30', stopAtZero: true, }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, }) }) @@ -405,10 +409,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: targetTimestamp, targetRaw: targetTimestamp, stopAtZero: true, }, + state: { + paused: false, + zeroTime: targetTimestamp, + }, }) }) @@ -427,6 +434,10 @@ describe('PlaylistTTimerImpl', () => { targetRaw: '18:00', stopAtZero: false, }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), }) }) @@ -442,10 +453,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: expect.objectContaining({ type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '5:30pm', stopAtZero: true, }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), }) }) @@ -469,7 +483,8 @@ describe('PlaylistTTimerImpl', () => { describe('pause', () => { it('should pause a running freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -481,15 +496,15 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 5000, - pauseTime: 10000, }, + state: { paused: true, duration: -5000 }, }) }) it('should pause a running countdown timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 70000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -501,11 +516,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 5000, - pauseTime: 10000, duration: 60000, stopAtZero: true, }, + state: { paused: true, duration: 60000 }, }) }) @@ -524,10 +538,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -541,7 +555,8 @@ describe('PlaylistTTimerImpl', () => { describe('resume', () => { it('should resume a paused freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: -3000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -553,15 +568,15 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 7000, // adjusted for pause duration - pauseTime: null, }, + state: { paused: false, zeroTime: 7000 }, // adjusted for pause duration }) }) it('should return true but not change a running timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -587,10 +602,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -604,7 +619,8 @@ describe('PlaylistTTimerImpl', () => { describe('restart', () => { it('should restart a countdown timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 40000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -616,11 +632,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, // reset to now - pauseTime: null, duration: 60000, stopAtZero: true, }, + state: { paused: false, zeroTime: 70000 }, // reset to now + duration }) }) @@ -628,11 +643,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: 8000, duration: 60000, stopAtZero: false, } + tTimers[0].state = { paused: true, duration: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -644,17 +658,17 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: 10000, // also reset to now (paused at start) duration: 60000, stopAtZero: false, }, + state: { paused: true, duration: 60000 }, // reset to full duration, paused }) }) it('should return false for freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -668,10 +682,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 5000, // old target time targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -683,10 +697,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '15:30', stopAtZero: true, }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, }) }) @@ -694,10 +711,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 5000, targetRaw: 'invalid-time-string', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index a39d82f7cc..54b97fb011 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -57,9 +57,9 @@ describe('Test external message queue static methods', () => { }, rundownIdsInOrder: [protectString('rundown_1')], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) await context.mockCollections.Rundowns.insertOne({ @@ -207,9 +207,9 @@ describe('Test sending messages to mocked endpoints', () => { }, rundownIdsInOrder: [protectString('rundown_1')], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index 47ddfed664..adf7cbaeab 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -316,9 +316,9 @@ describe('SyncChangesToPartInstancesWorker', () => { timing: { type: PlaylistTimingType.None }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index 91df4cc24e..cc40fff715 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -35,9 +35,9 @@ async function createMockRO(context: MockJobContext): Promise { rundownIdsInOrder: [rundownId], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) diff --git a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap index 45148a92f6..12dd5ff679 100644 --- a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap +++ b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap @@ -20,16 +20,19 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -329,16 +332,19 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -630,16 +636,19 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -952,16 +961,19 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1264,16 +1276,19 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1574,16 +1589,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1852,16 +1870,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2175,16 +2196,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2506,16 +2530,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2820,16 +2847,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3134,16 +3164,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3447,16 +3480,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3753,16 +3789,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -4091,16 +4130,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -4405,16 +4447,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { diff --git a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap index 8017111a4f..690dd9ac31 100644 --- a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap +++ b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap @@ -82,16 +82,19 @@ exports[`Playout API Basic rundown control 4`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts index 144baca1a5..bea1a2c92b 100644 --- a/packages/job-worker/src/playout/__tests__/tTimers.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { useFakeCurrentTime, useRealCurrentTime } from '../../__mocks__/time.js' import { validateTTimerIndex, pauseTTimer, @@ -7,14 +7,10 @@ import { restartTTimer, createCountdownTTimer, createFreeRunTTimer, - calculateTTimerCurrentTime, calculateNextTimeOfDayTarget, createTimeOfDayTTimer, } from '../tTimers.js' -import type { - RundownTTimerMode, - RundownTTimerModeTimeOfDay, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('tTimers utils', () => { beforeEach(() => { @@ -51,48 +47,63 @@ describe('tTimers utils', () => { describe('pauseTTimer', () => { it('should pause a running countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // 60 seconds from now } const result = pauseTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 5000, - pauseTime: 10000, // getCurrentTime() - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 60000 }, // Captured remaining time }) }) it('should pause a running freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // Started 5 seconds ago } const result = pauseTTimer(timer) expect(result).toEqual({ - type: 'freeRun', - startTime: 5000, - pauseTime: 10000, + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // Elapsed time (negative for counting up) }) }) it('should return unchanged countdown timer if already paused', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 7000, // already paused - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // already paused } const result = pauseTTimer(timer) @@ -101,10 +112,13 @@ describe('tTimers utils', () => { }) it('should return unchanged freeRun timer if already paused', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: 7000, // already paused + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 5000 }, // already paused } const result = pauseTTimer(timer) @@ -112,59 +126,77 @@ describe('tTimers utils', () => { expect(result).toBe(timer) // same reference, unchanged }) - it('should return null for null timer', () => { - expect(pauseTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(pauseTTimer(timer)).toBeNull() }) }) describe('resumeTTimer', () => { it('should resume a paused countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 8000, // paused 3 seconds after start - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // 30 seconds remaining } const result = resumeTTimer(timer) - // pausedOffset = 5000 - 8000 = -3000 - // newStartTime = 10000 + (-3000) = 7000 expect(result).toEqual({ - type: 'countdown', - startTime: 7000, // 3 seconds before now - pauseTime: null, - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 40000 }, // now (10000) + duration (30000) }) }) it('should resume a paused freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 2000, - pauseTime: 6000, // paused 4 seconds after start + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // 5 seconds elapsed } const result = resumeTTimer(timer) - // pausedOffset = 2000 - 6000 = -4000 - // newStartTime = 10000 + (-4000) = 6000 expect(result).toEqual({ - type: 'freeRun', - startTime: 6000, // 4 seconds before now - pauseTime: null, + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // now (10000) + duration (-5000) }) }) it('should return countdown timer unchanged if already running', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, // already running - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // already running } const result = resumeTTimer(timer) @@ -173,10 +205,13 @@ describe('tTimers utils', () => { }) it('should return freeRun timer unchanged if already running', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, // already running + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // already running } const result = resumeTTimer(timer) @@ -184,64 +219,93 @@ describe('tTimers utils', () => { expect(result).toBe(timer) // same reference }) - it('should return null for null timer', () => { - expect(resumeTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(resumeTTimer(timer)).toBeNull() }) }) describe('restartTTimer', () => { it('should restart a running countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 40000 }, // Partway through } const result = restartTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, // now - pauseTime: null, - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) }) }) it('should restart a paused countdown timer (stays paused)', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 8000, - duration: 60000, - stopAtZero: false, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: false, + }, + state: { paused: true, duration: 15000 }, // Paused with time remaining } const result = restartTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, // now - pauseTime: 10000, // also now (paused at start) - duration: 60000, - stopAtZero: false, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: false, + }, + state: { paused: true, duration: 60000 }, // Reset to full duration, still paused }) }) it('should return null for freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, } expect(restartTTimer(timer)).toBeNull() }) - it('should return null for null timer', () => { - expect(restartTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(restartTTimer(timer)).toBeNull() }) }) @@ -253,11 +317,12 @@ describe('tTimers utils', () => { }) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) }) }) @@ -268,11 +333,12 @@ describe('tTimers utils', () => { }) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, - pauseTime: 10000, - duration: 30000, - stopAtZero: false, + mode: { + type: 'countdown', + duration: 30000, + stopAtZero: false, + }, + state: { paused: true, duration: 30000 }, }) }) @@ -300,9 +366,10 @@ describe('tTimers utils', () => { const result = createFreeRunTTimer({ startPaused: false }) expect(result).toEqual({ - type: 'freeRun', - startTime: 10000, - pauseTime: null, + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 10000 }, // now }) }) @@ -310,51 +377,14 @@ describe('tTimers utils', () => { const result = createFreeRunTTimer({ startPaused: true }) expect(result).toEqual({ - type: 'freeRun', - startTime: 10000, - pauseTime: 10000, + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 0 }, }) }) }) - describe('calculateTTimerCurrentTime', () => { - it('should calculate time for a running timer', () => { - // Timer started at 5000, current time is 10000 - const result = calculateTTimerCurrentTime(5000, null) - - expect(result).toBe(5000) // 10000 - 5000 - }) - - it('should calculate time for a paused timer', () => { - // Timer started at 5000, paused at 8000 - const result = calculateTTimerCurrentTime(5000, 8000) - - expect(result).toBe(3000) // 8000 - 5000 - }) - - it('should handle timer that just started', () => { - const result = calculateTTimerCurrentTime(10000, null) - - expect(result).toBe(0) - }) - - it('should handle timer paused immediately', () => { - const result = calculateTTimerCurrentTime(10000, 10000) - - expect(result).toBe(0) - }) - - it('should update as time progresses', () => { - const startTime = 5000 - - expect(calculateTTimerCurrentTime(startTime, null)).toBe(5000) - - adjustFakeTime(2000) // Now at 12000 - - expect(calculateTTimerCurrentTime(startTime, null)).toBe(7000) - }) - }) - describe('calculateNextTimeOfDayTarget', () => { // Mock date to 2026-01-19 10:00:00 UTC for predictable tests const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() @@ -510,10 +540,15 @@ describe('tTimers utils', () => { const result = createTimeOfDayTTimer('15:30', { stopAtZero: true }) expect(result).toEqual({ - type: 'timeOfDay', - stopAtZero: true, - targetTime: expect.any(Number), // new target time - targetRaw: '15:30', + mode: { + type: 'timeOfDay', + stopAtZero: true, + targetRaw: '15:30', + }, + state: { + paused: false, + zeroTime: expect.any(Number), // Parsed target time + }, }) }) @@ -522,10 +557,15 @@ describe('tTimers utils', () => { const result = createTimeOfDayTTimer(timestamp, { stopAtZero: false }) expect(result).toEqual({ - type: 'timeOfDay', - targetTime: timestamp, - targetRaw: timestamp, - stopAtZero: false, + mode: { + type: 'timeOfDay', + targetRaw: timestamp, + stopAtZero: false, + }, + state: { + paused: false, + zeroTime: timestamp, + }, }) }) @@ -556,28 +596,41 @@ describe('tTimers utils', () => { }) it('should restart a timeOfDay timer with valid targetRaw', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: '15:30', - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) - expect(result).toEqual({ - ...timer, - targetTime: expect.any(Number), // new target time + expect(result).not.toBeNull() + expect(result?.mode).toEqual(timer.mode) + expect(result?.state).toEqual({ + paused: false, + zeroTime: expect.any(Number), // new target time }) - expect((result as RundownTTimerModeTimeOfDay).targetTime).toBeGreaterThan(timer.targetTime) + if (!result || !result.state || result.state.paused) { + throw new Error('Expected running timeOfDay timer state') + } + expect(result.state.zeroTime).toBeGreaterThan(1737300000000) }) it('should return null for timeOfDay timer with invalid targetRaw', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: 'invalid', - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 'invalid', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) @@ -586,11 +639,15 @@ describe('tTimers utils', () => { }) it('should return null for timeOfDay timer with unix timestamp', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: 1737300000000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 1737300000000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index f57f33d4ed..a7cabd427e 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -30,9 +30,9 @@ function makeMockPlaylist(): DBRundownPlaylist { rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) }