diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index fa8a8934ed..faec0a06be 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -52,6 +52,11 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: 'none' as any, }, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index c61e36bdcb..9133eb4439 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -618,6 +618,7 @@ describe('cronjobs', () => { type: PlaylistTimingType.None, }, activationId: protectString(''), + tTimers: [] as any, }) return { diff --git a/meteor/server/api/__tests__/externalMessageQueue.test.ts b/meteor/server/api/__tests__/externalMessageQueue.test.ts index 801220a8f8..1b5fb53f93 100644 --- a/meteor/server/api/__tests__/externalMessageQueue.test.ts +++ b/meteor/server/api/__tests__/externalMessageQueue.test.ts @@ -41,6 +41,7 @@ describe('Test external message queue static methods', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [protectString('rundown_1')], + tTimers: [] as any, }) await Rundowns.mutableCollection.insertAsync({ _id: protectString('rundown_1'), diff --git a/meteor/server/api/__tests__/peripheralDevice.test.ts b/meteor/server/api/__tests__/peripheralDevice.test.ts index 3c819cf20a..594c44049c 100644 --- a/meteor/server/api/__tests__/peripheralDevice.test.ts +++ b/meteor/server/api/__tests__/peripheralDevice.test.ts @@ -78,6 +78,7 @@ describe('test peripheralDevice general API methods', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [rundownID], + tTimers: [] as any, }) await Rundowns.mutableCollection.insertAsync({ _id: rundownID, diff --git a/meteor/server/api/rest/v1/__tests__/playlists.spec.ts b/meteor/server/api/rest/v1/__tests__/playlists.spec.ts new file mode 100644 index 0000000000..70f8b0f7c7 --- /dev/null +++ b/meteor/server/api/rest/v1/__tests__/playlists.spec.ts @@ -0,0 +1,77 @@ +import { registerRoutes } from '../playlists' +import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { PlaylistsRestAPI } from '../../../../lib/rest/v1' + +describe('Playlists REST API Routes', () => { + let mockRegisterRoute: jest.Mock + let mockServerAPI: jest.Mocked + + beforeEach(() => { + mockRegisterRoute = jest.fn() + mockServerAPI = { + tTimerStartCountdown: jest.fn().mockResolvedValue(ClientAPI.responseSuccess(undefined)), + tTimerStartFreeRun: jest.fn().mockResolvedValue(ClientAPI.responseSuccess(undefined)), + tTimerPause: jest.fn().mockResolvedValue(ClientAPI.responseSuccess(undefined)), + tTimerResume: jest.fn().mockResolvedValue(ClientAPI.responseSuccess(undefined)), + tTimerRestart: jest.fn().mockResolvedValue(ClientAPI.responseSuccess(undefined)), + } as any + + registerRoutes(mockRegisterRoute) + }) + + test('should register T-timer countdown route', () => { + const countdownRoute = mockRegisterRoute.mock.calls.find( + (call) => call[1] === '/playlists/:playlistId/t-timers/:timerIndex/countdown' + ) + expect(countdownRoute).toBeDefined() + expect(countdownRoute[0]).toBe('post') + }) + + test('T-timer countdown handler should call serverAPI.tTimerStartCountdown', async () => { + const countdownRoute = mockRegisterRoute.mock.calls.find( + (call) => call[1] === '/playlists/:playlistId/t-timers/:timerIndex/countdown' + ) + const handler = countdownRoute[4] + + const params = { playlistId: 'playlist0', timerIndex: '1' } + const body = { duration: 60, stopAtZero: true, startPaused: false } + const connection = {} as any + const event = 'test-event' + + await handler(mockServerAPI, connection, event, params, body) + + expect(mockServerAPI.tTimerStartCountdown).toHaveBeenCalledWith( + connection, + event, + protectString('playlist0'), + 1, + 60, + true, + false + ) + }) + + test('should register T-timer pause route', () => { + const pauseRoute = mockRegisterRoute.mock.calls.find( + (call) => call[1] === '/playlists/:playlistId/t-timers/:timerIndex/pause' + ) + expect(pauseRoute).toBeDefined() + expect(pauseRoute[0]).toBe('post') + }) + + test('T-timer pause handler should call serverAPI.tTimerPause', async () => { + const pauseRoute = mockRegisterRoute.mock.calls.find( + (call) => call[1] === '/playlists/:playlistId/t-timers/:timerIndex/pause' + ) + const handler = pauseRoute[4] + + const params = { playlistId: 'playlist0', timerIndex: '2' } + const connection = {} as any + const event = 'test-event' + + await handler(mockServerAPI, connection, event, params, {}) + + expect(mockServerAPI.tTimerPause).toHaveBeenCalledWith(connection, event, protectString('playlist0'), 2) + }) +}) diff --git a/meteor/server/api/rest/v1/playlists.ts b/meteor/server/api/rest/v1/playlists.ts index 2616f2e6f9..0f03db9a18 100644 --- a/meteor/server/api/rest/v1/playlists.ts +++ b/meteor/server/api/rest/v1/playlists.ts @@ -13,6 +13,7 @@ import { RundownPlaylistId, SegmentId, } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Match, check } from '../../../lib/check' import { PlaylistsRestAPI } from '../../../lib/rest/v1' import { Meteor } from 'meteor/meteor' @@ -544,6 +545,133 @@ class PlaylistsServerAPI implements PlaylistsRestAPI { } ) } + + async tTimerStartCountdown( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex, + duration: number, + stopAtZero?: boolean, + startPaused?: boolean + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this.context.getMethodContext(connection), + event, + getCurrentTime(), + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(timerIndex, Number) + check(duration, Number) + check(stopAtZero, Match.Optional(Boolean)) + check(startPaused, Match.Optional(Boolean)) + }, + StudioJobs.TTimerStartCountdown, + { + playlistId: rundownPlaylistId, + timerIndex, + duration, + stopAtZero: !!stopAtZero, + startPaused: !!startPaused, + } + ) + } + + async tTimerStartFreeRun( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex, + startPaused?: boolean + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this.context.getMethodContext(connection), + event, + getCurrentTime(), + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(timerIndex, Number) + check(startPaused, Match.Optional(Boolean)) + }, + StudioJobs.TTimerStartFreeRun, + { + playlistId: rundownPlaylistId, + timerIndex, + startPaused: !!startPaused, + } + ) + } + + async tTimerPause( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this.context.getMethodContext(connection), + event, + getCurrentTime(), + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(timerIndex, Number) + }, + StudioJobs.TTimerPause, + { + playlistId: rundownPlaylistId, + timerIndex, + } + ) + } + + async tTimerResume( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this.context.getMethodContext(connection), + event, + getCurrentTime(), + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(timerIndex, Number) + }, + StudioJobs.TTimerResume, + { + playlistId: rundownPlaylistId, + timerIndex, + } + ) + } + + async tTimerRestart( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex + ): Promise> { + return ServerClientAPI.runUserActionInLogForPlaylistOnWorker( + this.context.getMethodContext(connection), + event, + getCurrentTime(), + rundownPlaylistId, + () => { + check(rundownPlaylistId, String) + check(timerIndex, Number) + }, + StudioJobs.TTimerRestart, + { + playlistId: rundownPlaylistId, + timerIndex, + } + ) + } } class PlaylistsAPIFactory implements APIFactory { @@ -877,4 +1005,102 @@ export function registerRoutes(registerRoute: APIRegisterHook) return await serverAPI.recallStickyPiece(connection, event, playlistId, sourceLayerId) } ) + + registerRoute< + { playlistId: string; timerIndex: string }, + { duration: number; stopAtZero?: boolean; startPaused?: boolean }, + void + >( + 'post', + '/playlists/:playlistId/t-timers/:timerIndex/countdown', + new Map([[404, [UserErrorMessage.RundownPlaylistNotFound]]]), + playlistsAPIFactory, + async (serverAPI, connection, event, params, body) => { + const rundownPlaylistId = protectString(params.playlistId) + const timerIndex = Number.parseInt(params.timerIndex) as RundownTTimerIndex + logger.info(`API POST: t-timer countdown ${rundownPlaylistId} ${timerIndex}`) + + check(rundownPlaylistId, String) + check(timerIndex, Number) + return await serverAPI.tTimerStartCountdown( + connection, + event, + rundownPlaylistId, + timerIndex, + body.duration, + body.stopAtZero, + body.startPaused + ) + } + ) + + registerRoute<{ playlistId: string; timerIndex: string }, { startPaused?: boolean }, void>( + 'post', + '/playlists/:playlistId/t-timers/:timerIndex/free-run', + new Map([[404, [UserErrorMessage.RundownPlaylistNotFound]]]), + playlistsAPIFactory, + async (serverAPI, connection, event, params, body) => { + const rundownPlaylistId = protectString(params.playlistId) + const timerIndex = Number.parseInt(params.timerIndex) as RundownTTimerIndex + logger.info(`API POST: t-timer free-run ${rundownPlaylistId} ${timerIndex}`) + + check(rundownPlaylistId, String) + check(timerIndex, Number) + return await serverAPI.tTimerStartFreeRun( + connection, + event, + rundownPlaylistId, + timerIndex, + body.startPaused + ) + } + ) + + registerRoute<{ playlistId: string; timerIndex: string }, never, void>( + 'post', + '/playlists/:playlistId/t-timers/:timerIndex/pause', + new Map([[404, [UserErrorMessage.RundownPlaylistNotFound]]]), + playlistsAPIFactory, + async (serverAPI, connection, event, params, _) => { + const rundownPlaylistId = protectString(params.playlistId) + const timerIndex = Number.parseInt(params.timerIndex) as RundownTTimerIndex + logger.info(`API POST: t-timer pause ${rundownPlaylistId} ${timerIndex}`) + + check(rundownPlaylistId, String) + check(timerIndex, Number) + return await serverAPI.tTimerPause(connection, event, rundownPlaylistId, timerIndex) + } + ) + + registerRoute<{ playlistId: string; timerIndex: string }, never, void>( + 'post', + '/playlists/:playlistId/t-timers/:timerIndex/resume', + new Map([[404, [UserErrorMessage.RundownPlaylistNotFound]]]), + playlistsAPIFactory, + async (serverAPI, connection, event, params, _) => { + const rundownPlaylistId = protectString(params.playlistId) + const timerIndex = Number.parseInt(params.timerIndex) as RundownTTimerIndex + logger.info(`API POST: t-timer resume ${rundownPlaylistId} ${timerIndex}`) + + check(rundownPlaylistId, String) + check(timerIndex, Number) + return await serverAPI.tTimerResume(connection, event, rundownPlaylistId, timerIndex) + } + ) + + registerRoute<{ playlistId: string; timerIndex: string }, never, void>( + 'post', + '/playlists/:playlistId/t-timers/:timerIndex/restart', + new Map([[404, [UserErrorMessage.RundownPlaylistNotFound]]]), + playlistsAPIFactory, + async (serverAPI, connection, event, params, _) => { + const rundownPlaylistId = protectString(params.playlistId) + const timerIndex = Number.parseInt(params.timerIndex) as RundownTTimerIndex + logger.info(`API POST: t-timer restart ${rundownPlaylistId} ${timerIndex}`) + + check(rundownPlaylistId, String) + check(timerIndex, Number) + return await serverAPI.tTimerRestart(connection, event, rundownPlaylistId, timerIndex) + } + ) } diff --git a/meteor/server/lib/rest/v1/playlists.ts b/meteor/server/lib/rest/v1/playlists.ts index 74a60a2976..83069bea4d 100644 --- a/meteor/server/lib/rest/v1/playlists.ts +++ b/meteor/server/lib/rest/v1/playlists.ts @@ -11,6 +11,7 @@ import { SegmentId, } from '@sofie-automation/corelib/dist/dataModel/Ids' import { QueueNextSegmentResult } from '@sofie-automation/corelib/dist/worker/studio' +import { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { Meteor } from 'meteor/meteor' /* ************************************************************************* @@ -261,4 +262,78 @@ export interface PlaylistsRestAPI { rundownPlaylistId: RundownPlaylistId, sourceLayerId: string ): Promise> + /** + * Configure a T-timer as a countdown. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param timerIndex Index of the timer (1-3). + * @param duration Duration in seconds. + * @param stopAtZero Whether to stop at zero. + * @param startPaused Whether to start paused. + */ + tTimerStartCountdown( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex, + duration: number, + stopAtZero?: boolean, + startPaused?: boolean + ): Promise> + + /** + * Configure a T-timer as a free-running timer. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param timerIndex Index of the timer (1-3). + * @param startPaused Whether to start paused. + */ + tTimerStartFreeRun( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex, + startPaused?: boolean + ): Promise> + /** + * Pause a T-timer. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param timerIndex Index of the timer (1-3). + */ + tTimerPause( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex + ): Promise> + /** + * Resume a T-timer. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param timerIndex Index of the timer (1-3). + */ + tTimerResume( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex + ): Promise> + /** + * Restart a T-timer. + * @param connection Connection data including client and header details + * @param event User event string + * @param rundownPlaylistId Target Playlist. + * @param timerIndex Index of the timer (1-3). + */ + tTimerRestart( + connection: Meteor.Connection, + event: string, + rundownPlaylistId: RundownPlaylistId, + timerIndex: RundownTTimerIndex + ): Promise> } diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 7c7cef98e2..a3cd98e920 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -1,7 +1,7 @@ import { addMigrationSteps } from './databaseMigration' import { CURRENT_SYSTEM_VERSION } from './currentSystemVersion' import { MongoInternals } from 'meteor/mongo' -import { Studios } from '../collections' +import { RundownPlaylists, Studios } from '../collections' /* * ************************************************************************************** @@ -59,4 +59,29 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ // Do nothing, the user will have to resolve this manually }, }, + + { + id: 'Add T-timers to RundownPlaylist', + canBeRunAutomatically: true, + validate: async () => { + const playlistCount = await RundownPlaylists.countDocuments({ tTimers: { $exists: false } }) + if (playlistCount > 1) return `There are ${playlistCount} RundownPlaylists without T-timers` + return false + }, + migrate: async () => { + await RundownPlaylists.mutableCollection.updateAsync( + { tTimers: { $exists: false } }, + { + $set: { + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], + }, + }, + { multi: true } + ) + }, + }, ]) diff --git a/packages/blueprints-integration/src/context/adlibActionContext.ts b/packages/blueprints-integration/src/context/adlibActionContext.ts index 4435d76b41..afca8bcff0 100644 --- a/packages/blueprints-integration/src/context/adlibActionContext.ts +++ b/packages/blueprints-integration/src/context/adlibActionContext.ts @@ -5,6 +5,7 @@ import { IPartAndPieceActionContext } from './partsAndPieceActionContext.js' import { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import { IBlueprintPart, IBlueprintPartInstance, IBlueprintPiece } from '../index.js' import { IRouteSetMethods } from './routeSetContext.js' +import { ITTimersContext } from './tTimersContext.js' /** Actions */ export interface IDataStoreMethods { @@ -26,7 +27,8 @@ export interface IActionExecutionContext IDataStoreMethods, IPartAndPieceActionContext, IExecuteTSRActionsContext, - IRouteSetMethods { + IRouteSetMethods, + ITTimersContext { /** Fetch the showstyle config for the specified part */ // getNextShowStyleConfig(): Readonly<{ [key: string]: ConfigItemValue }> diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index 9e729ce402..ee7b3aa29e 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -12,12 +12,13 @@ import { } from '../index.js' import { BlueprintQuickLookInfo } from './quickLoopInfo.js' import { ReadonlyDeep } from 'type-fest' +import type { ITTimersContext } from './tTimersContext.js' /** * Context in which 'current' is the part currently on air, and 'next' is the partInstance being set as Next * This is similar to `IPartAndPieceActionContext`, but has more limits on what is allowed to be changed. */ -export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext { +export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContext, ITTimersContext { /** Information about the current loop, if there is one */ readonly quickLoopInfo: BlueprintQuickLookInfo | null diff --git a/packages/blueprints-integration/src/context/onTakeContext.ts b/packages/blueprints-integration/src/context/onTakeContext.ts index 3918bdd7ee..50606a37ba 100644 --- a/packages/blueprints-integration/src/context/onTakeContext.ts +++ b/packages/blueprints-integration/src/context/onTakeContext.ts @@ -1,6 +1,7 @@ import { IEventContext, IShowStyleUserContext, Time } from '../index.js' import { IPartAndPieceActionContext } from './partsAndPieceActionContext.js' import { IExecuteTSRActionsContext } from './executeTsrActionContext.js' +import { ITTimersContext } from './tTimersContext.js' /** * Context in which 'current' is the partInstance we're leaving, and 'next' is the partInstance we're taking @@ -9,7 +10,8 @@ export interface IOnTakeContext extends IPartAndPieceActionContext, IShowStyleUserContext, IEventContext, - IExecuteTSRActionsContext { + IExecuteTSRActionsContext, + ITTimersContext { /** Inform core that a take out of the taken partinstance should be blocked until the specified time */ blockTakeUntil(time: Time | null): Promise /** diff --git a/packages/blueprints-integration/src/context/rundownContext.ts b/packages/blueprints-integration/src/context/rundownContext.ts index 402da1fa39..cf3a30e332 100644 --- a/packages/blueprints-integration/src/context/rundownContext.ts +++ b/packages/blueprints-integration/src/context/rundownContext.ts @@ -4,6 +4,7 @@ import type { IPackageInfoContext } from './packageInfoContext.js' import type { IShowStyleContext } from './showStyleContext.js' import type { IExecuteTSRActionsContext } from './executeTsrActionContext.js' import type { IDataStoreMethods } from './adlibActionContext.js' +import { ITTimersContext } from './tTimersContext.js' export interface IRundownContext extends IShowStyleContext { readonly rundownId: string @@ -13,7 +14,11 @@ export interface IRundownContext extends IShowStyleContext { export interface IRundownUserContext extends IUserNotesContext, IRundownContext {} -export interface IRundownActivationContext extends IRundownContext, IExecuteTSRActionsContext, IDataStoreMethods { +export interface IRundownActivationContext + extends IRundownContext, + IExecuteTSRActionsContext, + IDataStoreMethods, + ITTimersContext { /** Info about the RundownPlaylist state before the Activation / Deactivation event */ readonly previousState: IRundownActivationContextState readonly currentState: IRundownActivationContextState diff --git a/packages/blueprints-integration/src/context/tTimersContext.ts b/packages/blueprints-integration/src/context/tTimersContext.ts new file mode 100644 index 0000000000..ee4d86afc4 --- /dev/null +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -0,0 +1,91 @@ +export type IPlaylistTTimerIndex = 1 | 2 | 3 + +export interface ITTimersContext { + /** + * Get a T-timer by its index + * Note: Index is 1-based (1, 2, 3) + * @param index Number of the timer to retrieve + */ + getTimer(index: IPlaylistTTimerIndex): IPlaylistTTimer + + /** + * Clear all T-timers + */ + clearAllTimers(): void +} + +export interface IPlaylistTTimer { + readonly index: IPlaylistTTimerIndex + + /** The label of the T-timer */ + readonly label: string + + /** + * The current state of the T-timer + * Null if the T-timer is not initialized + */ + readonly state: IPlaylistTTimerState | null + + /** Set the label of the T-timer */ + setLabel(label: string): void + + /** Clear the T-timer back to an uninitialized state */ + clearTimer(): void + + /** + * Start a countdown timer + * @param duration Duration of the countdown in milliseconds + * @param options Options for the countdown + */ + startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void + + /** + * Start a free-running timer + */ + startFreeRun(options?: { startPaused?: boolean }): void + + /** + * If the current mode supports being paused, pause the timer + * Note: This is supported by the countdown and freerun modes + * @returns True if the timer was paused, false if it could not be paused + */ + pause(): boolean + + /** + * If the current mode supports being paused, resume the timer + * This is the opposite of `pause()` + * @returns True if the timer was resumed, false if it could not be resumed + */ + resume(): boolean + + /** + * If the timer can be restarted, restore it to its initial/restarted state + * Note: This is supported by the countdown mode + * @returns True if the timer was restarted, false if it could not be restarted + */ + restart(): boolean +} + +export type IPlaylistTTimerState = IPlaylistTTimerStateCountdown | IPlaylistTTimerStateFreeRun + +export interface IPlaylistTTimerStateCountdown { + /** The mode of the T-timer */ + readonly mode: 'countdown' + /** The current time of the countdown, in milliseconds */ + readonly currentTime: number + /** The total duration of the countdown, in milliseconds */ + readonly duration: number + /** Whether the timer is currently paused */ + readonly paused: boolean + + /** If the countdown is set to stop at zero, or continue into negative values */ + readonly stopAtZero: boolean +} +export interface IPlaylistTTimerStateFreeRun { + /** The mode of the T-timer */ + readonly mode: 'freeRun' + /** The current time of the freerun, in milliseconds */ + readonly currentTime: number + /** Whether the timer is currently paused */ + readonly paused: boolean +} diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e2850bc49b..bcdf828aa9 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -94,6 +94,64 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown + +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 + */ + readonly duration: number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + stopAtZero: boolean +} + +export type RundownTTimerIndex = 1 | 2 | 3 + +export interface RundownTTimer { + readonly index: RundownTTimerIndex + + /** A label for the timer */ + label: string + + /** The current mode of the timer, or null if not configured */ + mode: RundownTTimerMode | null + + /* + * Future ideas: + * allowUiControl: boolean + * display: { ... } // some kind of options for how to display in the ui + */ +} + export interface DBRundownPlaylist { _id: RundownPlaylistId /** External ID (source) of the playlist */ @@ -176,6 +234,12 @@ export interface DBRundownPlaylist { trackedAbSessions?: ABSessionInfo[] /** AB playback sessions assigned in the last timeline generation */ assignedAbSessions?: Record + + /** + * T-timers for the Playlist. + * This is a fixed size pool with 3 being chosen as a likely good amount, that can be used for any purpose. + */ + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] } // Information about a 'selected' PartInstance for the Playlist diff --git a/packages/corelib/src/worker/studio.ts b/packages/corelib/src/worker/studio.ts index e5df4d1311..9542115d32 100644 --- a/packages/corelib/src/worker/studio.ts +++ b/packages/corelib/src/worker/studio.ts @@ -19,7 +19,7 @@ import { JSONBlob } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' import { CoreRundownPlaylistSnapshot } from '../snapshots.js' import { NoteSeverity } from '@sofie-automation/blueprints-integration' import { ITranslatableMessage } from '../TranslatableMessage.js' -import { QuickLoopMarker } from '../dataModel/RundownPlaylist.js' +import { QuickLoopMarker, RundownTTimerIndex } from '../dataModel/RundownPlaylist.js' /** List of all Jobs performed by the Worker related to a certain Studio */ export enum StudioJobs { @@ -205,6 +205,27 @@ export enum StudioJobs { * for use in ad.lib actions and other triggers */ SwitchRouteSet = 'switchRouteSet', + /** + * Configure a T-timer as a countdown + */ + TTimerStartCountdown = 'tTimerStartCountdown', + + /** + * Configure a T-timer as a free-running timer + */ + TTimerStartFreeRun = 'tTimerStartFreeRun', + /** + * Pause a T-timer + */ + TTimerPause = 'tTimerPause', + /** + * Resume a T-timer + */ + TTimerResume = 'tTimerResume', + /** + * Restart a T-timer + */ + TTimerRestart = 'tTimerRestart', } export interface RundownPlayoutPropsBase { @@ -368,6 +389,21 @@ export interface SwitchRouteSetProps { routeSetId: string state: boolean | 'toggle' } +export interface TTimerPropsBase extends RundownPlayoutPropsBase { + timerIndex: RundownTTimerIndex +} +export interface TTimerStartCountdownProps extends TTimerPropsBase { + duration: number + stopAtZero: boolean + startPaused: boolean +} + +export interface TTimerStartFreeRunProps extends TTimerPropsBase { + startPaused: boolean +} +export type TTimerPauseProps = TTimerPropsBase +export type TTimerResumeProps = TTimerPropsBase +export type TTimerRestartProps = TTimerPropsBase /** * Set of valid functions, of form: @@ -425,6 +461,13 @@ export type StudioJobFunc = { [StudioJobs.ClearQuickLoopMarkers]: (data: ClearQuickLoopMarkersProps) => void [StudioJobs.SwitchRouteSet]: (data: SwitchRouteSetProps) => void + + [StudioJobs.TTimerStartCountdown]: (data: TTimerStartCountdownProps) => void + + [StudioJobs.TTimerStartFreeRun]: (data: TTimerStartFreeRunProps) => void + [StudioJobs.TTimerPause]: (data: TTimerPauseProps) => void + [StudioJobs.TTimerResume]: (data: TTimerResumeProps) => void + [StudioJobs.TTimerRestart]: (data: TTimerRestartProps) => void } export function getStudioQueueName(id: StudioId): string { diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 88869a4da8..8d705cc1b7 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -44,6 +44,12 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: PlaylistTimingType.None, }, rundownIdsInOrder: [], + + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index a476c1c593..8c7798362f 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -28,11 +28,16 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { selectNewPartWithOffsets } from '../../playout/moveNextPart.js' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import { TTimersService } from './services/TTimersService.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class OnSetAsNextContext extends ShowStyleUserContext implements IOnSetAsNextContext, IEventContext, IPartAndPieceInstanceActionContext { + readonly #tTimersService: TTimersService + public pendingMoveNextPart: { selectedPart: ReadonlyDeep | null } | undefined = undefined constructor( @@ -45,6 +50,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) + this.#tTimersService = new TTimersService(playoutModel) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -159,4 +165,11 @@ export class OnSetAsNextContext getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index 9d431d9958..def2770fc8 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -27,8 +27,13 @@ import { ActionPartChange, PartAndPieceInstanceActionService } from './services/ import { BlueprintQuickLookInfo } from '@sofie-automation/blueprints-integration/dist/context/quickLoopInfo' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import { TTimersService } from './services/TTimersService.js' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContext, IEventContext { + readonly #tTimersService: TTimersService + public isTakeAborted: boolean public get quickLoopInfo(): BlueprintQuickLookInfo | null { @@ -52,6 +57,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex ) { super(contextInfo, _context, showStyle, watchedPackages) this.isTakeAborted = false + this.#tTimersService = new TTimersService(_playoutModel) } async getUpcomingParts(limit: number = 5): Promise> { @@ -162,4 +168,11 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index a1c6849245..a97d6c7dbc 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -13,10 +13,14 @@ import { PlayoutModel } from '../../playout/model/PlayoutModel.js' import { RundownEventContext } from './RundownEventContext.js' import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { setTimelineDatastoreValue, removeTimelineDatastoreValue } from '../../playout/datastore.js' +import { TTimersService } from './services/TTimersService.js' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class RundownActivationContext extends RundownEventContext implements IRundownActivationContext { private readonly _playoutModel: PlayoutModel private readonly _context: JobContext + readonly #tTimersService: TTimersService private readonly _previousState: IRundownActivationContextState private readonly _currentState: IRundownActivationContextState @@ -43,6 +47,8 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._playoutModel = options.playoutModel this._previousState = options.previousState this._currentState = options.currentState + + this.#tTimersService = new TTimersService(this._playoutModel) } get previousState(): IRundownActivationContextState { @@ -74,4 +80,11 @@ export class RundownActivationContext extends RundownEventContext implements IRu await removeTimelineDatastoreValue(this._context, key) }) } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 3eaaf728b6..89227bcec0 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -34,6 +34,9 @@ import { BlueprintQuickLookInfo } from '@sofie-automation/blueprints-integration import { setNextPartFromPart } from '../../playout/setNext.js' import { getOrderedPartsAfterPlayhead } from '../../playout/lookahead/util.js' import { convertPartToBlueprints } from './lib.js' +import { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import { TTimersService } from './services/TTimersService.js' +import type { RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' export class DatastoreActionExecutionContext extends ShowStyleUserContext @@ -66,6 +69,8 @@ export class DatastoreActionExecutionContext /** Actions */ export class ActionExecutionContext extends ShowStyleUserContext implements IActionExecutionContext, IEventContext { + readonly #tTimersService: TTimersService + /** * Whether the blueprints requested a take to be performed at the end of this action * */ @@ -102,6 +107,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct private readonly partAndPieceInstanceService: PartAndPieceInstanceActionService ) { super(contextInfo, _context, showStyle, watchedPackages) + this.#tTimersService = new TTimersService(_playoutModel) } async getUpcomingParts(limit: number = 5): Promise> { @@ -257,4 +263,11 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct getCurrentTime(): number { return getCurrentTime() } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() + } } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts new file mode 100644 index 0000000000..3a45741390 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -0,0 +1,150 @@ +import type { + IPlaylistTTimer, + IPlaylistTTimerState, +} from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' +import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +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, + pauseTTimer, + restartTTimer, + resumeTTimer, + validateTTimerIndex, +} from '../../../playout/tTimers.js' + +export class TTimersService { + readonly playoutModel: PlayoutModel + + readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] + + constructor(playoutModel: PlayoutModel) { + this.playoutModel = playoutModel + + this.timers = [ + new PlaylistTTimerImpl(playoutModel, 1), + new PlaylistTTimerImpl(playoutModel, 2), + new PlaylistTTimerImpl(playoutModel, 3), + ] + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + validateTTimerIndex(index) + return this.timers[index - 1] + } + clearAllTimers(): void { + for (const timer of this.timers) { + timer.clearTimer() + } + } +} + +export class PlaylistTTimerImpl implements IPlaylistTTimer { + readonly #playoutModel: PlayoutModel + readonly #index: RundownTTimerIndex + + get #modelTimer(): ReadonlyDeep { + return this.#playoutModel.playlist.tTimers[this.#index - 1] + } + + get index(): RundownTTimerIndex { + return this.#modelTimer.index + } + get label(): string { + return this.#modelTimer.label + } + get state(): IPlaylistTTimerState | null { + const rawMode = this.#modelTimer.mode + switch (rawMode?.type) { + case 'countdown': + return { + mode: 'countdown', + currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + duration: rawMode.duration, + paused: !!rawMode.pauseTime, + stopAtZero: rawMode.stopAtZero, + } + case 'freeRun': + return { + mode: 'freeRun', + currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + paused: !!rawMode.pauseTime, + } + case undefined: + return null + default: + assertNever(rawMode) + return null + } + } + + constructor(playoutModel: PlayoutModel, index: RundownTTimerIndex) { + this.#playoutModel = playoutModel + this.#index = index + + validateTTimerIndex(index) + } + + setLabel(label: string): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + label: label, + }) + } + clearTimer(): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: null, + }) + } + startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createCountdownTTimer(duration, { + stopAtZero: options?.stopAtZero ?? true, + startPaused: options?.startPaused ?? false, + }), + }) + } + startFreeRun(options?: { startPaused?: boolean }): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createFreeRunTTimer({ + startPaused: options?.startPaused ?? false, + }), + }) + } + pause(): boolean { + const newTimer = pauseTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true + } + resume(): boolean { + const newTimer = resumeTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true + } + restart(): boolean { + const newTimer = restartTTimer(this.#modelTimer.mode) + if (!newTimer) return false + + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: newTimer, + }) + return true + } +} 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 new file mode 100644 index 0000000000..dba52e91d7 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -0,0 +1,522 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { useFakeCurrentTime, useRealCurrentTime } from '../../../../__mocks__/time.js' +import { TTimersService, PlaylistTTimerImpl } from '../TTimersService.js' +import type { PlayoutModel } from '../../../../playout/model/PlayoutModel.js' +import type { RundownTTimer, RundownTTimerIndex } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { mock, MockProxy } from 'jest-mock-extended' +import type { ReadonlyDeep } from 'type-fest' + +function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownTTimer]): MockProxy { + const mockPlayoutModel = mock() + const mockPlaylist = { + tTimers, + } as unknown as ReadonlyDeep + + Object.defineProperty(mockPlayoutModel, 'playlist', { + get: () => mockPlaylist, + configurable: true, + }) + + return mockPlayoutModel +} + +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 }, + ] +} + +describe('TTimersService', () => { + beforeEach(() => { + useFakeCurrentTime(10000) + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('constructor', () => { + it('should create three timer instances', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + + const service = new TTimersService(mockPlayoutModel) + + expect(service.timers).toHaveLength(3) + expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) + expect(service.timers[1]).toBeInstanceOf(PlaylistTTimerImpl) + expect(service.timers[2]).toBeInstanceOf(PlaylistTTimerImpl) + }) + }) + + describe('getTimer', () => { + it('should return the correct timer for index 1', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + const timer = service.getTimer(1) + + expect(timer).toBe(service.timers[0]) + }) + + it('should return the correct timer for index 2', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + const timer = service.getTimer(2) + + expect(timer).toBe(service.timers[1]) + }) + + it('should return the correct timer for index 3', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + const timer = service.getTimer(3) + + expect(timer).toBe(service.timers[2]) + }) + + it('should throw for invalid index', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const service = new TTimersService(mockPlayoutModel) + + expect(() => service.getTimer(0 as RundownTTimerIndex)).toThrow('T-timer index out of range: 0') + expect(() => service.getTimer(4 as RundownTTimerIndex)).toThrow('T-timer index out of range: 4') + }) + }) + + 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 } + + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const service = new TTimersService(mockPlayoutModel) + + service.clearAllTimers() + + // updateTTimer should have been called 3 times (once for each timer) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledTimes(3) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 1, mode: null }) + ) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 2, mode: null }) + ) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 3, mode: null }) + ) + }) + }) +}) + +describe('PlaylistTTimerImpl', () => { + beforeEach(() => { + useFakeCurrentTime(10000) + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('getters', () => { + it('should return the correct index', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + + expect(timer.index).toBe(2) + }) + + it('should return the correct label', () => { + const tTimers = createEmptyTTimers() + tTimers[1].label = 'Custom Label' + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + + expect(timer.label).toBe('Custom Label') + }) + + it('should return null state when no mode is set', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toBeNull() + }) + + it('should return running freeRun state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'freeRun', + currentTime: 5000, // 10000 - 5000 + paused: false, // pauseTime is null = running + }) + }) + + it('should return paused freeRun state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'freeRun', + currentTime: 3000, // 8000 - 5000 + paused: true, // pauseTime is set = paused + }) + }) + + it('should return running countdown state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'countdown', + currentTime: 5000, // 10000 - 5000 + duration: 60000, + paused: false, // pauseTime is null = running + stopAtZero: true, + }) + }) + + it('should return paused countdown state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + startTime: 5000, + pauseTime: 7000, + duration: 60000, + stopAtZero: false, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'countdown', + currentTime: 2000, // 7000 - 5000 + duration: 60000, + paused: true, // pauseTime is set = paused + stopAtZero: false, + }) + }) + }) + + describe('setLabel', () => { + it('should update the label', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.setLabel('New Label') + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'New Label', + mode: null, + }) + }) + }) + + describe('clearTimer', () => { + it('should clear the timer mode', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.clearTimer() + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: null, + }) + }) + }) + + describe('startCountdown', () => { + it('should start a running countdown with default options', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startCountdown(60000) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + }, + }) + }) + + it('should start a paused countdown', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, + pauseTime: 10000, + duration: 30000, + stopAtZero: false, + }, + }) + }) + }) + + describe('startFreeRun', () => { + it('should start a running free-run timer', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startFreeRun() + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 10000, + pauseTime: null, + }, + }) + }) + + it('should start a paused free-run timer', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startFreeRun({ startPaused: true }) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 10000, + pauseTime: 10000, + }, + }) + }) + }) + + describe('pause', () => { + it('should pause a running freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 5000, + pauseTime: 10000, + }, + }) + }) + + it('should pause a running countdown timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 5000, + pauseTime: 10000, + duration: 60000, + stopAtZero: true, + }, + }) + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.pause() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + }) + + describe('resume', () => { + it('should resume a paused freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'freeRun', + startTime: 7000, // adjusted for pause duration + pauseTime: null, + }, + }) + }) + + it('should return true but not change a running timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + // Returns true because timer supports resume, but it's already running + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.resume() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + }) + + describe('restart', () => { + it('should restart a countdown timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, // reset to now + pauseTime: null, + duration: 60000, + stopAtZero: true, + }, + }) + }) + + it('should restart a paused countdown timer (stays paused)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'countdown', + startTime: 5000, + pauseTime: 8000, + duration: 60000, + stopAtZero: false, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(true) + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'countdown', + startTime: 10000, + pauseTime: 10000, // also reset to now (paused at start) + duration: 60000, + stopAtZero: false, + }, + }) + }) + + it('should return false for freeRun timer', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + + it('should return false for timer with no mode', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const result = timer.restart() + + expect(result).toBe(false) + expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + }) + }) + + describe('constructor validation', () => { + it('should throw for invalid index', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + + expect(() => new PlaylistTTimerImpl(mockPlayoutModel, 0 as RundownTTimerIndex)).toThrow( + 'T-timer index out of range: 0' + ) + expect(() => new PlaylistTTimerImpl(mockPlayoutModel, 4 as RundownTTimerIndex)).toThrow( + 'T-timer index out of range: 4' + ) + }) + }) +}) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index 7631c647c5..a39d82f7cc 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -56,6 +56,11 @@ describe('Test external message queue static methods', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [protectString('rundown_1')], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) await context.mockCollections.Rundowns.insertOne({ _id: protectString('rundown_1'), @@ -201,6 +206,11 @@ describe('Test sending messages to mocked endpoints', () => { type: PlaylistTimingType.None, }, rundownIdsInOrder: [protectString('rundown_1')], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) const rundown = (await context.mockCollections.Rundowns.findOne(rundownId)) as DBRundown diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index b663ad3501..47ddfed664 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -315,6 +315,11 @@ describe('SyncChangesToPartInstancesWorker', () => { modified: 0, timing: { type: PlaylistTimingType.None }, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } const segmentModel = new PlayoutSegmentModelImpl(segment, [part0]) diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index b92cbe7766..91df4cc24e 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -34,6 +34,11 @@ async function createMockRO(context: MockJobContext): Promise { }, rundownIdsInOrder: [rundownId], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) await context.mockCollections.Rundowns.insertOne({ 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 8c1b68d443..45148a92f6 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 @@ -15,6 +15,23 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -307,6 +324,23 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -591,6 +625,23 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -896,6 +947,23 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -1191,6 +1259,23 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -1484,6 +1569,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -1745,6 +1847,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2051,6 +2170,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2365,6 +2501,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2662,6 +2815,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -2959,6 +3129,23 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -3255,6 +3442,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -3544,6 +3748,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -3865,6 +4086,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, @@ -4162,6 +4400,23 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "5meLdE_m5k28xXw1vtX2JX8mSYQ_", ], "studioId": "mockStudio4", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, 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 d99635086b..8017111a4f 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 @@ -77,6 +77,23 @@ exports[`Playout API Basic rundown control 4`] = ` "resetTime": 0, "rundownIdsInOrder": [], "studioId": "mockStudio0", + "tTimers": [ + { + "index": 1, + "label": "", + "mode": null, + }, + { + "index": 2, + "label": "", + "mode": null, + }, + { + "index": 3, + "label": "", + "mode": null, + }, + ], "timing": { "type": "none", }, diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts new file mode 100644 index 0000000000..6e3b395857 --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -0,0 +1,351 @@ +import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { + validateTTimerIndex, + pauseTTimer, + resumeTTimer, + restartTTimer, + createCountdownTTimer, + createFreeRunTTimer, + calculateTTimerCurrentTime, +} from '../tTimers.js' +import type { RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' + +describe('tTimers utils', () => { + beforeEach(() => { + useFakeCurrentTime(10000) // Set a fixed time for tests + }) + + afterEach(() => { + useRealCurrentTime() + }) + + describe('validateTTimerIndex', () => { + it('should accept valid indices 1, 2, 3', () => { + expect(() => validateTTimerIndex(1)).not.toThrow() + expect(() => validateTTimerIndex(2)).not.toThrow() + expect(() => validateTTimerIndex(3)).not.toThrow() + }) + + it('should reject index 0', () => { + expect(() => validateTTimerIndex(0)).toThrow('T-timer index out of range: 0') + }) + + it('should reject index 4', () => { + expect(() => validateTTimerIndex(4)).toThrow('T-timer index out of range: 4') + }) + + it('should reject negative indices', () => { + expect(() => validateTTimerIndex(-1)).toThrow('T-timer index out of range: -1') + }) + + it('should reject NaN', () => { + expect(() => validateTTimerIndex(NaN)).toThrow('T-timer index out of range: NaN') + }) + }) + + describe('pauseTTimer', () => { + it('should pause a running countdown timer', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + } + + const result = pauseTTimer(timer) + + expect(result).toEqual({ + type: 'countdown', + startTime: 5000, + pauseTime: 10000, // getCurrentTime() + duration: 60000, + stopAtZero: true, + }) + }) + + it('should pause a running freeRun timer', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: null, + } + + const result = pauseTTimer(timer) + + expect(result).toEqual({ + type: 'freeRun', + startTime: 5000, + pauseTime: 10000, + }) + }) + + 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 result = pauseTTimer(timer) + + expect(result).toBe(timer) // same reference, unchanged + }) + + it('should return unchanged freeRun timer if already paused', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: 7000, // already paused + } + + const result = pauseTTimer(timer) + + expect(result).toBe(timer) // same reference, unchanged + }) + + it('should return null for null timer', () => { + expect(pauseTTimer(null)).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 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, + }) + }) + + it('should resume a paused freeRun timer', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 2000, + pauseTime: 6000, // paused 4 seconds after start + } + + 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, + }) + }) + + 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 result = resumeTTimer(timer) + + expect(result).toBe(timer) // same reference + }) + + it('should return freeRun timer unchanged if already running', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: null, // already running + } + + const result = resumeTTimer(timer) + + expect(result).toBe(timer) // same reference + }) + + it('should return null for null timer', () => { + expect(resumeTTimer(null)).toBeNull() + }) + }) + + describe('restartTTimer', () => { + it('should restart a running countdown timer', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, // now + pauseTime: null, + duration: 60000, + stopAtZero: true, + }) + }) + + it('should restart a paused countdown timer (stays paused)', () => { + const timer: RundownTTimerMode = { + type: 'countdown', + startTime: 5000, + pauseTime: 8000, + duration: 60000, + stopAtZero: false, + } + + const result = restartTTimer(timer) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, // now + pauseTime: 10000, // also now (paused at start) + duration: 60000, + stopAtZero: false, + }) + }) + + it('should return null for freeRun timer', () => { + const timer: RundownTTimerMode = { + type: 'freeRun', + startTime: 5000, + pauseTime: null, + } + + expect(restartTTimer(timer)).toBeNull() + }) + + it('should return null for null timer', () => { + expect(restartTTimer(null)).toBeNull() + }) + }) + + describe('createCountdownTTimer', () => { + it('should create a running countdown timer', () => { + const result = createCountdownTTimer(60000, { + stopAtZero: true, + startPaused: false, + }) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, + pauseTime: null, + duration: 60000, + stopAtZero: true, + }) + }) + + it('should create a paused countdown timer', () => { + const result = createCountdownTTimer(30000, { + stopAtZero: false, + startPaused: true, + }) + + expect(result).toEqual({ + type: 'countdown', + startTime: 10000, + pauseTime: 10000, + duration: 30000, + stopAtZero: false, + }) + }) + + it('should throw for zero duration', () => { + expect(() => + createCountdownTTimer(0, { + stopAtZero: true, + startPaused: false, + }) + ).toThrow('Duration must be greater than zero') + }) + + it('should throw for negative duration', () => { + expect(() => + createCountdownTTimer(-1000, { + stopAtZero: true, + startPaused: false, + }) + ).toThrow('Duration must be greater than zero') + }) + }) + + describe('createFreeRunTTimer', () => { + it('should create a running freeRun timer', () => { + const result = createFreeRunTTimer({ startPaused: false }) + + expect(result).toEqual({ + type: 'freeRun', + startTime: 10000, + pauseTime: null, + }) + }) + + it('should create a paused freeRun timer', () => { + const result = createFreeRunTTimer({ startPaused: true }) + + expect(result).toEqual({ + type: 'freeRun', + startTime: 10000, + pauseTime: 10000, + }) + }) + }) + + 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) + }) + }) +}) diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 0dff06ff91..439d58b895 100644 --- a/packages/job-worker/src/playout/model/PlayoutModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutModel.ts @@ -17,6 +17,7 @@ import { DBRundownPlaylist, QuickLoopMarker, RundownHoldState, + RundownTTimer, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ReadonlyDeep } from 'type-fest' import { StudioPlayoutModelBase, StudioPlayoutModelBaseReadonly } from '../../studio/model/StudioPlayoutModel.js' @@ -374,6 +375,12 @@ export interface PlayoutModel extends PlayoutModelReadonly, StudioPlayoutModelBa */ setQuickLoopMarker(type: 'start' | 'end', marker: QuickLoopMarker | null): void + /** + * Update a T-timer + * @param timer Timer properties + */ + updateTTimer(timer: RundownTTimer): void + calculatePartTimings( fromPartInstance: PlayoutPartInstanceModel | null, toPartInstance: PlayoutPartInstanceModel, diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts index 76086bd98c..9748c49d0f 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts @@ -16,6 +16,7 @@ import { DBRundownPlaylist, QuickLoopMarker, RundownHoldState, + RundownTTimer, SelectedPartInstance, } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { ReadonlyDeep } from 'type-fest' @@ -61,6 +62,7 @@ import { calculatePartTimings, PartCalculatedTimings } from '@sofie-automation/c import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout/processAndPrune' import { NotificationsModelHelper } from '../../../notifications/NotificationsModelHelper.js' import { getExpectedLatency } from '@sofie-automation/corelib/dist/studio/playout' +import { validateTTimerIndex } from '../../tTimers.js' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -859,6 +861,13 @@ export class PlayoutModelImpl extends PlayoutModelReadonlyImpl implements Playou this.#playlistHasChanged = true } + updateTTimer(timer: RundownTTimer): void { + validateTTimerIndex(timer.index) + + this.playlistImpl.tTimers[timer.index - 1] = timer + this.#playlistHasChanged = true + } + #lastMonotonicNowInPlayout = getCurrentTime() getNowInPlayout(): number { const nowOffsetLatency = this.getNowOffsetLatency() ?? 0 diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts new file mode 100644 index 0000000000..85c2fbaeea --- /dev/null +++ b/packages/job-worker/src/playout/tTimers.ts @@ -0,0 +1,124 @@ +import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { getCurrentTime } from '../lib/index.js' +import type { ReadonlyDeep } from 'type-fest' + +export function validateTTimerIndex(index: number): asserts index is RundownTTimerIndex { + if (isNaN(index) || index < 1 || index > 3) throw new Error(`T-timer index out of range: ${index}`) +} + +/** + * Returns an updated T-timer in the paused state (if supported) + * @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) { + // Already paused + return timer + } + + return { + ...timer, + pauseTime: getCurrentTime(), + } + } else { + return null + } +} + +/** + * Returns an updated T-timer in the resumed state (if supported) + * @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) { + // Already running + return timer + } + + const pausedOffset = timer.startTime - timer.pauseTime + const newStartTime = getCurrentTime() + pausedOffset + + return { + ...timer, + startTime: newStartTime, + pauseTime: null, + } + } else { + return null + } +} + +/** + * Returns an updated T-timer, after restarting (if supported) + * @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' || timer?.type === 'freeRun') { + return { + ...timer, + startTime: getCurrentTime(), + pauseTime: null, // Always unpause when restarting + } + } else { + return null + } +} + +/** + * Create a new countdown T-timer + * @param index Timer index + * @param duration Duration in milliseconds + * @param options Options for the countdown + * @returns The created T-timer + */ +export function createCountdownTTimer( + duration: number, + options: { + stopAtZero: boolean + startPaused: boolean + } +): 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, + } +} + +/** + * Create a new free-running T-timer + * @param index Timer index + * @param options Options for the free-run + * @returns The created T-timer + */ +export function createFreeRunTTimer(options: { startPaused: boolean }): 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 + } +} diff --git a/packages/job-worker/src/playout/tTimersJobs.ts b/packages/job-worker/src/playout/tTimersJobs.ts new file mode 100644 index 0000000000..c9c4b62cac --- /dev/null +++ b/packages/job-worker/src/playout/tTimersJobs.ts @@ -0,0 +1,104 @@ +import { + TTimerPauseProps, + TTimerRestartProps, + TTimerResumeProps, + TTimerStartCountdownProps, + TTimerStartFreeRunProps, +} from '@sofie-automation/corelib/dist/worker/studio' +import { JobContext } from '../jobs/index.js' +import { runJobWithPlayoutModel } from './lock.js' +import { + createCountdownTTimer, + createFreeRunTTimer, + pauseTTimer, + restartTTimer, + resumeTTimer, + validateTTimerIndex, +} from './tTimers.js' + +export async function handleTTimerStartCountdown(_context: JobContext, data: TTimerStartCountdownProps): Promise { + return runJobWithPlayoutModel(_context, data, null, async (playoutModel) => { + validateTTimerIndex(data.timerIndex) + + const timerMode = createCountdownTTimer(data.duration * 1000, { + stopAtZero: data.stopAtZero, + startPaused: data.startPaused, + }) + + const currentTimer = playoutModel.playlist.tTimers[data.timerIndex - 1] + playoutModel.updateTTimer({ + ...currentTimer, + mode: timerMode, + }) + }) +} + +export async function handleTTimerStartFreeRun(_context: JobContext, data: TTimerStartFreeRunProps): Promise { + return runJobWithPlayoutModel(_context, data, null, async (playoutModel) => { + validateTTimerIndex(data.timerIndex) + + const timerMode = createFreeRunTTimer({ + startPaused: data.startPaused, + }) + + const currentTimer = playoutModel.playlist.tTimers[data.timerIndex - 1] + playoutModel.updateTTimer({ + ...currentTimer, + mode: timerMode, + }) + }) +} + +export async function handleTTimerPause(_context: JobContext, data: TTimerPauseProps): Promise { + return runJobWithPlayoutModel(_context, data, null, async (playoutModel) => { + validateTTimerIndex(data.timerIndex) + + const timerIndex = data.timerIndex - 1 + const currentTimer = playoutModel.playlist.tTimers[timerIndex] + if (!currentTimer.mode) return + + const newMode = pauseTTimer(currentTimer.mode) + if (newMode) { + playoutModel.updateTTimer({ + ...currentTimer, + mode: newMode, + }) + } + }) +} + +export async function handleTTimerResume(_context: JobContext, data: TTimerResumeProps): Promise { + return runJobWithPlayoutModel(_context, data, null, async (playoutModel) => { + validateTTimerIndex(data.timerIndex) + + const timerIndex = data.timerIndex - 1 + const currentTimer = playoutModel.playlist.tTimers[timerIndex] + if (!currentTimer.mode) return + + const newMode = resumeTTimer(currentTimer.mode) + if (newMode) { + playoutModel.updateTTimer({ + ...currentTimer, + mode: newMode, + }) + } + }) +} + +export async function handleTTimerRestart(_context: JobContext, data: TTimerRestartProps): Promise { + return runJobWithPlayoutModel(_context, data, null, async (playoutModel) => { + validateTTimerIndex(data.timerIndex) + + const timerIndex = data.timerIndex - 1 + const currentTimer = playoutModel.playlist.tTimers[timerIndex] + if (!currentTimer.mode) return + + const newMode = restartTTimer(currentTimer.mode) + if (newMode) { + playoutModel.updateTTimer({ + ...currentTimer, + mode: newMode, + }) + } + }) +} diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 86e637802a..eb61a94b06 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -236,6 +236,11 @@ export function produceRundownPlaylistInfoFromRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], ...clone(existingPlaylist), @@ -332,6 +337,11 @@ function defaultPlaylistForRundown( nextPartInfo: null, previousPartInfo: null, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], ...clone(existingPlaylist), diff --git a/packages/job-worker/src/workers/studio/jobs.ts b/packages/job-worker/src/workers/studio/jobs.ts index 4e08fd7edb..87171213fa 100644 --- a/packages/job-worker/src/workers/studio/jobs.ts +++ b/packages/job-worker/src/workers/studio/jobs.ts @@ -48,6 +48,13 @@ import { handleClearQuickLoopMarkers, handleSetQuickLoopMarker } from '../../pla import { handleActivateAdlibTesting } from '../../playout/adlibTesting.js' import { handleExecuteBucketAdLibOrAction } from '../../playout/bucketAdlibJobs.js' import { handleSwitchRouteSet } from '../../studio/routeSet.js' +import { + handleTTimerPause, + handleTTimerRestart, + handleTTimerResume, + handleTTimerStartCountdown, + handleTTimerStartFreeRun, +} from '../../playout/tTimersJobs.js' type ExecutableFunction = ( context: JobContext, @@ -110,4 +117,10 @@ export const studioJobHandlers: StudioJobHandlers = { [StudioJobs.ClearQuickLoopMarkers]: handleClearQuickLoopMarkers, [StudioJobs.SwitchRouteSet]: handleSwitchRouteSet, + [StudioJobs.TTimerStartCountdown]: handleTTimerStartCountdown, + + [StudioJobs.TTimerStartFreeRun]: handleTTimerStartFreeRun, + [StudioJobs.TTimerPause]: handleTTimerPause, + [StudioJobs.TTimerResume]: handleTTimerResume, + [StudioJobs.TTimerRestart]: handleTTimerRestart, } diff --git a/packages/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index 576b1cb743..23b70507c1 100644 --- a/packages/live-status-gateway/src/topics/__tests__/utils.ts +++ b/packages/live-status-gateway/src/topics/__tests__/utils.ts @@ -34,6 +34,7 @@ export function makeTestPlaylist(id?: string): DBRundownPlaylist { studioId: protectString('STUDIO_1'), timing: { type: PlaylistTimingType.None }, publicData: { a: 'b' }, + tTimers: [] as any, } } diff --git a/packages/openapi/api/actions.yaml b/packages/openapi/api/actions.yaml index 70f1788148..c751efd1b2 100644 --- a/packages/openapi/api/actions.yaml +++ b/packages/openapi/api/actions.yaml @@ -71,6 +71,17 @@ paths: $ref: 'definitions/playlists.yaml#/resources/sourceLayer' /playlists/{playlistId}/sourceLayer/{sourceLayerId}/sticky: $ref: 'definitions/playlists.yaml#/resources/sourceLayer/sticky' + /playlists/{playlistId}/t-timers/{timerIndex}/countdown: + $ref: 'definitions/playlists.yaml#/resources/tTimerCountdown' + + /playlists/{playlistId}/t-timers/{timerIndex}/free-run: + $ref: 'definitions/playlists.yaml#/resources/tTimerFreeRun' + /playlists/{playlistId}/t-timers/{timerIndex}/pause: + $ref: 'definitions/playlists.yaml#/resources/tTimerPause' + /playlists/{playlistId}/t-timers/{timerIndex}/resume: + $ref: 'definitions/playlists.yaml#/resources/tTimerResume' + /playlists/{playlistId}/t-timers/{timerIndex}/restart: + $ref: 'definitions/playlists.yaml#/resources/tTimerRestart' # studio operations /studios: $ref: 'definitions/studios.yaml#/resources/studios' diff --git a/packages/openapi/api/definitions/playlists.yaml b/packages/openapi/api/definitions/playlists.yaml index 943778f641..4065416476 100644 --- a/packages/openapi/api/definitions/playlists.yaml +++ b/packages/openapi/api/definitions/playlists.yaml @@ -714,6 +714,169 @@ resources: example: Rundown must be active! 500: $ref: '#/components/responses/internalServerError' + tTimerCountdown: + post: + operationId: tTimerCountdown + tags: + - playlists + summary: Start a countdown timer. + parameters: + - name: playlistId + in: path + description: Target playlist. + required: true + schema: + type: string + - name: timerIndex + in: path + description: Index of the timer (1, 2, or 3). + required: true + schema: + type: integer + enum: [1, 2, 3] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duration: + type: number + description: Duration in seconds. + stopAtZero: + type: boolean + description: Whether to stop the timer at zero. + startPaused: + type: boolean + description: Whether to start the timer in a paused state. + required: + - duration + responses: + 200: + $ref: '#/components/responses/putSuccess' + 404: + $ref: '#/components/responses/playlistNotFound' + 500: + $ref: '#/components/responses/internalServerError' + + tTimerFreeRun: + post: + operationId: tTimerFreeRun + tags: + - playlists + summary: Start a free-running timer. + parameters: + - name: playlistId + in: path + description: Target playlist. + required: true + schema: + type: string + - name: timerIndex + in: path + description: Index of the timer (1, 2, or 3). + required: true + schema: + type: integer + enum: [1, 2, 3] + requestBody: + content: + application/json: + schema: + type: object + properties: + startPaused: + type: boolean + description: Whether to start the timer in a paused state. + responses: + 200: + $ref: '#/components/responses/putSuccess' + 404: + $ref: '#/components/responses/playlistNotFound' + 500: + $ref: '#/components/responses/internalServerError' + tTimerPause: + post: + operationId: tTimerPause + tags: + - playlists + summary: Pause a timer. + parameters: + - name: playlistId + in: path + description: Target playlist. + required: true + schema: + type: string + - name: timerIndex + in: path + description: Index of the timer (1, 2, or 3). + required: true + schema: + type: integer + enum: [1, 2, 3] + responses: + 200: + $ref: '#/components/responses/putSuccess' + 404: + $ref: '#/components/responses/playlistNotFound' + 500: + $ref: '#/components/responses/internalServerError' + tTimerResume: + post: + operationId: tTimerResume + tags: + - playlists + summary: Resume a timer. + parameters: + - name: playlistId + in: path + description: Target playlist. + required: true + schema: + type: string + - name: timerIndex + in: path + description: Index of the timer (1, 2, or 3). + required: true + schema: + type: integer + enum: [1, 2, 3] + responses: + 200: + $ref: '#/components/responses/putSuccess' + 404: + $ref: '#/components/responses/playlistNotFound' + 500: + $ref: '#/components/responses/internalServerError' + tTimerRestart: + post: + operationId: tTimerRestart + tags: + - playlists + summary: Restart a timer. + parameters: + - name: playlistId + in: path + description: Target playlist. + required: true + schema: + type: string + - name: timerIndex + in: path + description: Index of the timer (1, 2, or 3). + required: true + schema: + type: integer + enum: [1, 2, 3] + responses: + 200: + $ref: '#/components/responses/putSuccess' + 404: + $ref: '#/components/responses/playlistNotFound' + 500: + $ref: '#/components/responses/internalServerError' components: schemas: diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 7434f499fb..161bbec448 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -48,6 +48,11 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI type: 'none' as any, }, rundownIdsInOrder: [], + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], } } export function defaultRundown( diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index 8e402449d9..f57f33d4ed 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -28,6 +28,12 @@ function makeMockPlaylist(): DBRundownPlaylist { type: PlaylistTimingType.None, }, rundownIdsInOrder: [], + + tTimers: [ + { index: 1, label: '', mode: null }, + { index: 2, label: '', mode: null }, + { index: 3, label: '', mode: null }, + ], }) }