Skip to content

Commit ab2023c

Browse files
Julusianjstarpl
authored andcommitted
fix: reimplement removePartInstance flow for syncChangesToPartInstances
1 parent ddba586 commit ab2023c

File tree

3 files changed

+323
-1
lines changed

3 files changed

+323
-1
lines changed

packages/job-worker/src/blueprints/context/SyncIngestUpdateToPartInstanceContext.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export class SyncIngestUpdateToPartInstanceContext
4040

4141
private partInstance: PlayoutPartInstanceModel | null
4242

43+
public get hasRemovedPartInstance(): boolean {
44+
return !this.partInstance
45+
}
46+
4347
constructor(
4448
private readonly _context: JobContext,
4549
contextInfo: ContextInfo,

packages/job-worker/src/ingest/__tests__/syncChangesToPartInstance.test.ts

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
22
/* eslint-disable @typescript-eslint/unbound-method */
3-
import { setupDefaultJobEnvironment } from '../../__mocks__/context'
3+
import { MockJobContext, setupDefaultJobEnvironment } from '../../__mocks__/context'
44
import { setupMockShowStyleCompound } from '../../__mocks__/presetCollections'
55
import { findInstancesToSync, PartInstanceToSync, SyncChangesToPartInstancesWorker } from '../syncChangesToPartInstance'
66
import { mock } from 'jest-mock-extended'
@@ -10,6 +10,17 @@ import type { PlayoutRundownModel } from '../../playout/model/PlayoutRundownMode
1010
import type { PlayoutPartInstanceModel } from '../../playout/model/PlayoutPartInstanceModel'
1111
import type { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part'
1212
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
13+
import { PlayoutModelImpl } from '../../playout/model/implementation/PlayoutModelImpl'
14+
import { PlaylistTimingType, ShowStyleBlueprintManifest } from '@sofie-automation/blueprints-integration'
15+
import { RundownPlaylistId } from '@sofie-automation/corelib/dist/dataModel/Ids'
16+
import { DBRundownPlaylist, SelectedPartInstance } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
17+
import { PlayoutRundownModelImpl } from '../../playout/model/implementation/PlayoutRundownModelImpl'
18+
import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown'
19+
import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment'
20+
import { PlayoutSegmentModelImpl } from '../../playout/model/implementation/PlayoutSegmentModelImpl'
21+
import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance'
22+
import { ProcessedShowStyleCompound } from '../../jobs'
23+
import { PartialDeep, ReadonlyDeep } from 'type-fest'
1324

1425
jest.mock('../../playout/adlibTesting')
1526
import { validateAdlibTestingPartInstanceProperties } from '../../playout/adlibTesting'
@@ -165,5 +176,280 @@ describe('SyncChangesToPartInstancesWorker', () => {
165176
expect(syncIngestUpdateToPartInstanceFn).toHaveBeenCalledTimes(1)
166177
expect(validateAdlibTestingPartInstanceProperties).toHaveBeenCalledTimes(1)
167178
})
179+
180+
test('removePartInstance for next calls recreateNextPartInstance', async () => {
181+
const context = setupDefaultJobEnvironment()
182+
const showStyleCompound = await setupMockShowStyleCompound(context)
183+
184+
type TsyncIngestUpdateToPartInstanceFn = jest.MockedFunction<
185+
Required<ShowStyleBlueprintManifest>['syncIngestUpdateToPartInstance']
186+
>
187+
const syncIngestUpdateToPartInstanceFn: TsyncIngestUpdateToPartInstanceFn = jest.fn()
188+
context.updateShowStyleBlueprint({
189+
syncIngestUpdateToPartInstance: syncIngestUpdateToPartInstanceFn,
190+
})
191+
const blueprint = await context.getShowStyleBlueprint(showStyleCompound._id)
192+
193+
const partInstance = createMockPartInstance('mockPartInstanceId')
194+
const part = createMockPart('mockPartId')
195+
196+
const playoutModel = createMockPlayoutModel({ nextPartInstance: partInstance })
197+
const ingestModel = createMockIngestModelReadonly()
198+
const rundownModel = createMockPlayoutRundownModel()
199+
200+
const worker = new SyncChangesToPartInstancesWorker(
201+
context,
202+
playoutModel,
203+
ingestModel,
204+
showStyleCompound,
205+
blueprint
206+
)
207+
// Mock the method, we can test it separately
208+
worker.recreateNextPartInstance = jest.fn()
209+
210+
const instanceToSync: PartInstanceToSync = {
211+
playoutRundownModel: rundownModel,
212+
existingPartInstance: partInstance,
213+
previousPartInstance: null,
214+
playStatus: 'next',
215+
newPart: part,
216+
proposedPieceInstances: Promise.resolve([]),
217+
}
218+
219+
syncIngestUpdateToPartInstanceFn.mockImplementationOnce((context) => {
220+
// Remove the partInstance
221+
context.removePartInstance()
222+
})
223+
224+
await worker.syncChangesToPartInstance(instanceToSync)
225+
226+
expect(partInstance.snapshotMakeCopy).toHaveBeenCalledTimes(1)
227+
expect(partInstance.snapshotRestore).toHaveBeenCalledTimes(0)
228+
expect(syncIngestUpdateToPartInstanceFn).toHaveBeenCalledTimes(1)
229+
expect(validateAdlibTestingPartInstanceProperties).toHaveBeenCalledTimes(0)
230+
expect(worker.recreateNextPartInstance).toHaveBeenCalledTimes(1)
231+
expect(worker.recreateNextPartInstance).toHaveBeenCalledWith(part)
232+
})
233+
})
234+
235+
describe('recreateNextPartInstance', () => {
236+
async function createSimplePlayoutModel(
237+
context: MockJobContext,
238+
showStyleCompound: ReadonlyDeep<ProcessedShowStyleCompound>
239+
) {
240+
const playlistId = protectString<RundownPlaylistId>('mockPlaylistId')
241+
const playlistLock = await context.lockPlaylist(playlistId)
242+
243+
const rundown: DBRundown = {
244+
_id: protectString('mockRundownId'),
245+
externalId: 'mockExternalId',
246+
playlistId: playlistId,
247+
showStyleBaseId: showStyleCompound._id,
248+
showStyleVariantId: showStyleCompound.showStyleVariantId,
249+
name: 'mockName',
250+
organizationId: null,
251+
studioId: context.studioId,
252+
source: {
253+
type: 'http',
254+
},
255+
created: 0,
256+
modified: 0,
257+
importVersions: {
258+
blueprint: '',
259+
core: '',
260+
showStyleBase: '',
261+
showStyleVariant: '',
262+
studio: '',
263+
},
264+
timing: { type: PlaylistTimingType.None },
265+
}
266+
267+
const segment: DBSegment = {
268+
_id: protectString('mockSegmentId'),
269+
rundownId: rundown._id,
270+
name: 'mockSegmentName',
271+
externalId: 'mockSegmentExternalId',
272+
_rank: 0,
273+
}
274+
275+
const part0: DBPart = {
276+
_id: protectString('mockPartId0'),
277+
segmentId: segment._id,
278+
rundownId: rundown._id,
279+
title: 'mockPartTitle0',
280+
_rank: 0,
281+
expectedDuration: 0,
282+
expectedDurationWithTransition: 0,
283+
externalId: 'mockPartExternalId0',
284+
}
285+
286+
const nextPartInstance: DBPartInstance = {
287+
_id: protectString('mockPartInstanceId'),
288+
part: part0,
289+
segmentId: segment._id,
290+
rundownId: rundown._id,
291+
takeCount: 0,
292+
rehearsal: false,
293+
playlistActivationId: protectString('mockPlaylistActivationId'),
294+
segmentPlayoutId: protectString('mockSegmentPlayoutId'),
295+
}
296+
297+
const playlist: DBRundownPlaylist = {
298+
_id: playlistId,
299+
externalId: 'mockExternalId',
300+
activationId: protectString('mockActivationId'),
301+
currentPartInfo: null,
302+
nextPartInfo: {
303+
rundownId: nextPartInstance.rundownId,
304+
partInstanceId: nextPartInstance._id,
305+
manuallySelected: false,
306+
consumesQueuedSegmentId: false,
307+
},
308+
previousPartInfo: null,
309+
studioId: context.studioId,
310+
name: 'mockName',
311+
created: 0,
312+
modified: 0,
313+
timing: { type: PlaylistTimingType.None },
314+
rundownIdsInOrder: [],
315+
}
316+
317+
const segmentModel = new PlayoutSegmentModelImpl(segment, [part0])
318+
const rundownModel = new PlayoutRundownModelImpl(rundown, [segmentModel], [])
319+
const playoutModel = new PlayoutModelImpl(
320+
context,
321+
playlistLock,
322+
playlistId,
323+
[],
324+
playlist,
325+
[nextPartInstance],
326+
new Map(),
327+
[rundownModel],
328+
undefined
329+
)
330+
331+
return { playlistId, playoutModel, part0, nextPartInstance }
332+
}
333+
334+
function createMockIngestModelReadonly(): IngestModelReadonly {
335+
return mock<IngestModelReadonly>(
336+
{
337+
findPart: jest.fn(() => undefined),
338+
getGlobalPieces: jest.fn(() => []),
339+
},
340+
mockOptions
341+
)
342+
}
343+
344+
test('clear auto chosen partInstance', async () => {
345+
const context = setupDefaultJobEnvironment()
346+
const showStyleCompound = await setupMockShowStyleCompound(context)
347+
const blueprint = await context.getShowStyleBlueprint(showStyleCompound._id)
348+
349+
const { playoutModel } = await createSimplePlayoutModel(context, showStyleCompound)
350+
351+
const ingestModel = createMockIngestModelReadonly()
352+
353+
const worker = new SyncChangesToPartInstancesWorker(
354+
context,
355+
playoutModel,
356+
ingestModel,
357+
showStyleCompound,
358+
blueprint
359+
)
360+
361+
expect(playoutModel.nextPartInstance).toBeTruthy()
362+
expect(playoutModel.playlist.nextPartInfo).toEqual({
363+
partInstanceId: playoutModel.nextPartInstance!.partInstance._id,
364+
rundownId: playoutModel.nextPartInstance!.partInstance.rundownId,
365+
consumesQueuedSegmentId: false,
366+
manuallySelected: false,
367+
} satisfies SelectedPartInstance)
368+
369+
await worker.recreateNextPartInstance(undefined)
370+
371+
expect(playoutModel.nextPartInstance).toBeFalsy()
372+
})
373+
374+
test('clear manually chosen partInstance', async () => {
375+
const context = setupDefaultJobEnvironment()
376+
const showStyleCompound = await setupMockShowStyleCompound(context)
377+
const blueprint = await context.getShowStyleBlueprint(showStyleCompound._id)
378+
379+
const { playoutModel } = await createSimplePlayoutModel(context, showStyleCompound)
380+
381+
const ingestModel = createMockIngestModelReadonly()
382+
383+
const worker = new SyncChangesToPartInstancesWorker(
384+
context,
385+
playoutModel,
386+
ingestModel,
387+
showStyleCompound,
388+
blueprint
389+
)
390+
391+
expect(playoutModel.nextPartInstance).toBeTruthy()
392+
// Force the next part to be manually selected, and verify
393+
playoutModel.setPartInstanceAsNext(playoutModel.nextPartInstance, true, false)
394+
expect(playoutModel.playlist.nextPartInfo).toEqual({
395+
partInstanceId: playoutModel.nextPartInstance!.partInstance._id,
396+
rundownId: playoutModel.nextPartInstance!.partInstance.rundownId,
397+
consumesQueuedSegmentId: false,
398+
manuallySelected: true,
399+
} satisfies SelectedPartInstance)
400+
401+
await worker.recreateNextPartInstance(undefined)
402+
403+
expect(playoutModel.nextPartInstance).toBeFalsy()
404+
})
405+
406+
test('clear manually chosen partInstance with replacement part', async () => {
407+
const context = setupDefaultJobEnvironment()
408+
const showStyleCompound = await setupMockShowStyleCompound(context)
409+
const blueprint = await context.getShowStyleBlueprint(showStyleCompound._id)
410+
411+
const { playoutModel, part0 } = await createSimplePlayoutModel(context, showStyleCompound)
412+
413+
const ingestModel = createMockIngestModelReadonly()
414+
415+
const worker = new SyncChangesToPartInstancesWorker(
416+
context,
417+
playoutModel,
418+
ingestModel,
419+
showStyleCompound,
420+
blueprint
421+
)
422+
423+
expect(playoutModel.nextPartInstance).toBeTruthy()
424+
const partInstanceIdBefore = playoutModel.nextPartInstance!.partInstance._id
425+
426+
// Force the next part to be manually selected, and verify
427+
playoutModel.setPartInstanceAsNext(playoutModel.nextPartInstance, true, false)
428+
expect(playoutModel.playlist.nextPartInfo).toEqual({
429+
partInstanceId: playoutModel.nextPartInstance!.partInstance._id,
430+
rundownId: playoutModel.nextPartInstance!.partInstance.rundownId,
431+
consumesQueuedSegmentId: false,
432+
manuallySelected: true,
433+
} satisfies SelectedPartInstance)
434+
435+
await worker.recreateNextPartInstance(part0)
436+
437+
expect(playoutModel.nextPartInstance).toBeTruthy()
438+
// Must have been regenerated
439+
expect(playoutModel.nextPartInstance!.partInstance._id).not.toEqual(partInstanceIdBefore)
440+
expect(playoutModel.nextPartInstance!.partInstance).toMatchObject({
441+
part: {
442+
_id: part0._id,
443+
},
444+
} satisfies PartialDeep<DBPartInstance>)
445+
446+
// Make sure the part is still manually selected
447+
expect(playoutModel.playlist.nextPartInfo).toEqual({
448+
partInstanceId: playoutModel.nextPartInstance!.partInstance._id,
449+
rundownId: playoutModel.nextPartInstance!.partInstance.rundownId,
450+
consumesQueuedSegmentId: false,
451+
manuallySelected: true,
452+
} satisfies SelectedPartInstance)
453+
})
168454
})
169455
})

