From 51a53be1187bb761add223161afae49e7a0ab8f1 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 19 Jan 2026 17:02:29 +0000 Subject: [PATCH 1/6] feat: RundownPlaylist T-Timers which are controllable from blueprints --- meteor/__mocks__/defaultCollectionObjects.ts | 5 + meteor/server/__tests__/cronjobs.test.ts | 1 + .../__tests__/externalMessageQueue.test.ts | 1 + .../api/__tests__/peripheralDevice.test.ts | 1 + meteor/server/migration/X_X_X.ts | 26 +- meteor/yarn.lock | 8 + .../src/context/adlibActionContext.ts | 4 +- .../src/context/onSetAsNextContext.ts | 3 +- .../src/context/onTakeContext.ts | 4 +- .../src/context/rundownContext.ts | 7 +- .../src/context/tTimersContext.ts | 119 +++ .../corelib/src/dataModel/RundownPlaylist.ts | 81 ++ packages/job-worker/package.json | 1 + .../src/__mocks__/defaultCollectionObjects.ts | 6 + .../blueprints/context/OnSetAsNextContext.ts | 13 + .../src/blueprints/context/OnTakeContext.ts | 13 + .../context/RundownActivationContext.ts | 13 + .../src/blueprints/context/adlibActions.ts | 13 + .../context/services/TTimersService.ts | 168 ++++ .../services/__tests__/TTimersService.test.ts | 734 ++++++++++++++++++ .../__tests__/externalMessageQueue.test.ts | 10 + .../syncChangesToPartInstance.test.ts | 5 + .../src/ingest/__tests__/updateNext.test.ts | 5 + .../__snapshots__/mosIngest.test.ts.snap | 255 ++++++ .../__snapshots__/playout.test.ts.snap | 17 + .../src/playout/__tests__/tTimers.test.ts | 601 ++++++++++++++ .../src/playout/model/PlayoutModel.ts | 7 + .../model/implementation/PlayoutModelImpl.ts | 9 + packages/job-worker/src/playout/tTimers.ts | 172 ++++ packages/job-worker/src/rundownPlaylists.ts | 10 + .../src/topics/__tests__/utils.ts | 1 + .../src/__mocks__/defaultCollectionObjects.ts | 5 + .../lib/__tests__/rundownTiming.test.ts | 6 + packages/yarn.lock | 8 + 34 files changed, 2327 insertions(+), 5 deletions(-) create mode 100644 packages/blueprints-integration/src/context/tTimersContext.ts create mode 100644 packages/job-worker/src/blueprints/context/services/TTimersService.ts create mode 100644 packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts create mode 100644 packages/job-worker/src/playout/__tests__/tTimers.test.ts create mode 100644 packages/job-worker/src/playout/tTimers.ts diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index fa8a8934edb..faec0a06be9 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 c61e36bdcb7..9133eb44390 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 801220a8f85..1b5fb53f938 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 3c819cf20a7..594c44049ca 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/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 30a74d769e1..7056e6c1e6c 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' import { ExpectedPackages } from '../collections' import * as PackagesPreR53 from '@sofie-automation/corelib/dist/dataModel/Old/ExpectedPackagesR52' import { @@ -195,4 +195,28 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ } }, }, + { + 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/meteor/yarn.lock b/meteor/yarn.lock index 651ec538050..944f4c7378e 100644 --- a/meteor/yarn.lock +++ b/meteor/yarn.lock @@ -1231,6 +1231,7 @@ __metadata: "@sofie-automation/corelib": "npm:1.53.0-in-development" "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" mongodb: "npm:^6.12.0" @@ -2940,6 +2941,13 @@ __metadata: languageName: node linkType: hard +"chrono-node@npm:^2.9.0": + version: 2.9.0 + resolution: "chrono-node@npm:2.9.0" + checksum: 10/a30bbaa67f9a127e711db6e694ee4c89292d8f533dbfdc3d7cb34f479728e02e377f682e75ad84dd4b6a16016c248a5e85fb453943b96f93f5993f5ccddc6d08 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.8.0 resolution: "ci-info@npm:3.8.0" diff --git a/packages/blueprints-integration/src/context/adlibActionContext.ts b/packages/blueprints-integration/src/context/adlibActionContext.ts index 6f9931eeea7..90adea51b58 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 9e729ce4029..ee7b3aa29e0 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 461f64bfa1d..bd02aa22cd0 100644 --- a/packages/blueprints-integration/src/context/onTakeContext.ts +++ b/packages/blueprints-integration/src/context/onTakeContext.ts @@ -1,6 +1,7 @@ import { IBlueprintPart, IBlueprintPiece, 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 402da1fa396..cf3a30e332c 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 00000000000..8747f450a2c --- /dev/null +++ b/packages/blueprints-integration/src/context/tTimersContext.ts @@ -0,0 +1,119 @@ +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 timeOfDay timer, counting towards the target time + * This will throw if it is unable to parse the target time + * @param targetTime The target time, as a string (e.g. "14:30", "2023-12-31T23:59:59Z") or a timestamp number + */ + startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: 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 and timeOfDay modes + * @returns True if the timer was restarted, false if it could not be restarted + */ + restart(): boolean +} + +export type IPlaylistTTimerState = + | IPlaylistTTimerStateCountdown + | IPlaylistTTimerStateFreeRun + | IPlaylistTTimerStateTimeOfDay + +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 +} + +export interface IPlaylistTTimerStateTimeOfDay { + /** The mode of the T-timer */ + readonly mode: 'timeOfDay' + /** The current remaining time of the timer, in milliseconds */ + readonly currentTime: number + /** The target timestamp of the timer, in milliseconds */ + readonly targetTime: number + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** If the countdown is set to stop at zero, or continue into negative values */ + readonly stopAtZero: boolean +} diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index e2850bc49bb..0ea7a83fe82 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -94,6 +94,81 @@ export interface QuickLoopProps { forceAutoNext: ForceQuickLoopAutoNext } +export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCountdown | RundownTTimerModeTimeOfDay + +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 + */ + readonly stopAtZero: boolean +} +export interface RundownTTimerModeTimeOfDay { + readonly type: 'timeOfDay' + + /** The target timestamp of the timer, in milliseconds */ + targetTime: number + + /** + * The raw target string of the timer, as provided when setting the timer + * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) + */ + readonly targetRaw: string | number + + /** + * If the countdown should stop at zero, or continue into negative values + */ + readonly 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 +251,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/job-worker/package.json b/packages/job-worker/package.json index 9241feb122c..34a1df65a34 100644 --- a/packages/job-worker/package.json +++ b/packages/job-worker/package.json @@ -41,6 +41,7 @@ "@sofie-automation/corelib": "1.53.0-in-development", "@sofie-automation/shared-lib": "1.53.0-in-development", "amqplib": "^0.10.5", + "chrono-node": "^2.9.0", "deepmerge": "^4.3.1", "elastic-apm-node": "^4.11.0", "mongodb": "^6.12.0", diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 88869a4da84..8d705cc1b7c 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 92476b94be6..198ad84b57f 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 { @@ -163,4 +169,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 ddca6bdcfc8..b9b8db8cb1e 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -31,8 +31,13 @@ import { 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 partToQueueAfterTake: QueueablePartAndPieces | undefined @@ -61,6 +66,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> { @@ -184,4 +190,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 a1c6849245f..a97d6c7dbc2 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 0359871eb2a..a8953518484 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -38,6 +38,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 @@ -70,6 +73,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 * */ @@ -112,6 +117,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> { @@ -280,4 +286,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 00000000000..b8ef3c7e21b --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -0,0 +1,168 @@ +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, + createTimeOfDayTTimer, + pauseTTimer, + restartTTimer, + resumeTTimer, + validateTTimerIndex, +} from '../../../playout/tTimers.js' +import { getCurrentTime } from '../../../lib/time.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 'timeOfDay': + return { + mode: 'timeOfDay', + currentTime: rawMode.targetTime - getCurrentTime(), + targetTime: rawMode.targetTime, + targetRaw: rawMode.targetRaw, + stopAtZero: rawMode.stopAtZero, + } + 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, + }), + }) + } + startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { + this.#playoutModel.updateTTimer({ + ...this.#modelTimer, + mode: createTimeOfDayTTimer(targetTime, { + stopAtZero: options?.stopAtZero ?? true, + }), + }) + } + 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 00000000000..7943a895924 --- /dev/null +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -0,0 +1,734 @@ +/* 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, + }) + }) + + it('should return timeOfDay state', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 20000, // 10 seconds in the future + targetRaw: '15:30', + stopAtZero: true, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'timeOfDay', + currentTime: 10000, // targetTime - getCurrentTime() = 20000 - 10000 + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + }) + }) + + it('should return timeOfDay state with numeric targetRaw', () => { + const tTimers = createEmptyTTimers() + const targetTimestamp = 1737331200000 + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + stopAtZero: false, + } + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(timer.state).toEqual({ + mode: 'timeOfDay', + currentTime: targetTimestamp - 10000, // targetTime - getCurrentTime() + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + 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('startTimeOfDay', () => { + it('should start a timeOfDay timer with time string', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startTimeOfDay('15:30') + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetTime: expect.any(Number), // new target time + targetRaw: '15:30', + stopAtZero: true, + }, + }) + }) + + it('should start a timeOfDay timer with numeric timestamp', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const targetTimestamp = 1737331200000 + + timer.startTimeOfDay(targetTimestamp) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: { + type: 'timeOfDay', + targetTime: targetTimestamp, + targetRaw: targetTimestamp, + stopAtZero: true, + }, + }) + }) + + it('should start a timeOfDay timer with stopAtZero false', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startTimeOfDay('18:00', { stopAtZero: false }) + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: expect.objectContaining({ + type: 'timeOfDay', + targetRaw: '18:00', + stopAtZero: false, + }), + }) + }) + + it('should start a timeOfDay timer with 12-hour format', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + timer.startTimeOfDay('5:30pm') + + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + index: 1, + label: 'Timer 1', + mode: expect.objectContaining({ + type: 'timeOfDay', + targetTime: expect.any(Number), // new target time + targetRaw: '5:30pm', + stopAtZero: true, + }), + }) + }) + + it('should throw for invalid time string', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') + }) + + it('should throw for empty time string', () => { + const tTimers = createEmptyTTimers() + const mockPlayoutModel = createMockPlayoutModel(tTimers) + const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') + }) + }) + + 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() + }) + + it('should return false for timeOfDay timer (does not support pause)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + } + 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() + }) + + it('should return false for timeOfDay timer (does not support resume)', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 20000, + targetRaw: '15:30', + stopAtZero: true, + } + 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 restart a timeOfDay timer with valid targetRaw', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 5000, // old target time + targetRaw: '15:30', + 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: 'timeOfDay', + targetTime: expect.any(Number), // new target time + targetRaw: '15:30', + stopAtZero: true, + }, + }) + }) + + it('should return false for timeOfDay timer with invalid targetRaw', () => { + const tTimers = createEmptyTTimers() + tTimers[0].mode = { + type: 'timeOfDay', + targetTime: 5000, + targetRaw: 'invalid-time-string', + stopAtZero: true, + } + 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 7631c647c5d..a39d82f7cc6 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 b663ad3501c..47ddfed664b 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 b92cbe77665..91df4cc24ef 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 8c1b68d4433..45148a92f6f 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 d99635086b3..8017111a4f4 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 00000000000..144baca1a5b --- /dev/null +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -0,0 +1,601 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { + validateTTimerIndex, + pauseTTimer, + resumeTTimer, + restartTTimer, + createCountdownTTimer, + createFreeRunTTimer, + calculateTTimerCurrentTime, + calculateNextTimeOfDayTarget, + createTimeOfDayTTimer, +} from '../tTimers.js' +import type { + RundownTTimerMode, + RundownTTimerModeTimeOfDay, +} 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) + }) + }) + + describe('calculateNextTimeOfDayTarget', () => { + // Mock date to 2026-01-19 10:00:00 UTC for predictable tests + const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(MOCK_DATE) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return number input unchanged (unix timestamp)', () => { + const timestamp = 1737331200000 // Some future timestamp + expect(calculateNextTimeOfDayTarget(timestamp)).toBe(timestamp) + }) + + it('should return null for null/undefined/empty input', () => { + expect(calculateNextTimeOfDayTarget('' as string)).toBeNull() + expect(calculateNextTimeOfDayTarget(' ')).toBeNull() + }) + + // 24-hour time formats + it('should parse 24-hour time HH:mm', () => { + const result = calculateNextTimeOfDayTarget('13:34') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T13:34:00.000Z') + }) + + it('should parse 24-hour time H:mm (single digit hour)', () => { + const result = calculateNextTimeOfDayTarget('9:05') + expect(result).not.toBeNull() + // 9:05 is in the past (before 10:00), so chrono bumps to tomorrow + expect(new Date(result!).toISOString()).toBe('2026-01-20T09:05:00.000Z') + }) + + it('should parse 24-hour time with seconds HH:mm:ss', () => { + const result = calculateNextTimeOfDayTarget('14:30:45') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T14:30:45.000Z') + }) + + // 12-hour time formats + it('should parse 12-hour time with pm', () => { + const result = calculateNextTimeOfDayTarget('5:13pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T17:13:00.000Z') + }) + + it('should parse 12-hour time with PM (uppercase)', () => { + const result = calculateNextTimeOfDayTarget('5:13PM') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T17:13:00.000Z') + }) + + it('should parse 12-hour time with am', () => { + const result = calculateNextTimeOfDayTarget('9:30am') + expect(result).not.toBeNull() + // 9:30am is in the past (before 10:00), so chrono bumps to tomorrow + expect(new Date(result!).toISOString()).toBe('2026-01-20T09:30:00.000Z') + }) + + it('should parse 12-hour time with space before am/pm', () => { + const result = calculateNextTimeOfDayTarget('3:45 pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:45:00.000Z') + }) + + it('should parse 12-hour time with seconds', () => { + const result = calculateNextTimeOfDayTarget('11:30:15pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T23:30:15.000Z') + }) + + // Date + time formats + it('should parse date with time (slash separator)', () => { + const result = calculateNextTimeOfDayTarget('1/19/2026 15:43') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:00.000Z') + }) + + it('should parse date with time and seconds', () => { + const result = calculateNextTimeOfDayTarget('1/19/2026 15:43:30') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:30.000Z') + }) + + it('should parse date with 12-hour time', () => { + const result = calculateNextTimeOfDayTarget('1/19/2026 3:43pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:00.000Z') + }) + + // ISO 8601 format + it('should parse ISO 8601 format', () => { + const result = calculateNextTimeOfDayTarget('2026-01-19T15:43:00') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:43:00.000Z') + }) + + it('should parse ISO 8601 with timezone', () => { + const result = calculateNextTimeOfDayTarget('2026-01-19T15:43:00+01:00') + expect(result).not.toBeNull() + // +01:00 means the time is 1 hour ahead of UTC, so 15:43 +01:00 = 14:43 UTC + expect(new Date(result!).toISOString()).toBe('2026-01-19T14:43:00.000Z') + }) + + // Natural language formats (chrono-node strength) + it('should parse natural language date', () => { + const result = calculateNextTimeOfDayTarget('January 19, 2026 at 3:30pm') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T15:30:00.000Z') + }) + + it('should parse "noon"', () => { + const result = calculateNextTimeOfDayTarget('noon') + expect(result).not.toBeNull() + expect(new Date(result!).toISOString()).toBe('2026-01-19T12:00:00.000Z') + }) + + it('should parse "midnight"', () => { + const result = calculateNextTimeOfDayTarget('midnight') + expect(result).not.toBeNull() + // Midnight is in the past (before 10:00), so chrono bumps to tomorrow + expect(new Date(result!).toISOString()).toBe('2026-01-20T00:00:00.000Z') + }) + + // Edge cases + it('should return null for invalid time string', () => { + expect(calculateNextTimeOfDayTarget('not a time')).toBeNull() + }) + + it('should return null for gibberish', () => { + expect(calculateNextTimeOfDayTarget('asdfghjkl')).toBeNull() + }) + }) + + describe('createTimeOfDayTTimer', () => { + // Mock date to 2026-01-19 10:00:00 UTC for predictable tests + const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(MOCK_DATE) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should create a timeOfDay timer with valid time string', () => { + const result = createTimeOfDayTTimer('15:30', { stopAtZero: true }) + + expect(result).toEqual({ + type: 'timeOfDay', + stopAtZero: true, + targetTime: expect.any(Number), // new target time + targetRaw: '15:30', + }) + }) + + it('should create a timeOfDay timer with numeric timestamp', () => { + const timestamp = 1737331200000 + const result = createTimeOfDayTTimer(timestamp, { stopAtZero: false }) + + expect(result).toEqual({ + type: 'timeOfDay', + targetTime: timestamp, + targetRaw: timestamp, + stopAtZero: false, + }) + }) + + it('should throw for invalid time string', () => { + expect(() => createTimeOfDayTTimer('invalid', { stopAtZero: true })).toThrow( + 'Unable to parse target time for timeOfDay T-timer' + ) + }) + + it('should throw for empty string', () => { + expect(() => createTimeOfDayTTimer('', { stopAtZero: true })).toThrow( + 'Unable to parse target time for timeOfDay T-timer' + ) + }) + }) + + describe('restartTTimer with timeOfDay', () => { + // Mock date to 2026-01-19 10:00:00 UTC for predictable tests + const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(MOCK_DATE) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should restart a timeOfDay timer with valid targetRaw', () => { + const timer: RundownTTimerMode = { + type: 'timeOfDay', + targetTime: 1737300000000, + targetRaw: '15:30', + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toEqual({ + ...timer, + targetTime: expect.any(Number), // new target time + }) + expect((result as RundownTTimerModeTimeOfDay).targetTime).toBeGreaterThan(timer.targetTime) + }) + + it('should return null for timeOfDay timer with invalid targetRaw', () => { + const timer: RundownTTimerMode = { + type: 'timeOfDay', + targetTime: 1737300000000, + targetRaw: 'invalid', + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toBeNull() + }) + + it('should return null for timeOfDay timer with unix timestamp', () => { + const timer: RundownTTimerMode = { + type: 'timeOfDay', + targetTime: 1737300000000, + targetRaw: 1737300000000, + stopAtZero: true, + } + + const result = restartTTimer(timer) + + expect(result).toBeNull() + }) + }) +}) diff --git a/packages/job-worker/src/playout/model/PlayoutModel.ts b/packages/job-worker/src/playout/model/PlayoutModel.ts index 0dff06ff919..439d58b8954 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 52253f1a2f5..a593fad9c66 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' @@ -71,6 +72,7 @@ import { PieceInstanceWithTimings } from '@sofie-automation/corelib/dist/playout import { NotificationsModelHelper } from '../../../notifications/NotificationsModelHelper.js' import { getExpectedLatency } from '@sofie-automation/corelib/dist/studio/playout' import { ExpectedPackage } from '@sofie-automation/blueprints-integration' +import { validateTTimerIndex } from '../../tTimers.js' export class PlayoutModelReadonlyImpl implements PlayoutModelReadonly { public readonly playlistId: RundownPlaylistId @@ -877,6 +879,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 00000000000..5477491d714 --- /dev/null +++ b/packages/job-worker/src/playout/tTimers.ts @@ -0,0 +1,172 @@ +import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { getCurrentTime } from '../lib/index.js' +import type { ReadonlyDeep } from 'type-fest' +import * as chrono from 'chrono-node' + +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') { + return { + ...timer, + startTime: getCurrentTime(), + pauseTime: timer.pauseTime ? getCurrentTime() : null, + } + } else if (timer?.type === 'timeOfDay') { + const nextTime = calculateNextTimeOfDayTarget(timer.targetRaw) + // If we can't calculate the next time, we can't restart + if (nextTime === null || nextTime === timer.targetTime) return null + + return { + ...timer, + targetTime: nextTime, + } + } 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, + } +} + +export function createTimeOfDayTTimer( + targetTime: string | number, + options: { + stopAtZero: boolean + } +): ReadonlyDeep { + const nextTime = calculateNextTimeOfDayTarget(targetTime) + if (nextTime === null) throw new Error('Unable to parse target time for timeOfDay T-timer') + + return { + type: 'timeOfDay', + targetTime: nextTime, + targetRaw: targetTime, + stopAtZero: !!options.stopAtZero, + } +} + +/** + * 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 + } +} + +/** + * Calculate the next target time for a timeOfDay T-timer + * @param targetTime The target time, as a string or timestamp number + * @returns The next target timestamp in milliseconds, or null if it could not be calculated + */ +export function calculateNextTimeOfDayTarget(targetTime: string | number): number | null { + if (typeof targetTime === 'number') { + // This should be a unix timestamp + return targetTime + } + + // Verify we have a string worth parsing + if (typeof targetTime !== 'string' || !targetTime) return null + + const parsed = chrono.parseDate(targetTime, undefined, { + // Always look ahead for the next occurrence + forwardDate: true, + }) + return parsed ? parsed.getTime() : null +} diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index 86e637802a0..eb61a94b06c 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/live-status-gateway/src/topics/__tests__/utils.ts b/packages/live-status-gateway/src/topics/__tests__/utils.ts index 576b1cb7436..23b70507c10 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/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 7434f499fbf..161bbec4481 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 8e402449d98..f57f33d4ed1 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 }, + ], }) } diff --git a/packages/yarn.lock b/packages/yarn.lock index 477f1ce8d35..2af7a6d7fdb 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -6939,6 +6939,7 @@ __metadata: "@sofie-automation/corelib": "npm:1.53.0-in-development" "@sofie-automation/shared-lib": "npm:1.53.0-in-development" amqplib: "npm:^0.10.5" + chrono-node: "npm:^2.9.0" deepmerge: "npm:^4.3.1" elastic-apm-node: "npm:^4.11.0" jest: "npm:^29.7.0" @@ -11278,6 +11279,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"chrono-node@npm:^2.9.0": + version: 2.9.0 + resolution: "chrono-node@npm:2.9.0" + checksum: 10/a30bbaa67f9a127e711db6e694ee4c89292d8f533dbfdc3d7cb34f479728e02e377f682e75ad84dd4b6a16016c248a5e85fb453943b96f93f5993f5ccddc6d08 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.8.0 resolution: "ci-info@npm:3.8.0" From 6f31f6a09e376f87a61015542fb7e19b96e14302 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 20 Jan 2026 14:36:56 +0000 Subject: [PATCH 2/6] fix: expose IBlueprintSegmentDB through getSegment to bluerpints --- .../blueprints-integration/src/context/onSetAsNextContext.ts | 4 ++-- .../src/context/partsAndPieceActionContext.ts | 4 ++-- .../job-worker/src/blueprints/context/OnSetAsNextContext.ts | 4 ++-- packages/job-worker/src/blueprints/context/OnTakeContext.ts | 4 ++-- packages/job-worker/src/blueprints/context/adlibActions.ts | 4 ++-- .../context/services/PartAndPieceInstanceActionService.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/blueprints-integration/src/context/onSetAsNextContext.ts b/packages/blueprints-integration/src/context/onSetAsNextContext.ts index ee7b3aa29e0..c32b06d8e3c 100644 --- a/packages/blueprints-integration/src/context/onSetAsNextContext.ts +++ b/packages/blueprints-integration/src/context/onSetAsNextContext.ts @@ -6,7 +6,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, IEventContext, IShowStyleUserContext, } from '../index.js' @@ -56,7 +56,7 @@ export interface IOnSetAsNextContext extends IShowStyleUserContext, IEventContex /** Gets the Part for a Piece retrieved from findLastScriptedPieceOnLayer. This primarily allows for accessing metadata of the Part */ getPartForPreviousPiece(piece: IBlueprintPieceDB): Promise /** Gets the Segment. This primarily allows for accessing metadata */ - getSegment(segment: 'current' | 'next'): Promise + getSegment(segment: 'current' | 'next'): Promise /** Get a list of the upcoming Parts in the Rundown, in the order that they will be Taken * diff --git a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts index 6f10958eebc..a5a2b9c998c 100644 --- a/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts +++ b/packages/blueprints-integration/src/context/partsAndPieceActionContext.ts @@ -7,7 +7,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, Time, } from '../index.js' import { BlueprintQuickLookInfo } from './quickLoopInfo.js' @@ -50,7 +50,7 @@ export interface IPartAndPieceActionContext { /** Gets the Part for a Piece retrieved from findLastScriptedPieceOnLayer. This primarily allows for accessing metadata of the Part */ getPartForPreviousPiece(piece: IBlueprintPieceDB): Promise /** Gets the Segment. This primarily allows for accessing metadata */ - getSegment(segment: 'current' | 'next'): Promise + getSegment(segment: 'current' | 'next'): Promise /** Get a list of the upcoming Parts in the Rundown, in the order that they will be Taken * diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 198ad84b57f..0e8e1616d2e 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -9,7 +9,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, IEventContext, IOnSetAsNextContext, } from '@sofie-automation/blueprints-integration' @@ -85,7 +85,7 @@ export class OnSetAsNextContext return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } - async getSegment(segment: 'current' | 'next'): Promise { + async getSegment(segment: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getSegment(segment) } diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index b9b8db8cb1e..8a79f7521aa 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -12,7 +12,7 @@ import { TSR, IBlueprintPlayoutDevice, IOnTakeContext, - IBlueprintSegment, + IBlueprintSegmentDB, } from '@sofie-automation/blueprints-integration' import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' @@ -86,7 +86,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex async getResolvedPieceInstances(part: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } - async getSegment(segment: 'current' | 'next'): Promise { + async getSegment(segment: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getSegment(segment) } diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index a8953518484..63c5c3d7839 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -14,7 +14,7 @@ import { TSR, IBlueprintPlayoutDevice, StudioRouteSet, - IBlueprintSegment, + IBlueprintSegmentDB, } from '@sofie-automation/blueprints-integration' import { PartInstanceId, PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ReadonlyDeep } from 'type-fest' @@ -136,7 +136,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct return this.partAndPieceInstanceService.getResolvedPieceInstances(part) } - async getSegment(segment: 'current' | 'next'): Promise { + async getSegment(segment: 'current' | 'next'): Promise { return this.partAndPieceInstanceService.getSegment(segment) } diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 68c807d764d..f58115b375b 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -9,7 +9,7 @@ import { IBlueprintPieceDB, IBlueprintPieceInstance, IBlueprintResolvedPieceInstance, - IBlueprintSegment, + IBlueprintSegmentDB, OmitId, SomeContent, Time, @@ -145,7 +145,7 @@ export class PartAndPieceInstanceActionService { ) return resolvedInstances.map(convertResolvedPieceInstanceToBlueprints) } - getSegment(segment: 'current' | 'next'): IBlueprintSegment | undefined { + getSegment(segment: 'current' | 'next'): IBlueprintSegmentDB | undefined { const partInstance = this.#getPartInstance(segment) if (!partInstance) return undefined From 78dbdc3a16284f774d590da9fd8ec76e69bf2c21 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 20 Jan 2026 15:38:46 +0000 Subject: [PATCH 3/6] chore: ttimer debug test --- .../src/client/ui/RundownView/RundownHeader/RundownHeader.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx index ffbf52f145a..818fc888742 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeader.tsx @@ -106,6 +106,10 @@ export function RundownHeader({ const rundownTimesInfo = checkRundownTimes(playlist.timing) + useEffect(() => { + console.debug(`Rundown T-Timers Info: `, JSON.stringify(playlist.tTimers, undefined, 2)) + }, [playlist.tTimers]) + return ( <> From 9f453d6317a72cba1c7703edf628b3b728194fe5 Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Wed, 4 Feb 2026 10:43:07 +0000 Subject: [PATCH 4/6] T-timers refactor (#71) * Simplify timer state storage Makes calculations of timing consistent for all timer types * Separate T-Timer mode and state State contains the dynamic how much time is left information . Mode contains more static information about the timer's properties etc. * Fix t-timer tests --- meteor/__mocks__/defaultCollectionObjects.ts | 6 +- meteor/server/migration/X_X_X.ts | 8 +- .../corelib/src/dataModel/RundownPlaylist.ts | 57 +-- .../src/__mocks__/defaultCollectionObjects.ts | 6 +- .../context/services/TTimersService.ts | 51 +-- .../services/__tests__/TTimersService.test.ts | 113 +++-- .../__tests__/externalMessageQueue.test.ts | 12 +- .../syncChangesToPartInstance.test.ts | 6 +- .../src/ingest/__tests__/updateNext.test.ts | 6 +- .../__snapshots__/mosIngest.test.ts.snap | 45 ++ .../__snapshots__/playout.test.ts.snap | 3 + .../src/playout/__tests__/tTimers.test.ts | 415 ++++++++++-------- packages/job-worker/src/playout/tTimers.ts | 113 +++-- packages/job-worker/src/rundownPlaylists.ts | 12 +- .../src/__mocks__/defaultCollectionObjects.ts | 6 +- .../lib/__tests__/rundownTiming.test.ts | 6 +- 16 files changed, 491 insertions(+), 374 deletions(-) diff --git a/meteor/__mocks__/defaultCollectionObjects.ts b/meteor/__mocks__/defaultCollectionObjects.ts index faec0a06be9..9b605fd2caf 100644 --- a/meteor/__mocks__/defaultCollectionObjects.ts +++ b/meteor/__mocks__/defaultCollectionObjects.ts @@ -53,9 +53,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/meteor/server/migration/X_X_X.ts b/meteor/server/migration/X_X_X.ts index 7056e6c1e6c..b7c1b39de9a 100644 --- a/meteor/server/migration/X_X_X.ts +++ b/meteor/server/migration/X_X_X.ts @@ -205,13 +205,13 @@ export const addSteps = addMigrationSteps(CURRENT_SYSTEM_VERSION, [ }, migrate: async () => { await RundownPlaylists.mutableCollection.updateAsync( - { tTimers: { $exists: false } }, + { tTimers: { $exists: false } } as any, { $set: { tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }, }, diff --git a/packages/corelib/src/dataModel/RundownPlaylist.ts b/packages/corelib/src/dataModel/RundownPlaylist.ts index 0ea7a83fe82..93c4bb769c9 100644 --- a/packages/corelib/src/dataModel/RundownPlaylist.ts +++ b/packages/corelib/src/dataModel/RundownPlaylist.ts @@ -98,33 +98,11 @@ export type RundownTTimerMode = RundownTTimerModeFreeRun | RundownTTimerModeCoun export interface RundownTTimerModeFreeRun { readonly type: 'freeRun' - /** - * Starting time (unix timestamp) - * This may not be the original start time, if the timer has been paused/resumed - */ - startTime: number - /** - * Set to a timestamp to pause the timer at that timestamp - * When unpausing, the `startTime` should be adjusted to account for the paused duration - */ - pauseTime: number | null - /** The direction to count */ - // direction: 'up' | 'down' // TODO: does this make sense? } export interface RundownTTimerModeCountdown { readonly type: 'countdown' /** - * Starting time (unix timestamp) - * This may not be the original start time, if the timer has been paused/resumed - */ - startTime: number - /** - * Set to a timestamp to pause the timer at that timestamp - * When unpausing, the `targetTime` should be adjusted to account for the paused duration - */ - pauseTime: number | null - /** - * The duration of the countdown in milliseconds + * The original duration of the countdown in milliseconds, so that we know what value to reset to */ readonly duration: number @@ -136,9 +114,6 @@ export interface RundownTTimerModeCountdown { export interface RundownTTimerModeTimeOfDay { readonly type: 'timeOfDay' - /** The target timestamp of the timer, in milliseconds */ - targetTime: number - /** * The raw target string of the timer, as provided when setting the timer * (e.g. "14:30", "2023-12-31T23:59:59Z", or a timestamp number) @@ -151,6 +126,25 @@ export interface RundownTTimerModeTimeOfDay { readonly stopAtZero: boolean } +/** + * Timing state for a timer, optimized for efficient client rendering. + * When running, the client calculates current time from zeroTime. + * When paused, the duration is frozen and sent directly. + */ +export type TimerState = + | { + /** Whether the timer is paused */ + paused: false + /** The absolute timestamp (ms) when the timer reaches/reached zero */ + zeroTime: number + } + | { + /** Whether the timer is paused */ + paused: true + /** The frozen duration value in milliseconds */ + duration: number + } + export type RundownTTimerIndex = 1 | 2 | 3 export interface RundownTTimer { @@ -159,9 +153,18 @@ export interface RundownTTimer { /** A label for the timer */ label: string - /** The current mode of the timer, or null if not configured */ + /** The current mode of the timer, or null if not configured + * + * This defines how the timer behaves + */ mode: RundownTTimerMode | null + /** The current state of the timer, or null if not configured + * + * This contains the information needed to calculate the current time of the timer + */ + state: TimerState | null + /* * Future ideas: * allowUiControl: boolean diff --git a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts index 8d705cc1b7c..0a7478f109b 100644 --- a/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/job-worker/src/__mocks__/defaultCollectionObjects.ts @@ -46,9 +46,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index b8ef3c7e21b..80a7e0faf9f 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -7,7 +7,6 @@ import { assertNever } from '@sofie-automation/corelib/dist/lib' import type { PlayoutModel } from '../../../playout/model/PlayoutModel.js' import { ReadonlyDeep } from 'type-fest' import { - calculateTTimerCurrentTime, createCountdownTTimer, createFreeRunTTimer, createTimeOfDayTTimer, @@ -60,31 +59,35 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } get state(): IPlaylistTTimerState | null { const rawMode = this.#modelTimer.mode - switch (rawMode?.type) { + const rawState = this.#modelTimer.state + + if (!rawMode || !rawState) return null + + const currentTime = rawState.paused ? rawState.duration : rawState.zeroTime - getCurrentTime() + + switch (rawMode.type) { case 'countdown': return { mode: 'countdown', - currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), + currentTime, duration: rawMode.duration, - paused: !!rawMode.pauseTime, + paused: rawState.paused, stopAtZero: rawMode.stopAtZero, } case 'freeRun': return { mode: 'freeRun', - currentTime: calculateTTimerCurrentTime(rawMode.startTime, rawMode.pauseTime), - paused: !!rawMode.pauseTime, + currentTime, + paused: rawState.paused, } case 'timeOfDay': return { mode: 'timeOfDay', - currentTime: rawMode.targetTime - getCurrentTime(), - targetTime: rawMode.targetTime, + currentTime, + targetTime: rawState.paused ? 0 : rawState.zeroTime, targetRaw: rawMode.targetRaw, stopAtZero: rawMode.stopAtZero, } - case undefined: - return null default: assertNever(rawMode) return null @@ -108,12 +111,13 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { this.#playoutModel.updateTTimer({ ...this.#modelTimer, mode: null, + state: null, }) } startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createCountdownTTimer(duration, { + ...createCountdownTTimer(duration, { stopAtZero: options?.stopAtZero ?? true, startPaused: options?.startPaused ?? false, }), @@ -122,7 +126,7 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createTimeOfDayTTimer(targetTime, { + ...createTimeOfDayTTimer(targetTime, { stopAtZero: options?.stopAtZero ?? true, }), }) @@ -130,39 +134,30 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { startFreeRun(options?: { startPaused?: boolean }): void { this.#playoutModel.updateTTimer({ ...this.#modelTimer, - mode: createFreeRunTTimer({ + ...createFreeRunTTimer({ startPaused: options?.startPaused ?? false, }), }) } pause(): boolean { - const newTimer = pauseTTimer(this.#modelTimer.mode) + const newTimer = pauseTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } resume(): boolean { - const newTimer = resumeTTimer(this.#modelTimer.mode) + const newTimer = resumeTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } restart(): boolean { - const newTimer = restartTTimer(this.#modelTimer.mode) + const newTimer = restartTTimer(this.#modelTimer) if (!newTimer) return false - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, - mode: newTimer, - }) + this.#playoutModel.updateTTimer(newTimer) return true } } diff --git a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts index 7943a895924..352a48bde30 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -23,9 +23,9 @@ function createMockPlayoutModel(tTimers: [RundownTTimer, RundownTTimer, RundownT function createEmptyTTimers(): [RundownTTimer, RundownTTimer, RundownTTimer] { return [ - { index: 1, label: 'Timer 1', mode: null }, - { index: 2, label: 'Timer 2', mode: null }, - { index: 3, label: 'Timer 3', mode: null }, + { index: 1, label: 'Timer 1', mode: null, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, ] } @@ -91,8 +91,10 @@ describe('TTimersService', () => { describe('clearAllTimers', () => { it('should call clearTimer on all timers', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } - tTimers[1].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } + tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[1].state = { paused: false, zeroTime: 65000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const service = new TTimersService(mockPlayoutModel) @@ -151,7 +153,8 @@ describe('PlaylistTTimerImpl', () => { it('should return running freeRun state', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -164,7 +167,8 @@ describe('PlaylistTTimerImpl', () => { it('should return paused freeRun state', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: 3000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -179,11 +183,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: null, duration: 60000, stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -200,11 +203,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: 7000, duration: 60000, stopAtZero: false, } + tTimers[0].state = { paused: true, duration: 2000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -221,10 +223,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, // 10 seconds in the future targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -242,10 +244,10 @@ describe('PlaylistTTimerImpl', () => { const targetTimestamp = 1737331200000 tTimers[0].mode = { type: 'timeOfDay', - targetTime: targetTimestamp, targetRaw: targetTimestamp, stopAtZero: false, } + tTimers[0].state = { paused: false, zeroTime: targetTimestamp } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -271,6 +273,7 @@ describe('PlaylistTTimerImpl', () => { index: 1, label: 'New Label', mode: null, + state: null, }) }) }) @@ -278,7 +281,8 @@ describe('PlaylistTTimerImpl', () => { describe('clearTimer', () => { it('should clear the timer mode', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -288,6 +292,7 @@ describe('PlaylistTTimerImpl', () => { index: 1, label: 'Timer 1', mode: null, + state: null, }) }) }) @@ -305,11 +310,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: null, duration: 60000, stopAtZero: true, }, + state: { paused: false, zeroTime: 70000 }, }) }) @@ -325,11 +329,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: 10000, duration: 30000, stopAtZero: false, }, + state: { paused: true, duration: 30000 }, }) }) }) @@ -347,9 +350,8 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 10000, - pauseTime: null, }, + state: { paused: false, zeroTime: 10000 }, }) }) @@ -365,9 +367,8 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 10000, - pauseTime: 10000, }, + state: { paused: true, duration: 0 }, }) }) }) @@ -385,10 +386,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '15:30', stopAtZero: true, }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, }) }) @@ -405,10 +409,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: targetTimestamp, targetRaw: targetTimestamp, stopAtZero: true, }, + state: { + paused: false, + zeroTime: targetTimestamp, + }, }) }) @@ -427,6 +434,10 @@ describe('PlaylistTTimerImpl', () => { targetRaw: '18:00', stopAtZero: false, }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), }) }) @@ -442,10 +453,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: expect.objectContaining({ type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '5:30pm', stopAtZero: true, }), + state: expect.objectContaining({ + paused: false, + zeroTime: expect.any(Number), + }), }) }) @@ -469,7 +483,8 @@ describe('PlaylistTTimerImpl', () => { describe('pause', () => { it('should pause a running freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -481,15 +496,15 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 5000, - pauseTime: 10000, }, + state: { paused: true, duration: -5000 }, }) }) it('should pause a running countdown timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 70000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -501,11 +516,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 5000, - pauseTime: 10000, duration: 60000, stopAtZero: true, }, + state: { paused: true, duration: 60000 }, }) }) @@ -524,10 +538,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -541,7 +555,8 @@ describe('PlaylistTTimerImpl', () => { describe('resume', () => { it('should resume a paused freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: 8000 } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: true, duration: -3000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -553,15 +568,15 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'freeRun', - startTime: 7000, // adjusted for pause duration - pauseTime: null, }, + state: { paused: false, zeroTime: 7000 }, // adjusted for pause duration }) }) it('should return true but not change a running timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -587,10 +602,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 20000, targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 20000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -604,7 +619,8 @@ describe('PlaylistTTimerImpl', () => { describe('restart', () => { it('should restart a countdown timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'countdown', startTime: 5000, pauseTime: null, duration: 60000, stopAtZero: true } + tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } + tTimers[0].state = { paused: false, zeroTime: 40000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -616,11 +632,10 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, // reset to now - pauseTime: null, duration: 60000, stopAtZero: true, }, + state: { paused: false, zeroTime: 70000 }, // reset to now + duration }) }) @@ -628,11 +643,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', - startTime: 5000, - pauseTime: 8000, duration: 60000, stopAtZero: false, } + tTimers[0].state = { paused: true, duration: 15000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -644,17 +658,17 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'countdown', - startTime: 10000, - pauseTime: 10000, // also reset to now (paused at start) duration: 60000, stopAtZero: false, }, + state: { paused: true, duration: 60000 }, // reset to full duration, paused }) }) it('should return false for freeRun timer', () => { const tTimers = createEmptyTTimers() - tTimers[0].mode = { type: 'freeRun', startTime: 5000, pauseTime: null } + tTimers[0].mode = { type: 'freeRun' } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -668,10 +682,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 5000, // old target time targetRaw: '15:30', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) @@ -683,10 +697,13 @@ describe('PlaylistTTimerImpl', () => { label: 'Timer 1', mode: { type: 'timeOfDay', - targetTime: expect.any(Number), // new target time targetRaw: '15:30', stopAtZero: true, }, + state: { + paused: false, + zeroTime: expect.any(Number), // new target time + }, }) }) @@ -694,10 +711,10 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'timeOfDay', - targetTime: 5000, targetRaw: 'invalid-time-string', stopAtZero: true, } + tTimers[0].state = { paused: false, zeroTime: 5000 } const mockPlayoutModel = createMockPlayoutModel(tTimers) const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) diff --git a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts index a39d82f7cc6..54b97fb0110 100644 --- a/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts +++ b/packages/job-worker/src/events/__tests__/externalMessageQueue.test.ts @@ -57,9 +57,9 @@ describe('Test external message queue static methods', () => { }, rundownIdsInOrder: [protectString('rundown_1')], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) await context.mockCollections.Rundowns.insertOne({ @@ -207,9 +207,9 @@ describe('Test sending messages to mocked endpoints', () => { }, rundownIdsInOrder: [protectString('rundown_1')], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index 47ddfed664b..adf7cbaeab0 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -316,9 +316,9 @@ describe('SyncChangesToPartInstancesWorker', () => { timing: { type: PlaylistTimingType.None }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } diff --git a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts index 91df4cc24ef..cc40fff7157 100644 --- a/packages/job-worker/src/ingest/__tests__/updateNext.test.ts +++ b/packages/job-worker/src/ingest/__tests__/updateNext.test.ts @@ -35,9 +35,9 @@ async function createMockRO(context: MockJobContext): Promise { rundownIdsInOrder: [rundownId], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) diff --git a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap index 45148a92f6f..12dd5ff6796 100644 --- a/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap +++ b/packages/job-worker/src/ingest/mosDevice/__tests__/__snapshots__/mosIngest.test.ts.snap @@ -20,16 +20,19 @@ exports[`Test recieved mos ingest payloads mosRoCreate 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -329,16 +332,19 @@ exports[`Test recieved mos ingest payloads mosRoCreate: replace existing 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -630,16 +636,19 @@ exports[`Test recieved mos ingest payloads mosRoFullStory: Valid data 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -952,16 +961,19 @@ exports[`Test recieved mos ingest payloads mosRoReadyToAir: Update ro 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1264,16 +1276,19 @@ exports[`Test recieved mos ingest payloads mosRoStatus: Update ro 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1574,16 +1589,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryDelete: Remove segment 1`] "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -1852,16 +1870,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: Into segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2175,16 +2196,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryInsert: New segment 1`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2506,16 +2530,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Move whole segment to "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -2820,16 +2847,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryMove: Within segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3134,16 +3164,19 @@ exports[`Test recieved mos ingest payloads mosRoStoryReplace: Same segment 1`] = "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3447,16 +3480,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -3753,16 +3789,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Swap across segments2 "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -4091,16 +4130,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: With first in same se "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { @@ -4405,16 +4447,19 @@ exports[`Test recieved mos ingest payloads mosRoStorySwap: Within same segment 1 "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { diff --git a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap index 8017111a4f4..690dd9ac31b 100644 --- a/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap +++ b/packages/job-worker/src/playout/__tests__/__snapshots__/playout.test.ts.snap @@ -82,16 +82,19 @@ exports[`Playout API Basic rundown control 4`] = ` "index": 1, "label": "", "mode": null, + "state": null, }, { "index": 2, "label": "", "mode": null, + "state": null, }, { "index": 3, "label": "", "mode": null, + "state": null, }, ], "timing": { diff --git a/packages/job-worker/src/playout/__tests__/tTimers.test.ts b/packages/job-worker/src/playout/__tests__/tTimers.test.ts index 144baca1a5b..bea1a2c92b3 100644 --- a/packages/job-worker/src/playout/__tests__/tTimers.test.ts +++ b/packages/job-worker/src/playout/__tests__/tTimers.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { useFakeCurrentTime, useRealCurrentTime, adjustFakeTime } from '../../__mocks__/time.js' +import { useFakeCurrentTime, useRealCurrentTime } from '../../__mocks__/time.js' import { validateTTimerIndex, pauseTTimer, @@ -7,14 +7,10 @@ import { restartTTimer, createCountdownTTimer, createFreeRunTTimer, - calculateTTimerCurrentTime, calculateNextTimeOfDayTarget, createTimeOfDayTTimer, } from '../tTimers.js' -import type { - RundownTTimerMode, - RundownTTimerModeTimeOfDay, -} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('tTimers utils', () => { beforeEach(() => { @@ -51,48 +47,63 @@ describe('tTimers utils', () => { describe('pauseTTimer', () => { it('should pause a running countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // 60 seconds from now } const result = pauseTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 5000, - pauseTime: 10000, // getCurrentTime() - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 60000 }, // Captured remaining time }) }) it('should pause a running freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // Started 5 seconds ago } const result = pauseTTimer(timer) expect(result).toEqual({ - type: 'freeRun', - startTime: 5000, - pauseTime: 10000, + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // Elapsed time (negative for counting up) }) }) it('should return unchanged countdown timer if already paused', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 7000, // already paused - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // already paused } const result = pauseTTimer(timer) @@ -101,10 +112,13 @@ describe('tTimers utils', () => { }) it('should return unchanged freeRun timer if already paused', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: 7000, // already paused + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 5000 }, // already paused } const result = pauseTTimer(timer) @@ -112,59 +126,77 @@ describe('tTimers utils', () => { expect(result).toBe(timer) // same reference, unchanged }) - it('should return null for null timer', () => { - expect(pauseTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(pauseTTimer(timer)).toBeNull() }) }) describe('resumeTTimer', () => { it('should resume a paused countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 8000, // paused 3 seconds after start - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: true, duration: 30000 }, // 30 seconds remaining } const result = resumeTTimer(timer) - // pausedOffset = 5000 - 8000 = -3000 - // newStartTime = 10000 + (-3000) = 7000 expect(result).toEqual({ - type: 'countdown', - startTime: 7000, // 3 seconds before now - pauseTime: null, - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 40000 }, // now (10000) + duration (30000) }) }) it('should resume a paused freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 2000, - pauseTime: 6000, // paused 4 seconds after start + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: -5000 }, // 5 seconds elapsed } const result = resumeTTimer(timer) - // pausedOffset = 2000 - 6000 = -4000 - // newStartTime = 10000 + (-4000) = 6000 expect(result).toEqual({ - type: 'freeRun', - startTime: 6000, // 4 seconds before now - pauseTime: null, + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // now (10000) + duration (-5000) }) }) it('should return countdown timer unchanged if already running', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, // already running - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // already running } const result = resumeTTimer(timer) @@ -173,10 +205,13 @@ describe('tTimers utils', () => { }) it('should return freeRun timer unchanged if already running', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, // already running + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, // already running } const result = resumeTTimer(timer) @@ -184,64 +219,93 @@ describe('tTimers utils', () => { expect(result).toBe(timer) // same reference }) - it('should return null for null timer', () => { - expect(resumeTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(resumeTTimer(timer)).toBeNull() }) }) describe('restartTTimer', () => { it('should restart a running countdown timer', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 40000 }, // Partway through } const result = restartTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, // now - pauseTime: null, - duration: 60000, - stopAtZero: true, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) }) }) it('should restart a paused countdown timer (stays paused)', () => { - const timer: RundownTTimerMode = { - type: 'countdown', - startTime: 5000, - pauseTime: 8000, - duration: 60000, - stopAtZero: false, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: false, + }, + state: { paused: true, duration: 15000 }, // Paused with time remaining } const result = restartTTimer(timer) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, // now - pauseTime: 10000, // also now (paused at start) - duration: 60000, - stopAtZero: false, + index: 1, + label: 'Test', + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: false, + }, + state: { paused: true, duration: 60000 }, // Reset to full duration, still paused }) }) it('should return null for freeRun timer', () => { - const timer: RundownTTimerMode = { - type: 'freeRun', - startTime: 5000, - pauseTime: null, + const timer: RundownTTimer = { + index: 2, + label: 'Test', + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 5000 }, } expect(restartTTimer(timer)).toBeNull() }) - it('should return null for null timer', () => { - expect(restartTTimer(null)).toBeNull() + it('should return null for timer with no mode', () => { + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: null, + state: null, + } + + expect(restartTTimer(timer)).toBeNull() }) }) @@ -253,11 +317,12 @@ describe('tTimers utils', () => { }) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, - pauseTime: null, - duration: 60000, - stopAtZero: true, + mode: { + type: 'countdown', + duration: 60000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 70000 }, // now (10000) + duration (60000) }) }) @@ -268,11 +333,12 @@ describe('tTimers utils', () => { }) expect(result).toEqual({ - type: 'countdown', - startTime: 10000, - pauseTime: 10000, - duration: 30000, - stopAtZero: false, + mode: { + type: 'countdown', + duration: 30000, + stopAtZero: false, + }, + state: { paused: true, duration: 30000 }, }) }) @@ -300,9 +366,10 @@ describe('tTimers utils', () => { const result = createFreeRunTTimer({ startPaused: false }) expect(result).toEqual({ - type: 'freeRun', - startTime: 10000, - pauseTime: null, + mode: { + type: 'freeRun', + }, + state: { paused: false, zeroTime: 10000 }, // now }) }) @@ -310,51 +377,14 @@ describe('tTimers utils', () => { const result = createFreeRunTTimer({ startPaused: true }) expect(result).toEqual({ - type: 'freeRun', - startTime: 10000, - pauseTime: 10000, + mode: { + type: 'freeRun', + }, + state: { paused: true, duration: 0 }, }) }) }) - describe('calculateTTimerCurrentTime', () => { - it('should calculate time for a running timer', () => { - // Timer started at 5000, current time is 10000 - const result = calculateTTimerCurrentTime(5000, null) - - expect(result).toBe(5000) // 10000 - 5000 - }) - - it('should calculate time for a paused timer', () => { - // Timer started at 5000, paused at 8000 - const result = calculateTTimerCurrentTime(5000, 8000) - - expect(result).toBe(3000) // 8000 - 5000 - }) - - it('should handle timer that just started', () => { - const result = calculateTTimerCurrentTime(10000, null) - - expect(result).toBe(0) - }) - - it('should handle timer paused immediately', () => { - const result = calculateTTimerCurrentTime(10000, 10000) - - expect(result).toBe(0) - }) - - it('should update as time progresses', () => { - const startTime = 5000 - - expect(calculateTTimerCurrentTime(startTime, null)).toBe(5000) - - adjustFakeTime(2000) // Now at 12000 - - expect(calculateTTimerCurrentTime(startTime, null)).toBe(7000) - }) - }) - describe('calculateNextTimeOfDayTarget', () => { // Mock date to 2026-01-19 10:00:00 UTC for predictable tests const MOCK_DATE = new Date('2026-01-19T10:00:00Z').getTime() @@ -510,10 +540,15 @@ describe('tTimers utils', () => { const result = createTimeOfDayTTimer('15:30', { stopAtZero: true }) expect(result).toEqual({ - type: 'timeOfDay', - stopAtZero: true, - targetTime: expect.any(Number), // new target time - targetRaw: '15:30', + mode: { + type: 'timeOfDay', + stopAtZero: true, + targetRaw: '15:30', + }, + state: { + paused: false, + zeroTime: expect.any(Number), // Parsed target time + }, }) }) @@ -522,10 +557,15 @@ describe('tTimers utils', () => { const result = createTimeOfDayTTimer(timestamp, { stopAtZero: false }) expect(result).toEqual({ - type: 'timeOfDay', - targetTime: timestamp, - targetRaw: timestamp, - stopAtZero: false, + mode: { + type: 'timeOfDay', + targetRaw: timestamp, + stopAtZero: false, + }, + state: { + paused: false, + zeroTime: timestamp, + }, }) }) @@ -556,28 +596,41 @@ describe('tTimers utils', () => { }) it('should restart a timeOfDay timer with valid targetRaw', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: '15:30', - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: '15:30', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) - expect(result).toEqual({ - ...timer, - targetTime: expect.any(Number), // new target time + expect(result).not.toBeNull() + expect(result?.mode).toEqual(timer.mode) + expect(result?.state).toEqual({ + paused: false, + zeroTime: expect.any(Number), // new target time }) - expect((result as RundownTTimerModeTimeOfDay).targetTime).toBeGreaterThan(timer.targetTime) + if (!result || !result.state || result.state.paused) { + throw new Error('Expected running timeOfDay timer state') + } + expect(result.state.zeroTime).toBeGreaterThan(1737300000000) }) it('should return null for timeOfDay timer with invalid targetRaw', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: 'invalid', - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 'invalid', + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) @@ -586,11 +639,15 @@ describe('tTimers utils', () => { }) it('should return null for timeOfDay timer with unix timestamp', () => { - const timer: RundownTTimerMode = { - type: 'timeOfDay', - targetTime: 1737300000000, - targetRaw: 1737300000000, - stopAtZero: true, + const timer: RundownTTimer = { + index: 1, + label: 'Test', + mode: { + type: 'timeOfDay', + targetRaw: 1737300000000, + stopAtZero: true, + }, + state: { paused: false, zeroTime: 1737300000000 }, } const result = restartTTimer(timer) diff --git a/packages/job-worker/src/playout/tTimers.ts b/packages/job-worker/src/playout/tTimers.ts index 5477491d714..af86616f82a 100644 --- a/packages/job-worker/src/playout/tTimers.ts +++ b/packages/job-worker/src/playout/tTimers.ts @@ -1,4 +1,9 @@ -import type { RundownTTimerIndex, RundownTTimerMode } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { + RundownTTimerIndex, + RundownTTimerMode, + RundownTTimer, + TimerState, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { getCurrentTime } from '../lib/index.js' import type { ReadonlyDeep } from 'type-fest' import * as chrono from 'chrono-node' @@ -12,16 +17,16 @@ export function validateTTimerIndex(index: number): asserts index is RundownTTim * @param timer Timer to update * @returns If the timer supports pausing, the timer in paused state, otherwise null */ -export function pauseTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { - if (timer?.type === 'countdown' || timer?.type === 'freeRun') { - if (timer.pauseTime) { +export function pauseTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown' || timer.mode.type === 'freeRun') { + if (timer.state.paused) { // Already paused return timer } - return { ...timer, - pauseTime: getCurrentTime(), + state: { paused: true, duration: timer.state.zeroTime - getCurrentTime() }, } } else { return null @@ -33,20 +38,17 @@ export function pauseTTimer(timer: ReadonlyDeep | null): Read * @param timer Timer to update * @returns If the timer supports pausing, the timer in resumed state, otherwise null */ -export function resumeTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { - if (timer?.type === 'countdown' || timer?.type === 'freeRun') { - if (!timer.pauseTime) { +export function resumeTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown' || timer.mode.type === 'freeRun') { + if (!timer.state.paused) { // Already running return timer } - const pausedOffset = timer.startTime - timer.pauseTime - const newStartTime = getCurrentTime() + pausedOffset - return { ...timer, - startTime: newStartTime, - pauseTime: null, + state: { paused: false, zeroTime: timer.state.duration + getCurrentTime() }, } } else { return null @@ -58,21 +60,23 @@ export function resumeTTimer(timer: ReadonlyDeep | null): Rea * @param timer Timer to update * @returns If the timer supports restarting, the restarted timer, otherwise null */ -export function restartTTimer(timer: ReadonlyDeep | null): ReadonlyDeep | null { - if (timer?.type === 'countdown') { +export function restartTTimer(timer: ReadonlyDeep): ReadonlyDeep | null { + if (!timer.mode || !timer.state) return null + if (timer.mode.type === 'countdown') { return { ...timer, - startTime: getCurrentTime(), - pauseTime: timer.pauseTime ? getCurrentTime() : null, + state: timer.state.paused + ? { paused: true, duration: timer.mode.duration } + : { paused: false, zeroTime: getCurrentTime() + timer.mode.duration }, } - } else if (timer?.type === 'timeOfDay') { - const nextTime = calculateNextTimeOfDayTarget(timer.targetRaw) - // If we can't calculate the next time, we can't restart - if (nextTime === null || nextTime === timer.targetTime) return null + } else if (timer.mode.type === 'timeOfDay') { + const nextTime = calculateNextTimeOfDayTarget(timer.mode.targetRaw) + // If we can't calculate the next time, or it's the same, we can't restart + if (nextTime === null || (timer.state.paused ? false : nextTime === timer.state.zeroTime)) return null return { ...timer, - targetTime: nextTime, + state: { paused: false, zeroTime: nextTime }, } } else { return null @@ -80,11 +84,10 @@ export function restartTTimer(timer: ReadonlyDeep | null): Re } /** - * Create a new countdown T-timer - * @param index Timer index + * Create a new countdown T-timer mode and initial state * @param duration Duration in milliseconds * @param options Options for the countdown - * @returns The created T-timer + * @returns The created T-timer mode and state */ export function createCountdownTTimer( duration: number, @@ -92,16 +95,18 @@ export function createCountdownTTimer( stopAtZero: boolean startPaused: boolean } -): ReadonlyDeep { +): { mode: ReadonlyDeep; state: ReadonlyDeep } { if (duration <= 0) throw new Error('Duration must be greater than zero') - const now = getCurrentTime() return { - type: 'countdown', - startTime: now, - pauseTime: options.startPaused ? now : null, - duration, - stopAtZero: !!options.stopAtZero, + mode: { + type: 'countdown', + duration, + stopAtZero: !!options.stopAtZero, + }, + state: options.startPaused + ? { paused: true, duration: duration } + : { paused: false, zeroTime: getCurrentTime() + duration }, } } @@ -110,43 +115,35 @@ export function createTimeOfDayTTimer( options: { stopAtZero: boolean } -): ReadonlyDeep { +): { mode: ReadonlyDeep; state: ReadonlyDeep } { const nextTime = calculateNextTimeOfDayTarget(targetTime) if (nextTime === null) throw new Error('Unable to parse target time for timeOfDay T-timer') return { - type: 'timeOfDay', - targetTime: nextTime, - targetRaw: targetTime, - stopAtZero: !!options.stopAtZero, + mode: { + type: 'timeOfDay', + targetRaw: targetTime, + stopAtZero: !!options.stopAtZero, + }, + state: { paused: false, zeroTime: nextTime }, } } /** - * Create a new free-running T-timer - * @param index Timer index + * Create a new free-running T-timer mode and initial state * @param options Options for the free-run - * @returns The created T-timer + * @returns The created T-timer mode and state */ -export function createFreeRunTTimer(options: { startPaused: boolean }): ReadonlyDeep { +export function createFreeRunTTimer(options: { startPaused: boolean }): { + mode: ReadonlyDeep + state: ReadonlyDeep +} { const now = getCurrentTime() return { - type: 'freeRun', - startTime: now, - pauseTime: options.startPaused ? now : null, - } -} - -/** - * Calculate the current time of a T-timer - * @param startTime The start time of the timer (unix timestamp) - * @param pauseTime The pause time of the timer (unix timestamp) or null if not paused - */ -export function calculateTTimerCurrentTime(startTime: number, pauseTime: number | null): number { - if (pauseTime) { - return pauseTime - startTime - } else { - return getCurrentTime() - startTime + mode: { + type: 'freeRun', + }, + state: options.startPaused ? { paused: true, duration: 0 } : { paused: false, zeroTime: now }, } } diff --git a/packages/job-worker/src/rundownPlaylists.ts b/packages/job-worker/src/rundownPlaylists.ts index eb61a94b06c..33faf33e29a 100644 --- a/packages/job-worker/src/rundownPlaylists.ts +++ b/packages/job-worker/src/rundownPlaylists.ts @@ -237,9 +237,9 @@ export function produceRundownPlaylistInfoFromRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], ...clone(existingPlaylist), @@ -338,9 +338,9 @@ function defaultPlaylistForRundown( previousPartInfo: null, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], ...clone(existingPlaylist), diff --git a/packages/webui/src/__mocks__/defaultCollectionObjects.ts b/packages/webui/src/__mocks__/defaultCollectionObjects.ts index 161bbec4481..6ed4b6031b3 100644 --- a/packages/webui/src/__mocks__/defaultCollectionObjects.ts +++ b/packages/webui/src/__mocks__/defaultCollectionObjects.ts @@ -49,9 +49,9 @@ export function defaultRundownPlaylist(_id: RundownPlaylistId, studioId: StudioI }, rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], } } diff --git a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts index f57f33d4ed1..a7cabd427ea 100644 --- a/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts +++ b/packages/webui/src/client/lib/__tests__/rundownTiming.test.ts @@ -30,9 +30,9 @@ function makeMockPlaylist(): DBRundownPlaylist { rundownIdsInOrder: [], tTimers: [ - { index: 1, label: '', mode: null }, - { index: 2, label: '', mode: null }, - { index: 3, label: '', mode: null }, + { index: 1, label: '', mode: null, state: null }, + { index: 2, label: '', mode: null, state: null }, + { index: 3, label: '', mode: null, state: null }, ], }) } From 2a54056eeb39da0a5eb6f668d296acc2b6b40843 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 3 Feb 2026 16:27:59 +0000 Subject: [PATCH 5/6] feat: allow t-timers from syncIngestChanges --- .../src/context/syncIngestChangesContext.ts | 3 +- .../context-OnSetAsNextContext.test.ts | 11 +- .../__tests__/context-OnTakeContext.test.ts | 11 +- .../__tests__/context-adlibActions.test.ts | 11 +- .../blueprints/context/OnSetAsNextContext.ts | 2 +- .../src/blueprints/context/OnTakeContext.ts | 2 +- .../context/RundownActivationContext.ts | 2 +- .../SyncIngestUpdateToPartInstanceContext.ts | 94 +++--- .../src/blueprints/context/adlibActions.ts | 2 +- .../context/services/TTimersService.ts | 94 +++--- .../services/__tests__/TTimersService.test.ts | 280 ++++++++++-------- .../syncChangesToPartInstance.test.ts | 15 +- .../src/ingest/syncChangesToPartInstance.ts | 6 + 13 files changed, 318 insertions(+), 215 deletions(-) diff --git a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts index e6917d443b6..668e5bfd3e1 100644 --- a/packages/blueprints-integration/src/context/syncIngestChangesContext.ts +++ b/packages/blueprints-integration/src/context/syncIngestChangesContext.ts @@ -6,8 +6,9 @@ import type { IBlueprintPieceInstance, } from '../documents/index.js' import type { IEventContext } from './eventContext.js' +import type { ITTimersContext } from './tTimersContext.js' -export interface ISyncIngestUpdateToPartInstanceContext extends IRundownUserContext, IEventContext { +export interface ISyncIngestUpdateToPartInstanceContext extends IRundownUserContext, ITTimersContext, IEventContext { /** Sync a pieceInstance. Inserts the pieceInstance if new, updates if existing. Optionally pass in a mutated Piece, to override the content of the instance */ syncPieceInstance( pieceInstanceId: string, diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts index 5cdf53ed788..7bb1aaf9861 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnSetAsNextContext.test.ts @@ -9,13 +9,22 @@ import { OnSetAsNextContext } from '../context/index.js' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('Test blueprint api context', () => { async function getTestee(setManually = false, rehearsal?: boolean) { const mockActionService = mock() const mockPlayoutModel = mock() Object.defineProperty(mockPlayoutModel, 'playlist', { - get: () => ({ rehearsal }), + get: () => + ({ + rehearsal, + tTimers: [ + { index: 1, label: 'Timer 1', mode: null, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, + ], + }) satisfies Partial, }) const context = new OnSetAsNextContext( { diff --git a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts index 06319381fdb..8ea794c883d 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-OnTakeContext.test.ts @@ -9,12 +9,21 @@ import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { PlayoutModelImpl } from '../../playout/model/implementation/PlayoutModelImpl.js' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('Test blueprint api context', () => { async function getTestee(rehearsal?: boolean) { const mockPlayoutModel = mock() Object.defineProperty(mockPlayoutModel, 'playlist', { - get: () => ({ rehearsal }), + get: () => + ({ + rehearsal, + tTimers: [ + { index: 1, label: 'Timer 1', mode: null, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, + ], + }) satisfies Partial, }) const mockActionService = mock() const context = new OnTakeContext( diff --git a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts index 1dcd4e99a10..b61faf8c176 100644 --- a/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts +++ b/packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts @@ -7,13 +7,22 @@ import { JobContext, ProcessedShowStyleCompound } from '../../jobs/index.js' import { mock } from 'jest-mock-extended' import { PartAndPieceInstanceActionService } from '../context/services/PartAndPieceInstanceActionService.js' import { ProcessedShowStyleConfig } from '../config.js' +import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' describe('Test blueprint api context', () => { async function getTestee(rehearsal?: boolean) { const mockActionService = mock() const mockPlayoutModel = mock() Object.defineProperty(mockPlayoutModel, 'playlist', { - get: () => ({ rehearsal }), + get: () => + ({ + rehearsal, + tTimers: [ + { index: 1, label: 'Timer 1', mode: null, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, + ], + }) satisfies Partial, }) const context = new ActionExecutionContext( { diff --git a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts index 0e8e1616d2e..0e2f5309460 100644 --- a/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts +++ b/packages/job-worker/src/blueprints/context/OnSetAsNextContext.ts @@ -50,7 +50,7 @@ export class OnSetAsNextContext public readonly manuallySelected: boolean ) { super(contextInfo, context, showStyle, watchedPackages) - this.#tTimersService = new TTimersService(playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(playoutModel) } public get quickLoopInfo(): BlueprintQuickLookInfo | null { diff --git a/packages/job-worker/src/blueprints/context/OnTakeContext.ts b/packages/job-worker/src/blueprints/context/OnTakeContext.ts index 8a79f7521aa..f403d337239 100644 --- a/packages/job-worker/src/blueprints/context/OnTakeContext.ts +++ b/packages/job-worker/src/blueprints/context/OnTakeContext.ts @@ -66,7 +66,7 @@ export class OnTakeContext extends ShowStyleUserContext implements IOnTakeContex ) { super(contextInfo, _context, showStyle, watchedPackages) this.isTakeAborted = false - this.#tTimersService = new TTimersService(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) } async getUpcomingParts(limit: number = 5): Promise> { diff --git a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts index a97d6c7dbc2..3f0b47cc1df 100644 --- a/packages/job-worker/src/blueprints/context/RundownActivationContext.ts +++ b/packages/job-worker/src/blueprints/context/RundownActivationContext.ts @@ -48,7 +48,7 @@ export class RundownActivationContext extends RundownEventContext implements IRu this._previousState = options.previousState this._currentState = options.currentState - this.#tTimersService = new TTimersService(this._playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(this._playoutModel) } get previousState(): IRundownActivationContextState { diff --git a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts index d8289be7d99..3bbec8cdaad 100644 --- a/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts +++ b/packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts @@ -32,24 +32,39 @@ import { } from '@sofie-automation/corelib/dist/dataModel/Piece' import { EXPECTED_INGEST_TO_PLAYOUT_TIME } from '@sofie-automation/shared-lib/dist/core/constants' import { getCurrentTime } from '../../lib/index.js' +import { TTimersService } from './services/TTimersService.js' +import type { + DBRundownPlaylist, + RundownTTimer, + RundownTTimerIndex, +} from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import type { IPlaylistTTimer } from '@sofie-automation/blueprints-integration/dist/context/tTimersContext' export class SyncIngestUpdateToPartInstanceContext extends RundownUserContext implements ISyncIngestUpdateToPartInstanceContext { - private readonly _proposedPieceInstances: Map> + readonly #context: JobContext + readonly #proposedPieceInstances: Map> + readonly #tTimersService: TTimersService + readonly #changedTTimers = new Map() - private partInstance: PlayoutPartInstanceModel | null + #partInstance: PlayoutPartInstanceModel | null public get hasRemovedPartInstance(): boolean { - return !this.partInstance + return !this.#partInstance + } + + public get changedTTimers(): RundownTTimer[] { + return Array.from(this.#changedTTimers.values()) } constructor( - private readonly _context: JobContext, + context: JobContext, contextInfo: ContextInfo, studio: ReadonlyDeep, showStyleCompound: ReadonlyDeep, + playlist: ReadonlyDeep, rundown: ReadonlyDeep, partInstance: PlayoutPartInstanceModel, proposedPieceInstances: ReadonlyDeep, @@ -58,32 +73,43 @@ export class SyncIngestUpdateToPartInstanceContext super( contextInfo, studio, - _context.getStudioBlueprintConfig(), + context.getStudioBlueprintConfig(), showStyleCompound, - _context.getShowStyleBlueprintConfig(showStyleCompound), + context.getShowStyleBlueprintConfig(showStyleCompound), rundown ) - this.partInstance = partInstance + this.#context = context + this.#partInstance = partInstance - this._proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#proposedPieceInstances = normalizeArrayToMap(proposedPieceInstances, '_id') + this.#tTimersService = new TTimersService(playlist.tTimers, (updatedTimer) => { + this.#changedTTimers.set(updatedTimer.index, updatedTimer) + }) + } + + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { + return this.#tTimersService.getTimer(index) + } + clearAllTimers(): void { + this.#tTimersService.clearAllTimers() } syncPieceInstance( pieceInstanceId: string, modifiedPiece?: Omit ): IBlueprintPieceInstance { - const proposedPieceInstance = this._proposedPieceInstances.get(protectString(pieceInstanceId)) + const proposedPieceInstance = this.#proposedPieceInstances.get(protectString(pieceInstanceId)) if (!proposedPieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) // filter the submission to the allowed ones const piece = modifiedPiece ? postProcessPieces( - this._context, + this.#context, [ { ...modifiedPiece, @@ -92,9 +118,9 @@ export class SyncIngestUpdateToPartInstanceContext }, ], this.showStyleCompound.blueprintId, - this.partInstance.partInstance.rundownId, - this.partInstance.partInstance.segmentId, - this.partInstance.partInstance.part._id, + this.#partInstance.partInstance.rundownId, + this.#partInstance.partInstance.segmentId, + this.#partInstance.partInstance.part._id, this.playStatus === 'current' )[0] : proposedPieceInstance.piece @@ -103,7 +129,7 @@ export class SyncIngestUpdateToPartInstanceContext ...proposedPieceInstance, piece: piece, } - this.partInstance.mergeOrInsertPieceInstance(newPieceInstance) + this.#partInstance.mergeOrInsertPieceInstance(newPieceInstance) return convertPieceInstanceToBlueprints(newPieceInstance) } @@ -111,19 +137,19 @@ export class SyncIngestUpdateToPartInstanceContext insertPieceInstance(piece0: IBlueprintPiece): IBlueprintPieceInstance { const trimmedPiece: IBlueprintPiece = _.pick(piece0, IBlueprintPieceObjectsSampleKeys) - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) const piece = postProcessPieces( - this._context, + this.#context, [trimmedPiece], this.showStyleCompound.blueprintId, - this.partInstance.partInstance.rundownId, - this.partInstance.partInstance.segmentId, - this.partInstance.partInstance.part._id, + this.#partInstance.partInstance.rundownId, + this.#partInstance.partInstance.segmentId, + this.#partInstance.partInstance.part._id, this.playStatus === 'current' )[0] - const newPieceInstance = this.partInstance.insertPlannedPiece(piece) + const newPieceInstance = this.#partInstance.insertPlannedPiece(piece) return convertPieceInstanceToBlueprints(newPieceInstance.pieceInstance) } @@ -134,13 +160,13 @@ export class SyncIngestUpdateToPartInstanceContext throw new Error(`Cannot update PieceInstance "${pieceInstanceId}". Some valid properties must be defined`) } - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) - const pieceInstance = this.partInstance.getPieceInstance(protectString(pieceInstanceId)) + const pieceInstance = this.#partInstance.getPieceInstance(protectString(pieceInstanceId)) if (!pieceInstance) { throw new Error(`PieceInstance "${pieceInstanceId}" could not be found`) } - if (pieceInstance.pieceInstance.partInstanceId !== this.partInstance.partInstance._id) { + if (pieceInstance.pieceInstance.partInstanceId !== this.#partInstance.partInstance._id) { throw new Error(`PieceInstance "${pieceInstanceId}" does not belong to the current PartInstance`) } @@ -167,13 +193,13 @@ export class SyncIngestUpdateToPartInstanceContext return convertPieceInstanceToBlueprints(pieceInstance.pieceInstance) } updatePartInstance(updatePart: Partial): IBlueprintPartInstance { - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) // for autoNext, the new expectedDuration cannot be shorter than the time a part has been on-air for - const expectedDuration = updatePart.expectedDuration ?? this.partInstance.partInstance.part.expectedDuration - const autoNext = updatePart.autoNext ?? this.partInstance.partInstance.part.autoNext + const expectedDuration = updatePart.expectedDuration ?? this.#partInstance.partInstance.part.expectedDuration + const autoNext = updatePart.autoNext ?? this.#partInstance.partInstance.part.autoNext if (expectedDuration && autoNext) { - const onAir = this.partInstance.partInstance.timings?.reportedStartedPlayback + const onAir = this.#partInstance.partInstance.timings?.reportedStartedPlayback const minTime = Date.now() - (onAir ?? 0) + EXPECTED_INGEST_TO_PLAYOUT_TIME if (onAir && minTime > expectedDuration) { updatePart.expectedDuration = minTime @@ -185,31 +211,31 @@ export class SyncIngestUpdateToPartInstanceContext this.showStyleCompound.blueprintId ) - if (!this.partInstance.updatePartProps(playoutUpdatePart)) { + if (!this.#partInstance.updatePartProps(playoutUpdatePart)) { throw new Error(`Cannot update PartInstance. Some valid properties must be defined`) } - return convertPartInstanceToBlueprints(this.partInstance.partInstance) + return convertPartInstanceToBlueprints(this.#partInstance.partInstance) } removePartInstance(): void { if (this.playStatus !== 'next') throw new Error(`Only the 'next' PartInstance can be removed`) - this.partInstance = null + this.#partInstance = null } removePieceInstances(...pieceInstanceIds: string[]): string[] { - if (!this.partInstance) throw new Error(`PartInstance has been removed`) + if (!this.#partInstance) throw new Error(`PartInstance has been removed`) const rawPieceInstanceIdSet = new Set(protectStringArray(pieceInstanceIds)) - const pieceInstances = this.partInstance.pieceInstances.filter((p) => + const pieceInstances = this.#partInstance.pieceInstances.filter((p) => rawPieceInstanceIdSet.has(p.pieceInstance._id) ) const pieceInstanceIdsToRemove = pieceInstances.map((p) => p.pieceInstance._id) for (const id of pieceInstanceIdsToRemove) { - this.partInstance.removePieceInstance(id) + this.#partInstance.removePieceInstance(id) } return unprotectStringArray(pieceInstanceIdsToRemove) diff --git a/packages/job-worker/src/blueprints/context/adlibActions.ts b/packages/job-worker/src/blueprints/context/adlibActions.ts index 63c5c3d7839..8c41cc7d7d0 100644 --- a/packages/job-worker/src/blueprints/context/adlibActions.ts +++ b/packages/job-worker/src/blueprints/context/adlibActions.ts @@ -117,7 +117,7 @@ export class ActionExecutionContext extends ShowStyleUserContext implements IAct private readonly partAndPieceInstanceService: PartAndPieceInstanceActionService ) { super(contextInfo, _context, showStyle, watchedPackages) - this.#tTimersService = new TTimersService(_playoutModel) + this.#tTimersService = TTimersService.withPlayoutModel(_playoutModel) } async getUpcomingParts(limit: number = 5): Promise> { diff --git a/packages/job-worker/src/blueprints/context/services/TTimersService.ts b/packages/job-worker/src/blueprints/context/services/TTimersService.ts index 80a7e0faf9f..b1eeafd49c6 100644 --- a/packages/job-worker/src/blueprints/context/services/TTimersService.ts +++ b/packages/job-worker/src/blueprints/context/services/TTimersService.ts @@ -18,20 +18,25 @@ import { import { getCurrentTime } from '../../../lib/time.js' export class TTimersService { - readonly playoutModel: PlayoutModel - readonly timers: [PlaylistTTimerImpl, PlaylistTTimerImpl, PlaylistTTimerImpl] - constructor(playoutModel: PlayoutModel) { - this.playoutModel = playoutModel - + constructor( + timers: ReadonlyDeep, + emitChange: (updatedTimer: ReadonlyDeep) => void + ) { this.timers = [ - new PlaylistTTimerImpl(playoutModel, 1), - new PlaylistTTimerImpl(playoutModel, 2), - new PlaylistTTimerImpl(playoutModel, 3), + new PlaylistTTimerImpl(timers[0], emitChange), + new PlaylistTTimerImpl(timers[1], emitChange), + new PlaylistTTimerImpl(timers[2], emitChange), ] } + static withPlayoutModel(playoutModel: PlayoutModel): TTimersService { + return new TTimersService(playoutModel.playlist.tTimers, (updatedTimer) => { + playoutModel.updateTTimer(updatedTimer) + }) + } + getTimer(index: RundownTTimerIndex): IPlaylistTTimer { validateTTimerIndex(index) return this.timers[index - 1] @@ -44,22 +49,19 @@ export class TTimersService { } export class PlaylistTTimerImpl implements IPlaylistTTimer { - readonly #playoutModel: PlayoutModel - readonly #index: RundownTTimerIndex + readonly #emitChange: (updatedTimer: ReadonlyDeep) => void - get #modelTimer(): ReadonlyDeep { - return this.#playoutModel.playlist.tTimers[this.#index - 1] - } + #timer: ReadonlyDeep get index(): RundownTTimerIndex { - return this.#modelTimer.index + return this.#timer.index } get label(): string { - return this.#modelTimer.label + return this.#timer.label } get state(): IPlaylistTTimerState | null { - const rawMode = this.#modelTimer.mode - const rawState = this.#modelTimer.state + const rawMode = this.#timer.mode + const rawState = this.#timer.state if (!rawMode || !rawState) return null @@ -94,70 +96,76 @@ export class PlaylistTTimerImpl implements IPlaylistTTimer { } } - constructor(playoutModel: PlayoutModel, index: RundownTTimerIndex) { - this.#playoutModel = playoutModel - this.#index = index - - validateTTimerIndex(index) + constructor(timer: ReadonlyDeep, emitChange: (updatedTimer: ReadonlyDeep) => void) { + this.#timer = timer + this.#emitChange = emitChange } setLabel(label: string): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, label: label, - }) + } + this.#emitChange(this.#timer) } clearTimer(): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, mode: null, state: null, - }) + } + this.#emitChange(this.#timer) } startCountdown(duration: number, options?: { stopAtZero?: boolean; startPaused?: boolean }): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, ...createCountdownTTimer(duration, { stopAtZero: options?.stopAtZero ?? true, startPaused: options?.startPaused ?? false, }), - }) + } + this.#emitChange(this.#timer) } startTimeOfDay(targetTime: string | number, options?: { stopAtZero?: boolean }): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, ...createTimeOfDayTTimer(targetTime, { stopAtZero: options?.stopAtZero ?? true, }), - }) + } + this.#emitChange(this.#timer) } startFreeRun(options?: { startPaused?: boolean }): void { - this.#playoutModel.updateTTimer({ - ...this.#modelTimer, + this.#timer = { + ...this.#timer, ...createFreeRunTTimer({ startPaused: options?.startPaused ?? false, }), - }) + } + this.#emitChange(this.#timer) } pause(): boolean { - const newTimer = pauseTTimer(this.#modelTimer) + const newTimer = pauseTTimer(this.#timer) if (!newTimer) return false - this.#playoutModel.updateTTimer(newTimer) + this.#timer = newTimer + this.#emitChange(newTimer) return true } resume(): boolean { - const newTimer = resumeTTimer(this.#modelTimer) + const newTimer = resumeTTimer(this.#timer) if (!newTimer) return false - this.#playoutModel.updateTTimer(newTimer) + this.#timer = newTimer + this.#emitChange(newTimer) return true } restart(): boolean { - const newTimer = restartTTimer(this.#modelTimer) + const newTimer = restartTTimer(this.#timer) if (!newTimer) return false - this.#playoutModel.updateTTimer(newTimer) + this.#timer = newTimer + this.#emitChange(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 index 352a48bde30..2fe7a21b299 100644 --- a/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts +++ b/packages/job-worker/src/blueprints/context/services/__tests__/TTimersService.test.ts @@ -40,9 +40,10 @@ describe('TTimersService', () => { describe('constructor', () => { it('should create three timer instances', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + const timers = createEmptyTTimers() + const updateFn = jest.fn() - const service = new TTimersService(mockPlayoutModel) + const service = new TTimersService(timers, updateFn) expect(service.timers).toHaveLength(3) expect(service.timers[0]).toBeInstanceOf(PlaylistTTimerImpl) @@ -51,10 +52,27 @@ describe('TTimersService', () => { }) }) + it('from playout model', () => { + const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) + + const service = TTimersService.withPlayoutModel(mockPlayoutModel) + expect(service.timers).toHaveLength(3) + + const timer = service.getTimer(1) + expect(timer.index).toBe(1) + + timer.setLabel('New Label') + expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith( + expect.objectContaining({ index: 1, label: 'New Label' }) + ) + }) + describe('getTimer', () => { it('should return the correct timer for index 1', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) const timer = service.getTimer(1) @@ -62,8 +80,10 @@ describe('TTimersService', () => { }) it('should return the correct timer for index 2', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) const timer = service.getTimer(2) @@ -71,8 +91,10 @@ describe('TTimersService', () => { }) it('should return the correct timer for index 3', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) const timer = service.getTimer(3) @@ -80,8 +102,10 @@ describe('TTimersService', () => { }) it('should throw for invalid index', () => { - const mockPlayoutModel = createMockPlayoutModel(createEmptyTTimers()) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) 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') @@ -96,22 +120,18 @@ describe('TTimersService', () => { tTimers[1].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[1].state = { paused: false, zeroTime: 65000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const service = new TTimersService(mockPlayoutModel) + const timers = createEmptyTTimers() + const updateFn = jest.fn() + + const service = new TTimersService(timers, updateFn) 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 }) - ) + expect(updateFn).toHaveBeenCalledTimes(3) + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ index: 1, mode: null })) + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ index: 2, mode: null })) + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ index: 3, mode: null })) }) }) }) @@ -128,8 +148,8 @@ describe('PlaylistTTimerImpl', () => { describe('getters', () => { it('should return the correct index', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) expect(timer.index).toBe(2) }) @@ -137,16 +157,17 @@ describe('PlaylistTTimerImpl', () => { it('should return the correct label', () => { const tTimers = createEmptyTTimers() tTimers[1].label = 'Custom Label' - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 2) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[1], updateFn) 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) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toBeNull() }) @@ -155,8 +176,8 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 15000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'freeRun', @@ -169,8 +190,8 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: 3000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'freeRun', @@ -187,8 +208,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 15000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'countdown', @@ -207,8 +228,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, } tTimers[0].state = { paused: true, duration: 2000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'countdown', @@ -227,8 +248,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 20000 } // 10 seconds in the future - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -248,8 +269,8 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, } tTimers[0].state = { paused: false, zeroTime: targetTimestamp } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(timer.state).toEqual({ mode: 'timeOfDay', @@ -264,12 +285,13 @@ describe('PlaylistTTimerImpl', () => { describe('setLabel', () => { it('should update the label', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.setLabel('New Label') - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'New Label', mode: null, @@ -283,12 +305,12 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.clearTimer() - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: null, @@ -300,12 +322,13 @@ describe('PlaylistTTimerImpl', () => { describe('startCountdown', () => { it('should start a running countdown with default options', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startCountdown(60000) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -319,12 +342,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused countdown', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startCountdown(30000, { startPaused: true, stopAtZero: false }) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -340,12 +364,13 @@ describe('PlaylistTTimerImpl', () => { describe('startFreeRun', () => { it('should start a running free-run timer', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startFreeRun() - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -357,12 +382,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a paused free-run timer', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startFreeRun({ startPaused: true }) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -376,12 +402,13 @@ describe('PlaylistTTimerImpl', () => { describe('startTimeOfDay', () => { it('should start a timeOfDay timer with time string', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startTimeOfDay('15:30') - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -398,13 +425,14 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with numeric timestamp', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const targetTimestamp = 1737331200000 timer.startTimeOfDay(targetTimestamp) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -421,12 +449,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with stopAtZero false', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startTimeOfDay('18:00', { stopAtZero: false }) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: expect.objectContaining({ @@ -443,12 +472,13 @@ describe('PlaylistTTimerImpl', () => { it('should start a timeOfDay timer with 12-hour format', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) timer.startTimeOfDay('5:30pm') - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: expect.objectContaining({ @@ -465,16 +495,18 @@ describe('PlaylistTTimerImpl', () => { it('should throw for invalid time string', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(() => timer.startTimeOfDay('invalid')).toThrow('Unable to parse target time for timeOfDay T-timer') }) it('should throw for empty time string', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) expect(() => timer.startTimeOfDay('')).toThrow('Unable to parse target time for timeOfDay T-timer') }) @@ -485,13 +517,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -505,13 +537,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 70000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -525,13 +557,14 @@ describe('PlaylistTTimerImpl', () => { it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) it('should return false for timeOfDay timer (does not support pause)', () => { @@ -542,13 +575,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 20000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.pause() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) }) @@ -557,13 +590,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: true, duration: -3000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -577,25 +610,26 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() // Returns true because timer supports resume, but it's already running expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalled() + expect(updateFn).toHaveBeenCalled() }) it('should return false for timer with no mode', () => { const tTimers = createEmptyTTimers() - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) it('should return false for timeOfDay timer (does not support resume)', () => { @@ -606,13 +640,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 20000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.resume() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) }) @@ -621,13 +655,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'countdown', duration: 60000, stopAtZero: true } tTimers[0].state = { paused: false, zeroTime: 40000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -647,13 +681,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: false, } tTimers[0].state = { paused: true, duration: 15000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -669,13 +703,13 @@ describe('PlaylistTTimerImpl', () => { const tTimers = createEmptyTTimers() tTimers[0].mode = { type: 'freeRun' } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).not.toHaveBeenCalled() }) it('should restart a timeOfDay timer with valid targetRaw', () => { @@ -686,13 +720,13 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 5000 } // old target time - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(true) - expect(mockPlayoutModel.updateTTimer).toHaveBeenCalledWith({ + expect(updateFn).toHaveBeenCalledWith({ index: 1, label: 'Timer 1', mode: { @@ -715,37 +749,25 @@ describe('PlaylistTTimerImpl', () => { stopAtZero: true, } tTimers[0].state = { paused: false, zeroTime: 5000 } - const mockPlayoutModel = createMockPlayoutModel(tTimers) - const timer = new PlaylistTTimerImpl(mockPlayoutModel, 1) + const updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) const result = timer.restart() expect(result).toBe(false) - expect(mockPlayoutModel.updateTTimer).not.toHaveBeenCalled() + expect(updateFn).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 updateFn = jest.fn() + const timer = new PlaylistTTimerImpl(tTimers[0], updateFn) 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' - ) + expect(updateFn).not.toHaveBeenCalled() }) }) }) diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts index adf7cbaeab0..3f63fe88589 100644 --- a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts @@ -84,7 +84,7 @@ describe('SyncChangesToPartInstancesWorker', () => { describe('syncChangesToPartInstance', () => { function createMockPlayoutModel(partialModel?: Partial>) { - return mock( + const mockPlayoutModel = mock( { currentPartInstance: null, nextPartInstance: partialModel?.nextPartInstance ?? null, @@ -96,6 +96,19 @@ describe('SyncChangesToPartInstancesWorker', () => { }, mockOptions ) + + Object.defineProperty(mockPlayoutModel, 'playlist', { + get: () => + ({ + tTimers: [ + { index: 1, label: 'Timer 1', mode: null, state: null }, + { index: 2, label: 'Timer 2', mode: null, state: null }, + { index: 3, label: 'Timer 3', mode: null, state: null }, + ], + }) satisfies Partial, + }) + + return mockPlayoutModel } function createMockPlayoutRundownModel(): PlayoutRundownModel { return mock({}, mockOptions) diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index 6f8352751c5..bb39e145d5a 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -135,6 +135,7 @@ export class SyncChangesToPartInstancesWorker { }, this.#context.studio, this.#showStyle, + this.#playoutModel.playlist, instanceToSync.playoutRundownModel.rundown, existingPartInstance, proposedPieceInstances, @@ -152,6 +153,11 @@ export class SyncChangesToPartInstancesWorker { newResultData, instanceToSync.playStatus ) + + // Persist t-timer changes + for (const timer of syncContext.changedTTimers) { + this.#playoutModel.updateTTimer(timer) + } } catch (err) { logger.error(`Error in showStyleBlueprint.syncIngestUpdateToPartInstance: ${stringifyError(err)}`) From d0c77c8a4e636f722122322b05e90ee5cc67e29d Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 4 Feb 2026 17:28:30 +0000 Subject: [PATCH 6/6] feat: provide allParts and an approx index to syncIngest --- .../src/api/showStyle.ts | 9 ++ ...rtInstance-computeCurrentPartIndex.test.ts | 126 ++++++++++++++++++ .../src/ingest/syncChangesToPartInstance.ts | 84 +++++++++++- 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts diff --git a/packages/blueprints-integration/src/api/showStyle.ts b/packages/blueprints-integration/src/api/showStyle.ts index 43182638f38..bb14050e8e3 100644 --- a/packages/blueprints-integration/src/api/showStyle.ts +++ b/packages/blueprints-integration/src/api/showStyle.ts @@ -290,6 +290,15 @@ export interface BlueprintResultPart { } export interface BlueprintSyncIngestNewData { + /** All parts in the rundown, including the new/updated part */ + allParts: IBlueprintPartDB[] + /** + * An approximate index of the current part in the allParts array + * Note: this will not always be an integer, such as when the part is an adlib part + * `null` means the part could not be placed + */ + currentPartIndex: number | null + // source: BlueprintSyncIngestDataSource /** The new part */ part: IBlueprintPartDB | undefined diff --git a/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts new file mode 100644 index 00000000000..6b688cf1d04 --- /dev/null +++ b/packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance-computeCurrentPartIndex.test.ts @@ -0,0 +1,126 @@ +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { computeCurrentPartIndex } from '../syncChangesToPartInstance.js' + +describe('computeCurrentPartIndex', () => { + function createMockSegmentsAndParts() { + const segments = [ + { + _id: protectString('segment1'), + _rank: 1, + }, + { + _id: protectString('segment1b'), + _rank: 2, + }, + { + _id: protectString('segment2'), + _rank: 3, + }, + { + _id: protectString('segment3'), + _rank: 4, + }, + ] satisfies Partial[] + const parts = [ + { + _id: protectString('part1'), + segmentId: protectString('segment1'), + _rank: 1, + }, + { + _id: protectString('part2'), + segmentId: protectString('segment1'), + _rank: 2, + }, + { + _id: protectString('part3'), + segmentId: protectString('segment2'), + _rank: 1, + }, + { + _id: protectString('part4'), + segmentId: protectString('segment2'), + _rank: 2, + }, + { + _id: protectString('part5'), + segmentId: protectString('segment3'), + _rank: 1, + }, + { + _id: protectString('part6'), + segmentId: protectString('segment3'), + _rank: 2, + }, + { + _id: protectString('part7'), + segmentId: protectString('segment3'), + _rank: 3, + }, + ] satisfies Partial[] + + return { + segments: segments as DBSegment[], + parts: parts as DBPart[], + } + } + + it('match by id', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('part3'), protectString('segment2'), 3) + expect(index).toBe(2) + }) + + it('interpolate by rank', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partY'), protectString('segment2'), 1.3) + expect(index).toBe(2.5) + }) + + it('before first part in segment', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partZ'), protectString('segment2'), 0) + expect(index).toBe(1.5) + }) + + it('after last part in segment', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partW'), protectString('segment2'), 3) + expect(index).toBe(3.5) + }) + + it('segment with no parts', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partV'), protectString('segment1b'), 1) + expect(index).toBe(1.5) + }) + + it('non-existing segment', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partU'), protectString('segmentX'), 1) + expect(index).toBeNull() + }) + + it('no parts at all', () => { + const segments: DBSegment[] = [] + const parts: DBPart[] = [] + + const index = computeCurrentPartIndex(segments, parts, protectString('partT'), protectString('segment1'), 1) + expect(index).toBeNull() + }) + + it('before first part', () => { + const { segments, parts } = createMockSegmentsAndParts() + + const index = computeCurrentPartIndex(segments, parts, protectString('partS'), protectString('segment1'), 0) + expect(index).toBe(-0.5) + }) +}) diff --git a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts index bb39e145d5a..afee746ca29 100644 --- a/packages/job-worker/src/ingest/syncChangesToPartInstance.ts +++ b/packages/job-worker/src/ingest/syncChangesToPartInstance.ts @@ -33,8 +33,9 @@ import { convertNoteToNotification } from '../notifications/util.js' import { PlayoutRundownModel } from '../playout/model/PlayoutRundownModel.js' import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' import { setNextPart } from '../playout/setNext.js' -import { PartId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PartId, RundownId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import type { WrappedShowStyleBlueprint } from '../blueprints/cache.js' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' type PlayStatus = 'previous' | 'current' | 'next' export interface PartInstanceToSync { @@ -195,7 +196,7 @@ export class SyncChangesToPartInstancesWorker { } } - collectNewIngestDataToSync( + private collectNewIngestDataToSync( partId: PartId, instanceToSync: PartInstanceToSync, proposedPieceInstances: PieceInstance[] @@ -210,7 +211,18 @@ export class SyncChangesToPartInstancesWorker { if (adLibPiece) referencedAdlibs.push(convertAdLibPieceToBlueprints(adLibPiece)) } + const allModelParts = this.#ingestModel.getAllOrderedParts() + return { + allParts: allModelParts.map((part) => convertPartToBlueprints(part.part)), + currentPartIndex: computeCurrentPartIndex( + this.#ingestModel.getOrderedSegments().map((s) => s.segment), + allModelParts.map((p) => p.part), + partId, + instanceToSync.existingPartInstance.partInstance.segmentId, + instanceToSync.existingPartInstance.partInstance.part._rank + ), + part: instanceToSync.newPart ? convertPartToBlueprints(instanceToSync.newPart) : undefined, pieceInstances: proposedPieceInstances.map(convertPieceInstanceToBlueprints), adLibPieces: @@ -486,3 +498,71 @@ function findLastUnorphanedPartInstanceInSegment( part: previousPart, } } + +/** + * Compute an approximate (possibly non-integer) index of the part within all parts + * This is used to give the blueprints an idea of where the part is within the rundown + * Note: this assumes each part has a unique integer rank, which is what ingest will produce + * @returns The approximate index, or `null` if the part could not be placed + */ +export function computeCurrentPartIndex( + allOrderedSegments: ReadonlyDeep[], + allOrderedParts: ReadonlyDeep[], + partId: PartId, + segmentId: SegmentId, + targetRank: number +): number | null { + // Exact match by part id + const exactIdx = allOrderedParts.findIndex((p) => p._id === partId) + if (exactIdx !== -1) return exactIdx + + // Find the segment object + const segment = allOrderedSegments.find((s) => s._id === segmentId) + if (!segment) return null + + // Prepare parts with their global indices + const partsWithGlobal = allOrderedParts.map((p, globalIndex) => ({ part: p, globalIndex })) + + // Parts in the same segment + const partsInSegment = partsWithGlobal.filter((pg) => pg.part.segmentId === segmentId) + + if (partsInSegment.length === 0) { + // Segment has no parts: place between the previous/next parts by segment order + const segmentRank = segment._rank + + const prev = partsWithGlobal.findLast((pg) => { + const seg = allOrderedSegments.find((s) => s._id === pg.part.segmentId) + return !!seg && seg._rank < segmentRank + }) + + const next = partsWithGlobal.find((pg) => { + const seg = allOrderedSegments.find((s) => s._id === pg.part.segmentId) + return !!seg && seg._rank > segmentRank + }) + + if (prev && next) return (prev.globalIndex + next.globalIndex) / 2 + if (prev) return prev.globalIndex + 0.5 + if (next) return next.globalIndex - 0.5 + + // No parts at all + return null + } + + // There are parts in the segment: decide placement by rank within the segment. + + const nextIdx = partsInSegment.findIndex((pg) => pg.part._rank > targetRank) + if (nextIdx === -1) { + // After last + return partsInSegment[partsInSegment.length - 1].globalIndex + 0.5 + } + + if (nextIdx === 0) { + // Before first + return partsInSegment[0].globalIndex - 0.5 + } + + // Between two adjacent parts: interpolate by their ranks (proportionally) + const prev = partsInSegment[nextIdx - 1] + const next = partsInSegment[nextIdx] + return prev.globalIndex + (next.globalIndex - prev.globalIndex) / 2 +}