From 1917daac71b33b20f262d342a72c34e1ba4d19a6 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Thu, 1 Feb 2024 17:18:57 +0000 Subject: [PATCH 1/4] chore: add tests for PlayoutSegmentModelImpl --- .../__tests__/PlayoutSegmentModelImpl.spec.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts new file mode 100644 index 0000000000..ceacc7a5cf --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts @@ -0,0 +1,111 @@ +import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { PlayoutSegmentModelImpl } from '../PlayoutSegmentModelImpl' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' + +describe('PlayoutSegmentModelImpl', () => { + function createBasicDBSegment(): DBSegment { + return { + _id: protectString('abc'), + rundownId: protectString('rd0'), + externalId: 'ext1', + externalModified: 100000, + _rank: 1, + name: 'test segment', + } + } + + it('segment getter', async () => { + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, []) + + expect(model.segment).toBe(segment) + }) + + describe('getPartIds', () => { + it('no parts', async () => { + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, []) + + expect(model.getPartIds()).toEqual([]) + }) + it('with parts', async () => { + const fakePart: DBPart = { _id: protectString('part0'), _rank: 1 } as any + const fakePart2: DBPart = { _id: protectString('part1'), _rank: 2 } as any + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, [fakePart, fakePart2]) + + expect(model.getPartIds()).toEqual([fakePart._id, fakePart2._id]) + }) + it('with parts ensuring order', async () => { + const fakePart: DBPart = { _id: protectString('part0'), _rank: 5 } as any + const fakePart2: DBPart = { _id: protectString('part1'), _rank: 2 } as any + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, [fakePart, fakePart2]) + + expect(model.getPartIds()).toEqual([fakePart2._id, fakePart._id]) + }) + }) + + describe('getPart', () => { + it('no parts', async () => { + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, []) + + expect(model.getPart(protectString('missing'))).toBeUndefined() + }) + it('with other parts', async () => { + const fakePart: DBPart = { _id: protectString('part0'), _rank: 1 } as any + const fakePart2: DBPart = { _id: protectString('part1'), _rank: 2 } as any + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, [fakePart, fakePart2]) + + expect(model.getPart(protectString('missing'))).toBeUndefined() + }) + it('with found part', async () => { + const fakePart: DBPart = { _id: protectString('part0'), _rank: 1 } as any + const fakePart2: DBPart = { _id: protectString('part1'), _rank: 2 } as any + const segment = createBasicDBSegment() + const model = new PlayoutSegmentModelImpl(segment, [fakePart, fakePart2]) + + expect(model.getPart(fakePart._id)).toBe(fakePart) + }) + }) + + describe('setScratchpadRank', () => { + it('not scratchpad segment', async () => { + const segment = createBasicDBSegment() + const originalRank = segment._rank + const model = new PlayoutSegmentModelImpl(segment, []) + + expect(() => model.setScratchpadRank(originalRank + 1)).toThrow( + /setScratchpadRank can only be used on a SCRATCHPAD segment/ + ) + expect(model.segment._rank).toBe(originalRank) + }) + + it('is scratchpad segment', async () => { + const segment = createBasicDBSegment() + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + + const originalRank = segment._rank + const model = new PlayoutSegmentModelImpl(segment, []) + + model.setScratchpadRank(originalRank + 1) + expect(model.segment._rank).toBe(originalRank + 1) + }) + + it('not orphaned segment', async () => { + const segment = createBasicDBSegment() + segment.orphaned = SegmentOrphanedReason.DELETED + + const originalRank = segment._rank + const model = new PlayoutSegmentModelImpl(segment, []) + + expect(() => model.setScratchpadRank(originalRank + 1)).toThrow( + /setScratchpadRank can only be used on a SCRATCHPAD segment/ + ) + expect(model.segment._rank).toBe(originalRank) + }) + }) +}) From 6d18188d07a9f388480ed0f6aa86caceabc7aca3 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Mon, 4 Mar 2024 14:45:25 +0000 Subject: [PATCH 2/4] chore: add tests for PlayoutRundownModelImpl --- packages/job-worker/src/ingest/commit.ts | 12 +- .../src/playout/model/PlayoutRundownModel.ts | 6 +- .../implementation/PlayoutRundownModelImpl.ts | 21 +- .../__tests__/PlayoutRundownModelImpl.spec.ts | 342 ++++++++++++++++++ 4 files changed, 360 insertions(+), 21 deletions(-) create mode 100644 packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts diff --git a/packages/job-worker/src/ingest/commit.ts b/packages/job-worker/src/ingest/commit.ts index 0363596114..b609834493 100644 --- a/packages/job-worker/src/ingest/commit.ts +++ b/packages/job-worker/src/ingest/commit.ts @@ -729,16 +729,6 @@ async function removeSegments( async function validateScratchpad(_context: JobContext, playoutModel: PlayoutModel) { for (const rundown of playoutModel.rundowns) { - const scratchpadSegment = rundown.getScratchpadSegment() - - if (scratchpadSegment) { - // Ensure the _rank is just before the real content - const otherSegmentsInRundown = rundown.segments.filter( - (s) => s.segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD - ) - const minNormalRank = Math.min(0, ...otherSegmentsInRundown.map((s) => s.segment._rank)) - - rundown.setScratchpadSegmentRank(minNormalRank - 1) - } + rundown.updateScratchpadSegmentRank() } } diff --git a/packages/job-worker/src/playout/model/PlayoutRundownModel.ts b/packages/job-worker/src/playout/model/PlayoutRundownModel.ts index 4ed16cb94c..35faf85b68 100644 --- a/packages/job-worker/src/playout/model/PlayoutRundownModel.ts +++ b/packages/job-worker/src/playout/model/PlayoutRundownModel.ts @@ -63,9 +63,7 @@ export interface PlayoutRundownModel { */ getScratchpadSegment(): PlayoutSegmentModel | undefined /** - * Set the rank of the Scratchpad Segment in this Rundown - * Throws if the segment does not exists - * @param rank New rank + * Update the rank of the Scratchpad Segment in this Rundown, if it exists */ - setScratchpadSegmentRank(rank: number): void + updateScratchpadSegmentRank(): void } diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts index ae41598f2e..1c967c6dac 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts @@ -67,14 +67,12 @@ export class PlayoutRundownModelImpl implements PlayoutRundownModel { const existingSegment = this.segments.find((s) => s.segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) if (existingSegment) throw UserError.create(UserErrorMessage.ScratchpadAlreadyActive) - const minSegmentRank = Math.min(0, ...this.segments.map((s) => s.segment._rank)) - const segmentId: SegmentId = getRandomId() this.#segments.unshift( new PlayoutSegmentModelImpl( { _id: segmentId, - _rank: minSegmentRank - 1, + _rank: calculateRankForScratchpadSegment(this.#segments), externalId: '__scratchpad__', externalModified: getCurrentTime(), rundownId: this.rundown._id, @@ -105,13 +103,24 @@ export class PlayoutRundownModelImpl implements PlayoutRundownModel { return this.#segments.find((s) => s.segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) } - setScratchpadSegmentRank(rank: number): void { + updateScratchpadSegmentRank(): void { const segment = this.#segments.find((s) => s.segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) - if (!segment) throw new Error('Scratchpad segment does not exist!') + if (!segment) return - segment.setScratchpadRank(rank) + segment.setScratchpadRank(calculateRankForScratchpadSegment(this.#segments)) this.#segments.sort((a, b) => a.segment._rank - b.segment._rank) this.#scratchPadSegmentHasChanged = true } } + +function calculateRankForScratchpadSegment(segments: readonly PlayoutSegmentModel[]) { + // Ensure the _rank is just before the real content + + return ( + Math.min( + 0, + ...segments.map((s) => (s.segment.orphaned === SegmentOrphanedReason.SCRATCHPAD ? 0 : s.segment._rank)) + ) - 1 + ) +} diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts new file mode 100644 index 0000000000..64d094f724 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts @@ -0,0 +1,342 @@ +import { DBSegment, SegmentOrphanedReason } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { PlayoutSegmentModelImpl } from '../PlayoutSegmentModelImpl' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { PlayoutRundownModelImpl } from '../PlayoutRundownModelImpl' +import { SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { ReadonlyDeep } from 'type-fest' +import { restartRandomId } from '../../../../__mocks__/nanoid' +import { UserErrorMessage } from '@sofie-automation/corelib/dist/error' + +describe('PlayoutRundownModelImpl', () => { + function createBasicDBRundown(): DBRundown { + return { + _id: protectString('rd0'), + organizationId: null, + studioId: protectString('studio0'), + showStyleBaseId: protectString('ssb0'), + showStyleVariantId: protectString('ssv0'), + created: 0, + modified: 0, + externalId: 'rd0', + name: `my rundown`, + importVersions: null as any, + timing: null as any, + externalNRCSName: 'FAKE', + playlistId: protectString('playlist0'), + } + } + + function createBasicDBSegment(id: string, rank: number): DBSegment { + return { + _id: protectString(id), + rundownId: protectString('rd0'), + externalId: id, + externalModified: 100000, + _rank: rank, + name: `${id} segment`, + } + } + + it('rundown getter', async () => { + const rundown = createBasicDBRundown() + const model = new PlayoutRundownModelImpl(rundown, [], []) + + expect(model.rundown).toBe(rundown) + }) + + it('getSegment', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const segment2 = createBasicDBSegment('seg1', 5) + const segment2Model = new PlayoutSegmentModelImpl(segment2, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel, segment2Model], []) + + expect(model.getSegment(segment._id)).toBe(segmentModel) + expect(model.getSegment(segment2._id)).toBe(segment2Model) + + expect(model.getSegment(protectString('missing-id'))).toBeUndefined() + }) + + it('getSegmentIds', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const segment2 = createBasicDBSegment('seg1', 5) + const segment2Model = new PlayoutSegmentModelImpl(segment2, []) + + const model = new PlayoutRundownModelImpl( + rundown, + [ + // Intentionally reverse the order + segment2Model, + segmentModel, + ], + [] + ) + + expect(model.getSegmentIds()).toEqual([segment._id, segment2._id]) + }) + + describe('insertScratchpadSegment', () => { + beforeEach(() => { + restartRandomId() + }) + + it('ok', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + + const expectedId: SegmentId = protectString('randomId9000') + expect(model.insertScratchpadSegment()).toEqual(expectedId) + + const createdSegment = model.getSegment(expectedId) as PlayoutSegmentModelImpl + expect(createdSegment).toBeTruthy() + + const fixedSegment: ReadonlyDeep = { + ...createdSegment.segment, + externalModified: 0, + } + + expect(fixedSegment).toEqual({ + _id: expectedId, + rundownId: protectString('rd0'), + externalId: '__scratchpad__', + externalModified: 0, + _rank: -1, + name: '', + orphaned: SegmentOrphanedReason.SCRATCHPAD, + } satisfies DBSegment) + + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + }) + + it('check rank - first segment higher', async () => { + const rundown = createBasicDBRundown() + const segmentModel = new PlayoutSegmentModelImpl(createBasicDBSegment('seg0', 10), []) + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + const expectedId: SegmentId = protectString('randomId9000') + expect(model.insertScratchpadSegment()).toEqual(expectedId) + + const createdSegment = model.getSegment(expectedId) as PlayoutSegmentModelImpl + expect(createdSegment).toBeTruthy() + expect(createdSegment.segment._rank).toBe(-1) + }) + it('check rank - first segment lower', async () => { + const rundown = createBasicDBRundown() + const segmentModel = new PlayoutSegmentModelImpl(createBasicDBSegment('seg0', -5), []) + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + const expectedId: SegmentId = protectString('randomId9000') + expect(model.insertScratchpadSegment()).toEqual(expectedId) + + const createdSegment = model.getSegment(expectedId) as PlayoutSegmentModelImpl + expect(createdSegment).toBeTruthy() + expect(createdSegment.segment._rank).toBe(-6) + }) + + it('calling twice fails', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + const expectedId: SegmentId = protectString('randomId9000') + expect(model.insertScratchpadSegment()).toEqual(expectedId) + + const createdSegment = model.getSegment(expectedId) as PlayoutSegmentModelImpl + expect(createdSegment).toBeTruthy() + + model.clearScratchPadSegmentChangedFlag() + + // Expect a UserError + expect(() => model.insertScratchpadSegment()).toThrow( + expect.objectContaining({ key: UserErrorMessage.ScratchpadAlreadyActive }) + ) + + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + }) + + it('calling when predefined', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + // Expect a UserError + expect(() => model.insertScratchpadSegment()).toThrow( + expect.objectContaining({ key: UserErrorMessage.ScratchpadAlreadyActive }) + ) + + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + }) + }) + + describe('removeScratchpadSegment', () => { + beforeEach(() => { + restartRandomId() + }) + + it('ok', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + + expect(model.removeScratchpadSegment()).toBeTruthy() + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + }) + + it('calling multiple times', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + + expect(model.removeScratchpadSegment()).toBeTruthy() + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + + // call again + expect(model.removeScratchpadSegment()).toBeFalsy() + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + + // once more, after clearing changed flag + model.clearScratchPadSegmentChangedFlag() + expect(model.removeScratchpadSegment()).toBeFalsy() + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + }) + + it('insert then remove', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + + const expectedId: SegmentId = protectString('randomId9000') + expect(model.insertScratchpadSegment()).toEqual(expectedId) + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + expect(model.getSegmentIds()).toEqual([expectedId, segment._id]) + + model.clearScratchPadSegmentChangedFlag() + expect(model.removeScratchpadSegment()).toBeTruthy() + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + expect(model.getSegmentIds()).toEqual([segment._id]) + }) + }) + + describe('getScratchpadSegment', () => { + beforeEach(() => { + restartRandomId() + }) + + it('pre-defined', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + expect(model.getScratchpadSegment()).toBe(segmentModel) + }) + + it('after remove', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + expect(model.removeScratchpadSegment()).toBeTruthy() + + expect(model.getScratchpadSegment()).toBe(undefined) + }) + + it('after insert', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + const expectedId: SegmentId = protectString('randomId9000') + expect(model.insertScratchpadSegment()).toEqual(expectedId) + + expect(model.getScratchpadSegment()).toMatchObject({ + segment: { _id: expectedId }, + }) + }) + }) + + describe('setScratchpadSegmentRank', () => { + beforeEach(() => { + restartRandomId() + }) + + it('pre-defined', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 99) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + expect(model.getScratchpadSegment()?.segment._rank).toBe(99) + + model.clearScratchPadSegmentChangedFlag() + model.updateScratchpadSegmentRank() + + expect(model.getScratchpadSegment()?.segment._rank).toBe(-1) + expect(model.ScratchPadSegmentHasChanged).toBeTruthy() + }) + + it('after remove', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', 0) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + expect(model.removeScratchpadSegment()).toBeTruthy() + + model.clearScratchPadSegmentChangedFlag() + model.updateScratchpadSegmentRank() + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + }) + }) +}) From 1a0fbcaa73c953f7f1071adf6d4ef81d872392ac Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 5 Mar 2024 15:56:24 +0000 Subject: [PATCH 3/4] chore: only save scratchpad segment when rank changes --- .../implementation/PlayoutRundownModelImpl.ts | 5 +++-- .../implementation/PlayoutSegmentModelImpl.ts | 5 ++++- .../__tests__/PlayoutRundownModelImpl.spec.ts | 16 ++++++++++++++++ .../__tests__/PlayoutSegmentModelImpl.spec.ts | 7 ++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts index 1c967c6dac..c43e38fb97 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutRundownModelImpl.ts @@ -107,9 +107,10 @@ export class PlayoutRundownModelImpl implements PlayoutRundownModel { const segment = this.#segments.find((s) => s.segment.orphaned === SegmentOrphanedReason.SCRATCHPAD) if (!segment) return - segment.setScratchpadRank(calculateRankForScratchpadSegment(this.#segments)) - this.#segments.sort((a, b) => a.segment._rank - b.segment._rank) + const changed = segment.setScratchpadRank(calculateRankForScratchpadSegment(this.#segments)) + if (!changed) return + this.#segments.sort((a, b) => a.segment._rank - b.segment._rank) this.#scratchPadSegmentHasChanged = true } } diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts index 6753165086..519acaaa73 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutSegmentModelImpl.ts @@ -32,10 +32,13 @@ export class PlayoutSegmentModelImpl implements PlayoutSegmentModel { * This segment belongs to Playout, so is allowed to be modified in this way * @param rank New rank for the segment */ - setScratchpadRank(rank: number): void { + setScratchpadRank(rank: number): boolean { if (this.#segment.orphaned !== SegmentOrphanedReason.SCRATCHPAD) throw new Error('setScratchpadRank can only be used on a SCRATCHPAD segment') + if (this.#segment._rank == rank) return false + this.#segment._rank = rank + return true } } diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts index 64d094f724..41054565f1 100644 --- a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts +++ b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutRundownModelImpl.spec.ts @@ -323,6 +323,22 @@ describe('PlayoutRundownModelImpl', () => { expect(model.ScratchPadSegmentHasChanged).toBeTruthy() }) + it('pre-defined: no change', async () => { + const rundown = createBasicDBRundown() + + const segment = createBasicDBSegment('seg0', -1) + segment.orphaned = SegmentOrphanedReason.SCRATCHPAD + const segmentModel = new PlayoutSegmentModelImpl(segment, []) + + const model = new PlayoutRundownModelImpl(rundown, [segmentModel], []) + + model.clearScratchPadSegmentChangedFlag() + model.updateScratchpadSegmentRank() + + expect(model.getScratchpadSegment()?.segment._rank).toBe(-1) + expect(model.ScratchPadSegmentHasChanged).toBeFalsy() + }) + it('after remove', async () => { const rundown = createBasicDBRundown() diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts index ceacc7a5cf..1cfbc84ccb 100644 --- a/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts +++ b/packages/job-worker/src/playout/model/implementation/__tests__/PlayoutSegmentModelImpl.spec.ts @@ -91,7 +91,12 @@ describe('PlayoutSegmentModelImpl', () => { const originalRank = segment._rank const model = new PlayoutSegmentModelImpl(segment, []) - model.setScratchpadRank(originalRank + 1) + // Set should report change + expect(model.setScratchpadRank(originalRank + 1)).toBeTruthy() + expect(model.segment._rank).toBe(originalRank + 1) + + // Set again should report no change + expect(model.setScratchpadRank(originalRank + 1)).toBeFalsy() expect(model.segment._rank).toBe(originalRank + 1) }) From d2f12b64f2027b07c764548753fa10e7616a7b4d Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Tue, 5 Mar 2024 16:26:05 +0000 Subject: [PATCH 4/4] chore: add tests for SavePlayoutModel --- .../job-worker/src/__mocks__/collection.ts | 14 +- .../__tests__/SavePlayoutModel.spec.ts | 395 ++++++++++++++++++ 2 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 packages/job-worker/src/playout/model/implementation/__tests__/SavePlayoutModel.spec.ts diff --git a/packages/job-worker/src/__mocks__/collection.ts b/packages/job-worker/src/__mocks__/collection.ts index 3da3c0cd4c..932e06386e 100644 --- a/packages/job-worker/src/__mocks__/collection.ts +++ b/packages/job-worker/src/__mocks__/collection.ts @@ -175,9 +175,6 @@ export class MockMongoCollection }> imp async remove(selector: MongoQuery | TDoc['_id']): Promise { this.#ops.push({ type: 'remove', args: [selector] }) - return this.removeInner(selector) - } - private async removeInner(selector: MongoQuery | TDoc['_id']): Promise { const docs: Pick[] = await this.findFetchInner(selector, { projection: { _id: 1 } }) for (const doc of docs) { this.#documents.delete(doc._id) @@ -186,8 +183,6 @@ export class MockMongoCollection }> imp return docs.length } async update(selector: MongoQuery | TDoc['_id'], modifier: MongoModifier): Promise { - this.#ops.push({ type: 'update', args: [selector, modifier] }) - return this.updateInner(selector, modifier, false) } private async updateInner( @@ -195,6 +190,8 @@ export class MockMongoCollection }> imp modifier: MongoModifier, single: boolean ) { + this.#ops.push({ type: 'update', args: [selector, modifier] }) + const docs = await this.findFetchInner(selector) for (const doc of docs) { @@ -210,9 +207,6 @@ export class MockMongoCollection }> imp async replace(doc: TDoc | ReadonlyDeep): Promise { this.#ops.push({ type: 'replace', args: [doc._id] }) - return this.replaceInner(doc) - } - private async replaceInner(doc: TDoc | ReadonlyDeep): Promise { if (!doc._id) throw new Error(`replace requires document to have an _id`) const exists = this.#documents.has(doc._id) @@ -228,9 +222,9 @@ export class MockMongoCollection }> imp } else if ('updateOne' in op) { await this.updateInner(op.updateOne.filter, op.updateOne.update, true) } else if ('replaceOne' in op) { - await this.replaceInner(op.replaceOne.replacement as any) + await this.replace(op.replaceOne.replacement as any) } else if ('deleteMany' in op) { - await this.removeInner(op.deleteMany.filter) + await this.remove(op.deleteMany.filter) } else { // Note: implement more as we start using them throw new Error(`Unknown mongo Bulk Operation: ${JSON.stringify(op)}`) diff --git a/packages/job-worker/src/playout/model/implementation/__tests__/SavePlayoutModel.spec.ts b/packages/job-worker/src/playout/model/implementation/__tests__/SavePlayoutModel.spec.ts new file mode 100644 index 0000000000..5f4e8a4c68 --- /dev/null +++ b/packages/job-worker/src/playout/model/implementation/__tests__/SavePlayoutModel.spec.ts @@ -0,0 +1,395 @@ +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { PlayoutSegmentModelImpl } from '../PlayoutSegmentModelImpl' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { DBRundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' +import { PlayoutRundownModelImpl } from '../PlayoutRundownModelImpl' +import { setupDefaultJobEnvironment } from '../../../../__mocks__/context' +import { writePartInstancesAndPieceInstances, writeScratchpadSegments } from '../SavePlayoutModel' +import { PlayoutPartInstanceModelImpl } from '../PlayoutPartInstanceModelImpl' +import { PartInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PieceInstance } from '@sofie-automation/corelib/dist/dataModel/PieceInstance' + +describe('SavePlayoutModel', () => { + function createRundownModel(segments?: DBSegment[]): PlayoutRundownModelImpl { + const rundown: DBRundown = { + _id: protectString('rd0'), + organizationId: null, + studioId: protectString('studio0'), + showStyleBaseId: protectString('ssb0'), + showStyleVariantId: protectString('ssv0'), + created: 0, + modified: 0, + externalId: 'rd0', + name: `my rundown`, + importVersions: null as any, + timing: null as any, + externalNRCSName: 'FAKE', + playlistId: protectString('playlist0'), + } + + const segmentModels = (segments ?? []).map((s) => new PlayoutSegmentModelImpl(s, [])) + return new PlayoutRundownModelImpl(rundown, segmentModels, []) + } + + describe('writeScratchpadSegments', () => { + it('no rundowns', async () => { + const context = setupDefaultJobEnvironment() + + await writeScratchpadSegments(context, []) + + expect(context.mockCollections.Segments.operations).toHaveLength(0) + }) + + it('no scratchpad segment', async () => { + const context = setupDefaultJobEnvironment() + + const rundown0 = createRundownModel() + const rundown1 = createRundownModel() + rundown1.insertScratchpadSegment() + rundown1.clearScratchPadSegmentChangedFlag() + + await writeScratchpadSegments(context, [rundown0, rundown1]) + + expect(context.mockCollections.Segments.operations).toHaveLength(0) + }) + + it('scratchpads with changes', async () => { + const context = setupDefaultJobEnvironment() + + // create a rundown with an inserted scratchpad + const rundown0 = createRundownModel() + rundown0.insertScratchpadSegment() + + // create a rundown with a removed scratchpad + const rundown1 = createRundownModel() + rundown1.insertScratchpadSegment() + rundown1.clearScratchPadSegmentChangedFlag() + rundown1.removeScratchpadSegment() + + // create a rundown with no changes + const rundown2 = createRundownModel() + rundown2.insertScratchpadSegment() + rundown2.clearScratchPadSegmentChangedFlag() + + await writeScratchpadSegments(context, [rundown0, rundown1, rundown2]) + + expect(context.mockCollections.Segments.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 3, + ], + "type": "bulkWrite", + }, + { + "args": [ + { + "_id": { + "$ne": "randomId9001", + }, + "orphaned": "scratchpad", + "rundownId": "rd0", + }, + ], + "type": "remove", + }, + { + "args": [ + "randomId9001", + ], + "type": "replace", + }, + { + "args": [ + { + "_id": { + "$ne": "", + }, + "orphaned": "scratchpad", + "rundownId": "rd0", + }, + ], + "type": "remove", + }, + ] + `) + }) + }) + + describe('writePartInstancesAndPieceInstances', () => { + it('no PartInstances', async () => { + const context = setupDefaultJobEnvironment() + + await Promise.all(writePartInstancesAndPieceInstances(context, new Map())) + + expect(context.mockCollections.PartInstances.operations).toHaveLength(0) + expect(context.mockCollections.PieceInstances.operations).toHaveLength(0) + }) + + it('delete PartInstances', async () => { + const context = setupDefaultJobEnvironment() + + const partInstances = new Map() + partInstances.set(protectString('id0'), null) + partInstances.set(protectString('id1'), null) + + await Promise.all(writePartInstancesAndPieceInstances(context, partInstances)) + + expect(context.mockCollections.PartInstances.operations).toHaveLength(2) + expect(context.mockCollections.PartInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 1, + ], + "type": "bulkWrite", + }, + { + "args": [ + { + "_id": { + "$in": [ + "id0", + "id1", + ], + }, + }, + ], + "type": "remove", + }, + ] + `) + expect(context.mockCollections.PieceInstances.operations).toHaveLength(2) + expect(context.mockCollections.PieceInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 1, + ], + "type": "bulkWrite", + }, + { + "args": [ + { + "partInstanceId": { + "$in": [ + "id0", + "id1", + ], + }, + }, + ], + "type": "remove", + }, + ] + `) + }) + + it('delete PieceInstances', async () => { + const context = setupDefaultJobEnvironment() + + const pieceInstance = { _id: 'test0' } as unknown as PieceInstance + const partInstanceModel = new PlayoutPartInstanceModelImpl(null as any, [pieceInstance], false) + expect(partInstanceModel.removePieceInstance(pieceInstance._id)).toBeTruthy() + + const partInstances = new Map() + partInstances.set(protectString('id0'), partInstanceModel) + + await Promise.all(writePartInstancesAndPieceInstances(context, partInstances)) + + expect(context.mockCollections.PartInstances.operations).toHaveLength(0) + + expect(context.mockCollections.PieceInstances.operations).toHaveLength(2) + expect(context.mockCollections.PieceInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 1, + ], + "type": "bulkWrite", + }, + { + "args": [ + { + "_id": { + "$in": [ + "test0", + ], + }, + }, + ], + "type": "remove", + }, + ] + `) + }) + + it('update PartInstance', async () => { + const context = setupDefaultJobEnvironment() + + const partInstanceModel = new PlayoutPartInstanceModelImpl({ _id: 'id0' } as any, [], false) + expect(partInstanceModel.partInstance.blockTakeUntil).toBeUndefined() + partInstanceModel.blockTakeUntil(10000) + expect(partInstanceModel.partInstance.blockTakeUntil).toEqual(10000) + + const partInstances = new Map() + partInstances.set(protectString('id0'), partInstanceModel) + + await Promise.all(writePartInstancesAndPieceInstances(context, partInstances)) + + expect(context.mockCollections.PartInstances.operations).toHaveLength(2) + expect(context.mockCollections.PartInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 1, + ], + "type": "bulkWrite", + }, + { + "args": [ + "id0", + ], + "type": "replace", + }, + ] + `) + }) + + it('update PieceInstance', async () => { + const context = setupDefaultJobEnvironment() + + const pieceInstance = { _id: 'test0' } as unknown as PieceInstance + const partInstanceModel = new PlayoutPartInstanceModelImpl(null as any, [pieceInstance], false) + expect( + partInstanceModel.mergeOrInsertPieceInstance({ + ...pieceInstance, + adLibSourceId: protectString('adlib0'), + }) + ).toBeTruthy() + + const partInstances = new Map() + partInstances.set(protectString('id0'), partInstanceModel) + + await Promise.all(writePartInstancesAndPieceInstances(context, partInstances)) + + expect(context.mockCollections.PartInstances.operations).toHaveLength(0) + + expect(context.mockCollections.PieceInstances.operations).toHaveLength(2) + expect(context.mockCollections.PieceInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 1, + ], + "type": "bulkWrite", + }, + { + "args": [ + "test0", + ], + "type": "replace", + }, + ] + `) + }) + + it('combination of all ops', async () => { + const context = setupDefaultJobEnvironment() + + const pieceInstance = { _id: 'test0' } as unknown as PieceInstance + const pieceInstance2 = { _id: 'test1' } as unknown as PieceInstance + const partInstanceModel = new PlayoutPartInstanceModelImpl( + { _id: 'id0' } as any, + [pieceInstance, pieceInstance2], + false + ) + expect( + partInstanceModel.mergeOrInsertPieceInstance({ + ...pieceInstance, + adLibSourceId: protectString('adlib0'), + }) + ).toBeTruthy() + expect(partInstanceModel.removePieceInstance(pieceInstance2._id)).toBeTruthy() + partInstanceModel.blockTakeUntil(10000) + + const partInstances = new Map() + partInstances.set(protectString('id0'), partInstanceModel) + partInstances.set(protectString('id1'), null) + + await Promise.all(writePartInstancesAndPieceInstances(context, partInstances)) + + expect(context.mockCollections.PartInstances.operations).toHaveLength(3) + expect(context.mockCollections.PartInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 2, + ], + "type": "bulkWrite", + }, + { + "args": [ + "id0", + ], + "type": "replace", + }, + { + "args": [ + { + "_id": { + "$in": [ + "id1", + ], + }, + }, + ], + "type": "remove", + }, + ] + `) + + expect(context.mockCollections.PieceInstances.operations).toHaveLength(4) + expect(context.mockCollections.PieceInstances.operations).toMatchInlineSnapshot(` + [ + { + "args": [ + 3, + ], + "type": "bulkWrite", + }, + { + "args": [ + "test0", + ], + "type": "replace", + }, + { + "args": [ + { + "partInstanceId": { + "$in": [ + "id1", + ], + }, + }, + ], + "type": "remove", + }, + { + "args": [ + { + "_id": { + "$in": [ + "test1", + ], + }, + }, + ], + "type": "remove", + }, + ] + `) + }) + }) +})