packages/job-worker/src/ingest/syncChangesToPartInstance.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { convertIngestModelToPlayoutRundownWithSegments } from './commit'
3131
import { convertNoteToNotification } from '../notifications/util'
3232
import { PlayoutRundownModel } from '../playout/model/PlayoutRundownModel'
3333
import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance'
34+
import { setNextPart } from '../playout/setNext'
3435
import { PartId, RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids'
3536
import type { WrappedShowStyleBlueprint } from '../blueprints/cache'
3637

@@ -157,6 +158,14 @@ export class SyncChangesToPartInstancesWorker {
157158
existingPartInstance.snapshotRestore(partInstanceSnapshot)
158159
}
159160

161+
if (instanceToSync.playStatus === 'next' && syncContext.hasRemovedPartInstance) {
162+
// PartInstance was removed, so we need to remove it and re-select the next part
163+
await this.recreateNextPartInstance(instanceToSync.newPart)
164+
165+
// We don't need to continue syncing this partInstance, as it's been replaced
166+
return
167+
}
168+
160169
if (instanceToSync.playStatus === 'next') {
161170
existingPartInstance.recalculateExpectedDurationWithTransition()
162171
}
@@ -204,6 +213,29 @@ export class SyncChangesToPartInstancesWorker {
204213
}
205214
}
206215

216+
async recreateNextPartInstance(newPart: ReadonlyDeep<DBPart> | undefined): Promise<void> {
217+
const originalNextPartInfo = this.#playoutModel.playlist.nextPartInfo
218+
219+
// Clear the next part
220+
await setNextPart(this.#context, this.#playoutModel, null, false, 0)
221+
222+
if (originalNextPartInfo?.manuallySelected && newPart) {
223+
// If the next part was manually selected, we need to force it to be re-created
224+
await setNextPart(
225+
this.#context,
226+
this.#playoutModel,
227+
{
228+
part: newPart,
229+
consumesQueuedSegmentId: originalNextPartInfo.consumesQueuedSegmentId,
230+
},
231+
true,
232+
this.#playoutModel.playlist.nextTimeOffset || 0
233+
)
234+
} else {
235+
// A new next part will be selected automatically during the commit
236+
}
237+
}
238+
207239
saveNotes(
208240
syncContext: SyncIngestUpdateToPartInstanceContext,
209241
existingPartInstance: PlayoutPartInstanceModel

0 commit comments

Comments
 (0)