diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index f1dfec996..dc9d5f7a5 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -51,7 +51,7 @@ type BatchEditEntry = { /** * HTTP request to edit multiple events */ -export async function putBatchEditEvents(data: BatchEditEntry): Promise> { +export async function putBatchEditEvents(data: BatchEditEntry): Promise> { return axios.put(`${rundownPath}/batch`, data); } @@ -83,7 +83,7 @@ export async function requestEventSwap(data: SwapEntry): Promise> { +export async function requestApplyDelay(delayId: EntryId): Promise> { return axios.patch(`${rundownPath}/applydelay/${delayId}`); } diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index c584a7207..3837500cb 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -389,8 +389,18 @@ export const useEntryActions = () => { // Return a context with the previous rundown return { previousRundown }; }, - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: RUNDOWN }); + onSuccess: (response) => { + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); }, onError: (_error, _newEvent, context) => { queryClient.setQueryData(RUNDOWN, context?.previousRundown); @@ -521,6 +531,19 @@ export const useEntryActions = () => { */ const _applyDelayMutation = useMutation({ mutationFn: requestApplyDelay, + onSuccess: (response) => { + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); + }, // Mutation finished, failed or successful onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); diff --git a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts index d8e300006..e0ea64709 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts @@ -1,7 +1,7 @@ import { PlayableEvent, TimerLifeCycle } from 'ontime-types'; import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; -import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js'; +import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js'; import { testConditions, triggerAutomations } from '../automation.service.js'; diff --git a/apps/server/src/api-data/custom-fields/customFields.controller.ts b/apps/server/src/api-data/custom-fields/customFields.controller.ts index 71c55f5f8..aaf6d6b67 100644 --- a/apps/server/src/api-data/custom-fields/customFields.controller.ts +++ b/apps/server/src/api-data/custom-fields/customFields.controller.ts @@ -3,15 +3,13 @@ import { CustomField, CustomFields, ErrorResponse } from 'ontime-types'; import type { Request, Response } from 'express'; import { getErrorMessage } from 'ontime-utils'; -import { - createCustomField, - editCustomField, - getCustomFields as getCustomFieldsFromCache, - removeCustomField, -} from '../../services/rundown-service/rundownCache.js'; + +import { createCustomField, editCustomField, removeCustomField } from '../../services/rundown-service/rundownCache.js'; + +import { getProjectCustomFields } from '../rundown/rundown.dao.js'; export async function getCustomFields(_req: Request, res: Response) { - const customFields = getCustomFieldsFromCache(); + const customFields = getProjectCustomFields(); res.json(customFields); } diff --git a/apps/server/src/api-data/excel/excel.service.ts b/apps/server/src/api-data/excel/excel.service.ts index f5d989e75..275f82561 100644 --- a/apps/server/src/api-data/excel/excel.service.ts +++ b/apps/server/src/api-data/excel/excel.service.ts @@ -14,9 +14,9 @@ import type { WorkBook } from 'xlsx'; import { parseExcel } from '../../utils/parser.js'; import { parseCustomFields } from '../../utils/parserFunctions.js'; import { deleteFile } from '../../utils/parserUtils.js'; -import { getCustomFields } from '../../services/rundown-service/rundownCache.js'; import { parseRundown } from '../rundown/rundown.parser.js'; +import { getProjectCustomFields } from '../rundown/rundown.dao.js'; let excelData: WorkBook = xlsx.utils.book_new(); @@ -45,7 +45,7 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown; const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false }); - const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options.worksheet, options); + const dataFromExcel = parseExcel(arrayOfData, getProjectCustomFields(), options.worksheet, options); const parsedCustomFields = parseCustomFields(dataFromExcel); // we run the parsed data through an extra step to ensure the objects shape diff --git a/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts b/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts new file mode 100644 index 000000000..4598c3c23 --- /dev/null +++ b/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts @@ -0,0 +1,54 @@ +import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types'; +import { defaultRundown } from '../../../models/dataModel.js'; + +const baseEvent = { + type: SupportedEntry.Event, + skip: false, + revision: 1, +}; + +const baseBlock = { + type: SupportedEntry.Block, + events: [], +}; + +/** + * Utility to create a Ontime event + */ +export function makeOntimeEvent(patch: Partial): OntimeEvent { + return { + ...baseEvent, + ...patch, + } as OntimeEvent; +} + +/** + * Utility to create a delay event + */ +export function makeOntimeDelay(patch: Partial): OntimeDelay { + return { id: 'delay', type: SupportedEntry.Delay, duration: 0, ...patch } as OntimeDelay; +} + +/** + * Utility to create a block event + */ +export function makeOntimeBlock(patch: Partial): OntimeBlock { + return { id: 'block', ...baseBlock, ...patch } as OntimeBlock; +} + +/** + * Utility to create a rundown object + */ +export function makeRundown(patch: Partial): Rundown { + return { + ...defaultRundown, + ...patch, + }; +} + +/** + * Utility to generate a rundown of OntimeEvents form partial objects + */ +export function prepareTimedEvents(events: Partial[]): OntimeEvent[] { + return events.map(makeOntimeEvent); +} diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts new file mode 100644 index 000000000..8373f2fa9 --- /dev/null +++ b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts @@ -0,0 +1,1570 @@ +import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; +import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; + +import { makeOntimeEvent, makeRundown, makeOntimeBlock, makeOntimeDelay } from '../__mocks__/rundown.mocks.js'; + +import { createTransaction, processRundown, rundownCache, rundownMutation } from '../rundown.dao.js'; +import { demoDb } from '../../../models/demoProject.js'; +import { ProcessedRundownMetadata } from '../../../services/rundown-service/rundownCache.utils.js'; + +beforeAll(() => { + vi.mock('../../../classes/data-provider/DataProvider.js', () => { + return { + getDataProvider: vi.fn().mockImplementation(() => { + return { + setRundown: vi.fn().mockImplementation(() => undefined), + }; + }), + }; + }); +}); + +afterAll(() => { + vi.clearAllMocks(); +}); + +describe('createTransaction', () => { + it('should return a snapshot of the cached rundown and an commit function', () => { + const { rundown, commit } = createTransaction(); + expect(rundown).toBeDefined(); + expect(typeof commit).toBe('function'); + }); + + it('should return the updated rundown after commit is called and update the db', () => { + const { rundown, commit } = createTransaction(); + rundown.title = 'Another Title'; + const updated = commit(); + expect(updated.rundown.title).toBe('Another Title'); + }); +}); + +describe('processRundown()', () => { + test('benchmark function execution time', () => { + const t1 = performance.now(); + let result: ProcessedRundownMetadata | null = null; + for (let i = 0; i < 100; i++) { + result = processRundown(demoDb.rundowns.default, demoDb.customFields); + } + const t2 = performance.now(); + console.warn( + 'Rundown generation took', + t2 - t1, + 'milliseconds for 100x', + Object.keys(result?.entries ?? {}).length, + 'events', + ); + }); + + it('generates metadata from given rundown', () => { + const initResult = processRundown(demoDb.rundowns.default, demoDb.customFields); + + expect(initResult.order.length).toBe(12); + expect(initResult.flatEntryOrder.length).toBe(17); + expect(initResult.timedEventOrder.length).toBe(14); + expect(initResult.playableEventOrder.length).toBe(14); + expect(initResult.firstStart).toBe(36000000); + expect(initResult.lastEnd).toBe(61800000); + expect(initResult.totalDays).toBe(0); + expect(initResult.totalDelay).toBe(0); + }); + + it('creates normalised versions of a given rundown', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1' }), + '2': makeOntimeBlock({ id: '2' }), + '3': makeOntimeDelay({ id: '3' }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(3); + expect(initResult.order).toStrictEqual(['1', '2', '3']); + expect(initResult.entries['1'].type).toBe(SupportedEntry.Event); + expect(initResult.entries['2'].type).toBe(SupportedEntry.Block); + expect(initResult.entries['3'].type).toBe(SupportedEntry.Delay); + }); + + it('calculates delays versions of a given rundown', () => { + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeDelay({ id: '1', duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 1, timeEnd: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(2); + expect((initResult.entries['2'] as OntimeEvent).delay).toBe(100); + expect(initResult.totalDelay).toBe(100); + }); + + it('accounts for gaps in rundown when calculating delays', () => { + const rundown = makeRundown({ + order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 200 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + block: makeOntimeBlock({ id: 'block', title: 'break' }), + '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), + 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), + '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(7); + expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); + expect((initResult.entries['2'] as OntimeEvent).delay).toBe(200); + expect((initResult.entries['3'] as OntimeEvent).delay).toBe(100); + expect((initResult.entries['4'] as OntimeEvent).delay).toBe(0); + expect(initResult.totalDelay).toBe(0); + expect(initResult.totalDuration).toBe(700 - 100); + }); + + it('accounts for overlaps in rundown', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), + '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), + '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start + }); + + it('accounts for overlaps in rundown (with added gap)', () => { + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), + '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), + '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), + '4': makeOntimeEvent({ id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start + }); + + it('accounts for overlaps in rundown (with multiple days)', () => { + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, + timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, + duration: 30 * MILLIS_PER_MINUTE, + }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, + timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, + duration: MILLIS_PER_HOUR, + }), + '4': makeOntimeEvent({ + id: '4', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start + }); + + it('maintains delays over gaps', () => { + const rundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 500 }), + '2': makeOntimeEvent({ id: '2', timeStart: 500, timeEnd: 600, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.entries['2']).toMatchObject({ + gap: 400, + delay: 500, + }); + expect(initResult.totalDelay).toBe(500); + expect(initResult.totalDuration).toBe(600); + }); + + it('maintains delays over gaps when there are cumulative delays', () => { + const rundown = makeRundown({ + order: ['delay1', '1', 'delay2', '2'], + entries: { + delay1: makeOntimeDelay({ id: 'delay', duration: 100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay2: makeOntimeDelay({ id: 'delay', duration: 300 }), + '2': makeOntimeEvent({ id: '2', timeStart: 300, timeEnd: 400, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.entries['1']).toMatchObject({ + gap: 0, + delay: 100, + }); + expect(initResult.entries['2']).toMatchObject({ + gap: 200, + delay: 300, // first delay was fully absorbed + }); + expect(initResult.totalDelay).toBe(300); + expect(initResult.totalDuration).toBe(400); + }); + + it('handles negative delays on gaps', () => { + const rundown = makeRundown({ + order: ['delay1', '1', 'delay2', '2'], + entries: { + delay1: makeOntimeDelay({ id: 'delay', duration: -100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay2: makeOntimeDelay({ id: 'delay', duration: -300 }), + '2': makeOntimeEvent({ id: '2', timeStart: 300, timeEnd: 400, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.entries['1']).toMatchObject({ + gap: 0, + delay: -100, + }); + expect(initResult.entries['2']).toMatchObject({ + gap: 200, + delay: -400, + }); + expect(initResult.totalDelay).toBe(-400); + expect(initResult.totalDuration).toBe(400); + }); + + it('handles negative delays', () => { + const rundown = makeRundown({ + order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: -200 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + block: makeOntimeBlock({ id: 'block', title: 'break' }), + '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), + 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), + '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(7); + expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); + expect((initResult.entries['2'] as OntimeEvent).delay).toBe(-200); + expect((initResult.entries['3'] as OntimeEvent).delay).toBe(-200); + expect((initResult.entries['4'] as OntimeEvent).delay).toBe(-200); + expect(initResult.totalDelay).toBe(-200); + expect(initResult.totalDuration).toBe(700 - 100); + }); + + it('links times across events', () => { + const rundown = makeRundown({ + order: ['1', '2', 'block', 'delay', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 1, + duration: 1, + timeEnd: 2, + timeStrategy: TimeStrategy.LockEnd, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 11, + duration: 1, + timeEnd: 12, + linkStart: true, + timeStrategy: TimeStrategy.LockEnd, + }), + block: makeOntimeBlock({ id: 'block' }), + delay: makeOntimeDelay({ id: 'delay' }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 21, + duration: 1, + timeEnd: 22, + linkStart: true, + timeStrategy: TimeStrategy.LockEnd, + }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(5); + expect((initResult.entries['2'] as OntimeEvent).timeStart).toBe(2); + expect((initResult.entries['2'] as OntimeEvent).timeEnd).toBe(12); + expect((initResult.entries['2'] as OntimeEvent).duration).toBe(10); + + expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(12); + expect((initResult.entries['3'] as OntimeEvent).timeEnd).toBe(22); + expect((initResult.entries['3'] as OntimeEvent).duration).toBe(10); + }); + + it('links times across events, reordered', () => { + const rundown = makeRundown({ + order: ['1', '3', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }), + '3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: true }), + '2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: true }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(3); + expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(2); + }); + + it('calculates total duration', () => { + const rundown = makeRundown({ + order: ['1', '2', 'skipped', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 100 }), + '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(4); + expect(initResult.totalDuration).toBe(500 - 100); + }); + + it('calculates total duration with 0 duration events without causing a next day', () => { + const rundown = makeRundown({ + order: ['1', '2', 'skipped', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 100, duration: 0 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 300, duration: 200 }), + skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 0 }), + '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.order.length).toBe(4); + expect(initResult.totalDuration).toBe(500 - 100); + }); + + it('calculates total duration across days with gap', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + '3': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR); + }); + + it('calculates total duration across days', () => { + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 12 * MILLIS_PER_HOUR, + timeEnd: 22 * MILLIS_PER_HOUR, + duration: 10 * MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 22 * MILLIS_PER_HOUR, + timeEnd: 8 * MILLIS_PER_HOUR, + duration: (24 - 22 + 8) * MILLIS_PER_HOUR, + }), + }, + }); + + const initResult = processRundown(rundown, {}); + const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR); + expect(initResult.totalDuration).toBe(expectedDuration); + }); + + it('handles updating event sequence', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 0, + timeEnd: 600000, + duration: 600000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: false, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 600000, + timeEnd: 601000, + duration: 85801000, // <------------- value out of sync + timeStrategy: TimeStrategy.LockEnd, + linkStart: true, + }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 100, // <------------- value out of sync + timeEnd: 602000, + duration: 0, + timeStrategy: TimeStrategy.LockEnd, + linkStart: true, + }), + }, + }); + + const initResult = processRundown(rundown, {}); + expect(initResult.entries).toMatchObject({ + '1': { + timeStart: 0, + timeEnd: 600000, + duration: 600000, + timeStrategy: 'lock-duration', + linkStart: false, + }, + '2': { + timeStart: 600000, + timeEnd: 601000, + duration: 1000, + timeStrategy: 'lock-end', + linkStart: true, + }, + '3': { + timeStart: 601000, + timeEnd: 602000, + duration: 1000, + timeStrategy: 'lock-end', + linkStart: true, + }, + }); + }); + + describe('custom properties feature', () => { + it('creates a map of custom properties', () => { + const customProperties: CustomFields = { + lighting: { + label: 'lighting', + type: 'string', + colour: 'red', + }, + sound: { + label: 'sound', + type: 'string', + colour: 'red', + }, + }; + + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ + id: '1', + custom: { + lighting: 'event 1 lx', + }, + }), + '2': makeOntimeEvent({ + id: '2', + custom: { + lighting: 'event 2 lx', + sound: 'event 2 sound', + }, + }), + }, + }); + const initResult = processRundown(rundown, customProperties); + expect(initResult.order.length).toBe(2); + expect(initResult.assignedCustomFields).toMatchObject({ + lighting: ['1', '2'], + sound: ['2'], + }); + expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' }); + expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({ + lighting: 'event 2 lx', + sound: 'event 2 sound', + }); + }); + }); + describe('handle of event groups', () => { + it('correctly parses group metadata', () => { + const rundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['100', '200', '300'] }), + '100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), + '200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }), + '300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }), + }, + }); + const generatedRundown = processRundown(rundown, {}); + + expect(generatedRundown.order).toMatchObject(['1']); + expect(generatedRundown.totalDuration).toBe(300); + expect(generatedRundown.totalDelay).toBe(0); + expect(generatedRundown.entries).toMatchObject({ + '1': { + type: SupportedEntry.Block, + events: ['100', '200', '300'], + startTime: 100, + endTime: 400, + duration: 300, + isFirstLinked: false, + }, + '100': { type: SupportedEntry.Event, parent: '1' }, + '200': { type: SupportedEntry.Event, parent: '1' }, + '300': { type: SupportedEntry.Event, parent: '1' }, + }); + }); + + it('treats groups as invisible for gap calculations', () => { + const rundown = makeRundown({ + order: ['0', '1', '2', '3'], + entries: { + '0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }), + '1': makeOntimeBlock({ id: '1', events: ['101', '102', '103'] }), + '101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), + '102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), + '103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }), + '2': makeOntimeBlock({ id: '2', events: ['201', '202', '203'] }), + '201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }), + '202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }), + '203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }), + '3': makeOntimeBlock({ id: '3', events: ['301', '302', '303'] }), + '301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }), + '302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }), + '303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }), + }, + }); + const generatedRundown = processRundown(rundown, {}); + + expect(generatedRundown.order).toMatchObject(['0', '1', '2', '3']); + expect(generatedRundown.totalDuration).toBe(1200); + expect(generatedRundown.totalDelay).toBe(0); + expect(generatedRundown.entries).toMatchObject({ + '0': { type: SupportedEntry.Event, parent: null }, + '1': { + type: SupportedEntry.Block, + events: ['101', '102', '103'], + startTime: 100, + endTime: 400, + duration: 300, + isFirstLinked: false, + }, + '101': { parent: '1', gap: 90, linkStart: false }, + '102': { parent: '1' }, + '103': { parent: '1' }, + '2': { + type: SupportedEntry.Block, + events: ['201', '202', '203'], + startTime: 500, + endTime: 800, + duration: 300, + isFirstLinked: false, + }, + '201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false }, + '202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 }, + '203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 }, + '3': { + type: SupportedEntry.Block, + events: ['301', '302', '303'], + startTime: 900, + endTime: 1200, + duration: 300, + isFirstLinked: false, + }, + '301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false }, + '302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 }, + '303': { id: '303', timeStart: 1100, timeEnd: 1200, duration: 100 }, + }); + }); + }); +}); + +describe('rundownMutation.add()', () => { + test('adds an event an empty rundown', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({}); + + rundownMutation.add(rundown, mockEvent, null, null); + + expect(rundown.order.length).toBe(1); + expect(rundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the top if no afterId is given', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1'], + order: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: '1' }), + }, + }); + + rundownMutation.add(rundown, mockEvent, null, null); + + expect(rundown.order).toStrictEqual(['mock', '1']); + expect(rundown.flatOrder).toStrictEqual(['mock', '1']); + expect(rundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the top of the block if no after is given', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1', '1a'], + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1' }), + '1a': makeOntimeEvent({ id: '1a', parent: '1' }), + }, + }); + + rundownMutation.add(rundown, mockEvent, null, '1'); + + expect(rundown.order).toStrictEqual(['1']); + expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']); + expect(rundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the a given location inside a block', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1', '1a'], + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1' }), + '1a': makeOntimeEvent({ id: '1a', parent: '1' }), + }, + }); + + rundownMutation.add(rundown, mockEvent, '1a', '1'); + + expect(rundown.order).toStrictEqual(['1']); + expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); + expect(rundown.entries['mock']).toMatchObject(mockEvent); + }); +}); + +describe('rundownMutation.edit()', () => { + test('edits an event in the rundown', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const mockEventPatch = { id: 'mock', cue: 'patched' }; + const rundown = makeRundown({ + order: ['mock'], + entries: { + mock: mockEvent, + }, + }); + + const { entry, didInvalidate } = rundownMutation.edit(rundown, mockEventPatch); + + expect(rundown.order.length).toBe(1); + expect(didInvalidate).toBeFalsy(); + expect(entry).toMatchObject({ + id: 'mock', + cue: 'patched', + type: SupportedEntry.Event, + }); + }); + + test('changing time fields invalidates the rundown', () => { + const rundown = makeRundown({ + order: ['delay', 'event'], + entries: { + delay: makeOntimeDelay({ id: 'delay' }), + event: makeOntimeEvent({ id: 'event' }), + }, + }); + + expect(rundownMutation.edit(rundown, { id: 'delay', duration: 1000 }).didInvalidate).toBeTruthy(); + + expect(rundownMutation.edit(rundown, { id: 'event', timeStart: 1000 }).didInvalidate).toBeTruthy(); + expect(rundownMutation.edit(rundown, { id: 'event', timeEnd: 1000 }).didInvalidate).toBeTruthy(); + expect(rundownMutation.edit(rundown, { id: 'event', duration: 1000 }).didInvalidate).toBeTruthy(); + expect(rundownMutation.edit(rundown, { id: 'event', linkStart: true }).didInvalidate).toBeTruthy(); + expect(rundownMutation.edit(rundown, { id: 'event', linkStart: false }).didInvalidate).toBeTruthy(); + }); +}); + +describe('rundownMutation.remove()', () => { + it('deletes an event from the rundown', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'mock' }), + '2': makeOntimeEvent({ id: '2', cue: 'mock' }), + '3': makeOntimeEvent({ id: '3', cue: 'mock' }), + }, + }); + + rundownMutation.remove(rundown, rundown.entries['2']); + + expect(rundown.order).toStrictEqual(['1', '3']); + expect(rundown.entries['1']).not.toBeUndefined(); + expect(rundown.entries['2']).toBeUndefined(); + expect(rundown.entries['3']).not.toBeUndefined(); + }); + + it('deletes a block and its children', () => { + const rundown = makeRundown({ + order: ['1', '4'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['2', '3'] }), + '2': makeOntimeEvent({ id: '2', parent: '1' }), + '3': makeOntimeDelay({ id: '3', parent: '1' }), + '4': makeOntimeEvent({ id: '4', parent: null }), + }, + }); + + rundownMutation.remove(rundown, rundown.entries['1']); + + expect(rundown.order).toStrictEqual(['4']); + expect(rundown.entries).not.toHaveProperty('1'); + expect(rundown.entries).not.toHaveProperty('2'); + expect(rundown.entries).not.toHaveProperty('3'); + expect(rundown.entries['4']).toMatchObject({ + parent: null, + }); + }); + + it('deletes a nested event and its reference in the parent', () => { + const rundown = makeRundown({ + order: ['1', '4'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['2', '3'] }), + '2': makeOntimeEvent({ id: '2', parent: '1' }), + '3': makeOntimeDelay({ id: '3', parent: '1' }), + '4': makeOntimeEvent({ id: '4', parent: null }), + }, + }); + + rundownMutation.remove(rundown, rundown.entries['2']); + + expect(rundown.order).toStrictEqual(['1', '4']); + expect(rundown.entries).not.toHaveProperty('2'); + expect(rundown.entries['1']).toMatchObject({ + events: ['3'], + }); + }); +}); + +describe('rundownMutation.removeAll()', () => { + test('deletes all events from the rundown', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'mock' }), + '2': makeOntimeEvent({ id: '2', cue: 'mock' }), + '3': makeOntimeEvent({ id: '3', cue: 'mock' }), + }, + }); + + rundownMutation.removeAll(rundown); + + expect(rundown.order).toStrictEqual([]); + expect(rundown.entries['1']).toBeUndefined(); + expect(rundown.entries['2']).toBeUndefined(); + expect(rundown.entries['3']).toBeUndefined(); + }); +}); + +describe('rundownMutation.reorder()', () => { + it('moves an event into a block', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeBlock({ id: '1', events: [] }), + '2': makeOntimeEvent({ id: '2', parent: null }), + '3': makeOntimeEvent({ id: '3', parent: null }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['3'], rundown.entries['1'], 'insert'); + + expect(rundown.order).toStrictEqual(['1', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: ['3'], + }); + expect(rundown.entries['3']).toMatchObject({ + parent: '1', + }); + }); + + it('adds an event into a block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeEvent({ id: '2', parent: null }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['2'], rundown.entries['11'], 'before'); + + expect(rundown.order).toStrictEqual(['1']); + expect(rundown.entries['1']).toMatchObject({ + events: ['2', '11'], + }); + expect(rundown.entries['2']).toMatchObject({ + parent: '1', + }); + }); + + it('moves an event after another', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), + }, + }); + + // move first event to the end + rundownMutation.reorder(rundown, rundown.entries['1'], rundown.entries['2'], 'after'); + + expect(rundown.order).toStrictEqual(['2', '1', '3']); + }); + + it('moves an event before another', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), + }, + }); + + // move last event to the beginning + rundownMutation.reorder(rundown, rundown.entries['3'], rundown.entries['1'], 'before'); + + expect(rundown.order).toStrictEqual(['3', '1', '2']); + }); + + it('moves an event out of a block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeEvent({ id: '2', parent: null }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['11'], rundown.entries['2'], 'before'); + + expect(rundown.order).toStrictEqual(['1', '11', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['2']).toMatchObject({ + parent: null, + }); + }); + + it('moves an event between blocks', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['11'], rundown.entries['22'], 'before'); + + expect(rundown.order).toStrictEqual(['1', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['2']).toMatchObject({ + events: ['11', '22'], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: '2', + }); + }); + + it('moves an event into an empty block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: [] }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['22'], rundown.entries['1'], 'insert'); + + expect(rundown.order).toStrictEqual(['1', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: ['22'], + }); + expect(rundown.entries['2']).toMatchObject({ + events: [], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: '1', + }); + }); + + it('moves an event out of a block (up)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['22'], rundown.entries['2'], 'before'); + + expect(rundown.order).toStrictEqual(['1', '22', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: ['11'], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: '1', + }); + expect(rundown.entries['2']).toMatchObject({ + events: [], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: null, + }); + }); + + it('moves an event out of a block (down)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + rundownMutation.reorder(rundown, rundown.entries['11'], rundown.entries['1'], 'after'); + + expect(rundown.order).toStrictEqual(['1', '11', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: null, + }); + expect(rundown.entries['2']).toMatchObject({ + events: ['22'], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: '2', + }); + }); +}); + +describe('rundownMutation.applyDelay()', () => { + it('applies a positive delay to the rundown', () => { + const testRundown = makeRundown({ + revision: 0, + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 10 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }), + '3': makeOntimeBlock({ id: '3' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, + '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true }, + '3': { id: '3' }, + '4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false }, + '5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true }, + }); + }); + + it('applies negative delays', () => { + const testRundown = makeRundown({ + revision: 0, + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: -10 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }), + '3': makeOntimeBlock({ id: '3' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, + '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: false }, + '3': { id: '3' }, + '4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false }, + '5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true }, + }); + }); + + it('should account for minimum duration and start when applying negative delays', () => { + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: -50 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + type: SupportedEntry.Event, + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 2, + } as OntimeEvent, + '2': { + id: '2', + type: SupportedEntry.Event, + timeStart: 50, + timeEnd: 100, + duration: 50, + linkStart: false, + revision: 2, + }, + }); + }); + + it('unlinks events to maintain gaps when applying positive delays', () => { + const testRundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + '2': { + id: '2', + timeStart: 150, + timeEnd: 200, + duration: 50, + linkStart: false, + revision: 2, + }, + }); + }); + + it('maintains links if there is no gap', () => { + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 50, + timeEnd: 150, + duration: 100, + revision: 2, + }, + '2': { + id: '2', + timeStart: 150, + timeEnd: 200, + duration: 50, + linkStart: true, + revision: 2, + }, + }); + }); + + it('unlinks events to maintain gaps when applying negative delays', () => { + const testRundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: -50 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }, + '2': { + id: '2', + timeStart: 50, + timeEnd: 100, + duration: 50, + linkStart: false, + revision: 2, + }, + }); + }); + + it('gaps reduce positive delay', () => { + const testRundown = makeRundown({ + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + // gap 50 + '2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), + // gap 0 + '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), + // gap 50 + '4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), + // linked + '5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, + // gap 50 (100 - 50) + '2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, + // gap 50 (50 - 50) + '3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, + // gap (delay is 0) + '4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, + // linked + '5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true }, + }); + }); + + it('gaps reduce positive delay (2)', () => { + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }), + '1': makeOntimeEvent({ + id: '1', + gap: 0, + dayOffset: 0, + timeStart: 46800000, // 13:00:00 + timeEnd: 50400000, // 14:00:00 + duration: MILLIS_PER_HOUR, + }), + // gap 1h + '2': makeOntimeEvent({ + id: '2', + gap: 1 * MILLIS_PER_HOUR, + dayOffset: 0, + timeStart: 54000000, // 15:00:00 + timeEnd: 57600000, // 16:00:00 + duration: MILLIS_PER_HOUR, + }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, + // gap 1h (2h - 1h) + '2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 }, + }); + }); + + it('removes empty delays without applying changes', () => { + const testRundown = makeRundown({ + order: ['delay', '1'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 0 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); + }); + + it('removes delays in last position without applying changes', () => { + const testRundown = makeRundown({ + order: ['1', 'delay'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); + }); + + it('unlinks events to across blocks is it is the first event after the delay', () => { + const testRundown = makeRundown({ + order: ['1', 'delay', 'block', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + block: makeOntimeBlock({ id: 'block' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + block: { id: 'block' }, + '2': { + id: '2', + timeStart: 150, + timeEnd: 200, + duration: 50, + linkStart: false, + revision: 2, + }, + }); + }); + + it('applies a delay from inside a block', () => { + const testRundown = makeRundown({ + order: ['1', 'block', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + block: makeOntimeBlock({ id: 'block', events: ['delay'] }), + delay: makeOntimeDelay({ id: 'delay', duration: 100, parent: 'block' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 200, duration: 100, linkStart: true }), + '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + '2': { + id: '2', + timeStart: 200, + timeEnd: 300, + duration: 100, + linkStart: false, + revision: 2, + }, + '3': { + id: '3', + timeStart: 300, + timeEnd: 400, + duration: 100, + linkStart: true, + revision: 2, + }, + }); + }); + + it('applies a delay from across nested orders', () => { + const testRundown = makeRundown({ + order: ['1', 'delay', 'block', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + block: makeOntimeBlock({ id: 'block', events: ['block-1'] }), + 'block-1': makeOntimeEvent({ + id: 'block-1', + timeStart: 100, + timeEnd: 200, + duration: 100, + linkStart: true, + parent: 'block', + }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), + '3': makeOntimeEvent({ id: '3', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }), + }, + }); + + rundownCache.init(testRundown, {}); + rundownMutation.applyDelay(testRundown, testRundown.entries['delay'] as OntimeDelay); + + expect(testRundown.entries).toMatchObject({ + '1': { + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + 'block-1': { + timeStart: 200, + timeEnd: 300, + duration: 100, + linkStart: false, + revision: 2, + }, + '2': { + timeStart: 300, + timeEnd: 400, + duration: 100, + linkStart: true, + revision: 2, + }, + '3': { + timeStart: 400, + timeEnd: 500, + duration: 100, + linkStart: true, + revision: 2, + }, + }); + }); +}); + +describe('rundownMutation.swap()', () => { + it('should correctly swap data between events', () => { + const testRundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', timeStart: 1 }), + '2': makeOntimeEvent({ id: '2', cue: 'data2', timeStart: 2 }), + '3': makeOntimeEvent({ id: '3', cue: 'data3', timeStart: 3 }), + }, + }); + + rundownMutation.swap(testRundown, testRundown.entries['1'] as OntimeEvent, testRundown.entries['2'] as OntimeEvent); + + expect((testRundown.entries['1'] as OntimeEvent).id).toBe('1'); + expect((testRundown.entries['1'] as OntimeEvent).cue).toBe('data2'); + expect((testRundown.entries['1'] as OntimeEvent).timeStart).toBe(1); + expect((testRundown.entries['1'] as OntimeEvent).revision).toBe(1); + + expect((testRundown.entries['2'] as OntimeEvent).id).toBe('2'); + expect((testRundown.entries['2'] as OntimeEvent).cue).toBe('data1'); + expect((testRundown.entries['2'] as OntimeEvent).timeStart).toBe(2); + expect((testRundown.entries['2'] as OntimeEvent).revision).toBe(1); + + expect((testRundown.entries['3'] as OntimeEvent).id).toBe('3'); + expect((testRundown.entries['3'] as OntimeEvent).cue).toBe('data3'); + expect((testRundown.entries['3'] as OntimeEvent).timeStart).toBe(3); + expect((testRundown.entries['3'] as OntimeEvent).revision).toBe(1); + }); +}); + +describe('rundownMutation.clone()', () => { + it('clones an event and adds it to the rundown', () => { + const testRundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), + }, + }); + + const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1']); + + expect(testRundown.order).toStrictEqual(['1', newEntry.id]); + expect(testRundown.entries[newEntry.id]).toMatchObject({ + type: SupportedEntry.Event, + cue: 'data1', + parent: null, + revision: 0, + }); + }); + + it('clones an event inside a block and adds it to the rundown', () => { + const testRundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['1a'] }), + '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), + }, + }); + + const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1a']); + + expect(testRundown.order).toStrictEqual(['1']); + expect(testRundown.entries['1']).toMatchObject({ events: ['1a', newEntry.id] }); + expect(testRundown.entries[newEntry.id]).toMatchObject({ + type: SupportedEntry.Event, + parent: '1', + cue: 'nested', + }); + }); + + it('clones a block and its nested elements', () => { + const testRundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1', title: 'top', events: ['1a'] }), + '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), + }, + }); + + const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1']); + + expect(testRundown.order).toStrictEqual(['1', newEntry.id]); + expect(testRundown.entries[newEntry.id]).toMatchObject({ + type: SupportedEntry.Block, + events: [expect.any(String)], + }); + expect((testRundown.entries[newEntry.id] as OntimeBlock).events[0]).not.toBe('1a'); + }); +}); + +describe('rundownMutation.group()', () => { + it('groups a list of existing events into a new block', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', parent: null }), + '2': makeOntimeEvent({ id: '2', parent: null }), + '3': makeOntimeEvent({ id: '3', parent: null }), + }, + }); + + rundownMutation.group(rundown, ['1', '2']); + + const blockId = rundown.order[0]; + expect(blockId).toStrictEqual(expect.any(String)); + expect(rundown.order).toStrictEqual([expect.any(String), '3']); + expect(rundown.entries).toMatchObject({ + [blockId]: { + type: SupportedEntry.Block, + events: ['1', '2'], + }, + '1': { id: '1', type: SupportedEntry.Event, parent: blockId }, + '2': { id: '2', type: SupportedEntry.Event, parent: blockId }, + '3': { id: '3', type: SupportedEntry.Event, parent: null }, + }); + }); +}); + +describe('rundownMutation.ungroup()', () => { + it('should correctly dissolve a block into its events', () => { + const testRundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), + '2': makeOntimeBlock({ id: '2', events: ['21', '22'] }), + '21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }), + '22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }), + }, + }); + + rundownMutation.ungroup(testRundown, testRundown.entries['2'] as OntimeBlock); + + expect(testRundown.order).toStrictEqual(['1', '21', '22']); + expect(testRundown.entries['2']).toBeUndefined(); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', type: SupportedEntry.Event, cue: 'data1', parent: null }, + '21': { id: '21', type: SupportedEntry.Event, cue: 'data21', parent: null }, + '22': { id: '22', type: SupportedEntry.Event, cue: 'data22', parent: null }, + }); + }); +}); diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts index d6e2272c5..abb672e34 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts @@ -1,7 +1,7 @@ import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types'; import { defaultRundown } from '../../../models/dataModel.js'; -import { makeOntimeBlock, makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js'; +import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; import { parseRundowns, parseRundown } from '../rundown.parser.js'; diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index 6ebd7e2ef..fa2d59cbf 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -1,6 +1,8 @@ +import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types'; + import { assertType } from 'vitest'; -import { createEvent } from '../rundown.utils.js'; +import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js'; describe('test event validator', () => { it('validates a good object', () => { @@ -78,3 +80,79 @@ describe('test event validator', () => { expect(typeof validated.title).toEqual('string'); }); }); + +describe('doesInvalidateMetadata()', () => { + it('is stale if data contains timers', () => { + const needsRecompute = [ + { timeStart: 10 }, + { timeEnd: 10 }, + { duration: 10 }, + { linkStart: true }, + { timerStrategy: TimeStrategy.LockDuration }, + ]; + + for (const testCase of needsRecompute) { + expect(doesInvalidateMetadata(testCase)).toBe(true); + } + expect.assertions(needsRecompute.length); + }); + + it('is not stale if data contains auxiliary dataset', () => { + expect( + doesInvalidateMetadata({ + cue: 'cue', + title: 'title', + note: 'note', + endAction: EndAction.LoadNext, + timerType: TimerType.Clock, + isPublic: false, + colour: 'colour', + timeWarning: 1, + timeDanger: 2, + custom: { + lighting: '3', + }, + }), + ).toBe(false); + }); +}); + +describe('hasChanges()', () => { + it('identifies objects with new values', () => { + const newEvent = { id: '1', title: 'new-title' } as OntimeEvent; + const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent; + expect(hasChanges(existing, newEvent)).toBe(true); + }); + it('identifies objects with all same values', () => { + const newEvent = { id: '1', title: 'title' } as OntimeEvent; + const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent; + expect(hasChanges(existing, newEvent)).toBe(false); + }); +}); + +describe('deleteById', () => { + it('should delete the first instance of the specified ID from the array', () => { + const array = ['id1', 'id2', 'id3', 'id4']; + const result = deleteById(array, 'id2'); + expect(result).toStrictEqual(['id1', 'id3', 'id4']); + expect(result).not.toBe(array); // Ensure a new array is returned + }); + + it('should not modify the array if the specified ID does not exist', () => { + const array = ['id1', 'id2', 'id3', 'id4']; + const result = deleteById(array, 'id5'); + expect(result).toStrictEqual(['id1', 'id2', 'id3', 'id4']); + }); + + it('should return the same array if it is empty', () => { + const array: string[] = []; + const result = deleteById(array, 'id1'); + expect(result).toStrictEqual([]); + }); + + it('should handle scenarios where the delete id is not found', () => { + const array = ['id1', 'id2', 'id3']; + const result = deleteById(array, 'id4'); + expect(result).toStrictEqual(['id1', 'id2', 'id3']); + }); +}); diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts deleted file mode 100644 index 0f173d385..000000000 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; -import { getErrorMessage } from 'ontime-utils'; - -import type { Request, Response } from 'express'; - -import { failEmptyObjects } from '../../utils/routerUtils.js'; -import { - addEvent, - applyDelay, - batchEditEvents, - deleteAllEntries, - deleteEvent, - editEvent, - ungroupEntries, - groupEntries, - swapEvents, - cloneEntry, -} from '../../services/rundown-service/RundownService.js'; -import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; - -export async function rundownGetAll(_req: Request, res: Response) { - const rundown = getCurrentRundown(); - res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]); -} - -export async function rundownGetCurrent(_req: Request, res: Response) { - const cachedRundown = getCurrentRundown(); - res.json(cachedRundown); -} - -export async function rundownGetById(req: Request, res: Response) { - const { eventId } = req.params; - - try { - const event = getEntryWithId(eventId); - - if (!event) { - res.status(404).send({ message: 'Event not found' }); - return; - } - res.status(200).json(event); - } catch (error) { - const message = getErrorMessage(error); - res.status(500).json({ message }); - } -} - -export async function rundownPost(req: Request, res: Response) { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const newEvent = await addEvent(req.body); - res.status(201).send(newEvent); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownPut(req: Request, res: Response) { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const event = await editEvent(req.body); - res.status(200).send(event); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownBatchPut(req: Request, res: Response) { - if (failEmptyObjects(req.body, res)) { - return res.status(404); - } - - try { - const { data, ids } = req.body; - await batchEditEvents(ids, data); - res.status(200).send({ message: 'Batch edit successful' }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownSwap(req: Request, res: Response) { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const { from, to } = req.body; - await swapEvents(from, to); - res.status(200).send({ message: 'Swap successful' }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownApplyDelay(req: Request, res: Response) { - try { - await applyDelay(req.params.entryId); - res.status(200).send({ message: 'Delay applied' }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownCloneEntry(req: Request, res: Response) { - try { - const newRundown = await cloneEntry(req.params.entryId); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownUngroupEntries(req: Request, res: Response) { - try { - const newRundown = await ungroupEntries(req.params.entryId); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownAddToBlock(req: Request, res: Response) { - try { - const newRundown = await groupEntries(req.body.ids); - res.status(200).send(newRundown); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function rundownDelete(_req: Request, res: Response) { - try { - await deleteAllEntries(); - res.status(204).send({ message: 'All events deleted' }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} - -export async function deletesEventById(req: Request, res: Response) { - try { - await deleteEvent(req.body.ids); - res.status(204).send({ message: 'Events deleted' }); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts new file mode 100644 index 000000000..c279480dc --- /dev/null +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -0,0 +1,598 @@ +/** + * This module handles interfacing with the stored rundown + * Additionally it provides a transaction-like interface on a caching layer + * + * The mutation functions mutate the rundown in place + * This is to simplify the logic and avoid multiple copies of the objects + * + * The mutations assume that the data has been validated + * - in shape + * - in domain + */ + +import { + CustomFields, + EntryId, + isOntimeBlock, + isOntimeEvent, + isPlayableEvent, + OntimeBlock, + OntimeDelay, + OntimeEntry, + OntimeEvent, + PatchWithId, + Rundown, +} from 'ontime-types'; +import { insertAtIndex } from 'ontime-utils'; + +import { makeRundownMetadata, ProcessedRundownMetadata } from '../../services/rundown-service/rundownCache.utils.js'; +import { customFieldChangelog } from '../../services/rundown-service/rundownCache.js'; +import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; + +import type { RundownMetadata } from './rundown.types.js'; +import { + applyPatchToEntry, + cloneBlock, + cloneEntry, + createBlock, + deleteById, + doesInvalidateMetadata, + getUniqueId, +} from './rundown.utils.js'; + +/** + * The currently loaded rundown in cache + */ +const cachedRundown: Rundown = { + id: '', + title: '', + order: [], + flatOrder: [], // TODO: remove in favour of the metadata flatEntryOrder + entries: {}, + revision: 0, +}; + +let rundownMetadata: RundownMetadata = { + totalDelay: 0, + totalDuration: 0, + totalDays: 0, + firstStart: null, + lastEnd: null, + + playableEventOrder: [], + timedEventOrder: [], + flatEntryOrder: [], + + assignedCustomFields: {}, +}; + +/** + * The custom fields that are used in the project + * Not unique to the loaded rundown + */ +let projectCustomFields: CustomFields = {}; + +export const getCurrentRundown = (): Readonly => cachedRundown; +export const getProjectCustomFields = (): Readonly => projectCustomFields; + +export function createTransaction() { + const rundown = structuredClone(cachedRundown); + const customFields = projectCustomFields; + + function commit(shouldProcess: boolean = true) { + // schedule a database update + setImmediate(async () => { + await getDataProvider().setRundown(cachedRundown.id, cachedRundown); + }); + + const revision = rundown.revision + 1; + cachedRundown.revision = revision; + + /** + * Some mutations do not require processing the rundown + * We simply increment the revision and return the rundown + */ + if (!shouldProcess) { + cachedRundown.entries = rundown.entries; + cachedRundown.order = rundown.order; + cachedRundown.flatOrder = rundown.flatOrder; + return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision }; + } + + const processedData = processRundown(rundown, projectCustomFields); + // update the cache values + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data + const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; + cachedRundown.entries = entries; + cachedRundown.order = order; + cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder + rundownMetadata = metadata; + + return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision }; + } + + return { + customFields, + rundown, + commit, + }; +} + +/** + * Add entry to rundown, handles the following cases: + * - 1a. add entry in block, after a given entry + * - 1b. add entry in block, at the beginning + * - 2a. add entry to the rundown, after a given entry + * - 2b. add entry to the rundown, at the beginning + */ +function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parentId: EntryId | null): OntimeEntry { + if (parentId) { + // 1. inserting an entry inside a block + const parentBlock = rundown.entries[parentId] as OntimeBlock; + if (afterId) { + const atEventsIndex = parentBlock.events.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events); + const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1; + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } + } else { + // 2. inserting an entry at top level + if (afterId) { + const atOrderIndex = rundown.order.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + rundown.order = insertAtIndex(0, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder); + } + } + + // either way, we insert the entry into the rundown + rundown.entries[entry.id] = entry; + + return entry; +} + +/** + * Applies a patch of changes to an existing entry + * @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache + */ +function edit(rundown: Rundown, patch: PatchWithId): { entry: OntimeEntry; didInvalidate: boolean } { + const entry = rundown.entries[patch.id]; + + // apply the patch and replace the entry + const newEntry = applyPatchToEntry(entry, patch); + rundown.entries[entry.id] = newEntry; + + // check whether the data warrants recalculation of cache + const didInvalidate = doesInvalidateMetadata(patch); + + return { entry: newEntry, didInvalidate }; +} + +/** + * Deletes an entry from the rundown + * - if the entry is an ontime block, we delete it along with its children + * - if the entry is inside a block, we delete it and remove the reference from the parent block + */ +function remove(rundown: Rundown, entry: OntimeEntry) { + if (isOntimeBlock(entry)) { + // for ontime blocks, we need to iterate through the children and delete them + for (let i = 0; i < entry.events.length; i++) { + const nestedEntryId = entry.events[i]; + deleteEntry(nestedEntryId); + } + } else if (entry.parent) { + // at this point, we are handling entries inside a block, so we need to remove the reference + const parentBlock = rundown.entries[entry.parent] as OntimeBlock; + if (parentBlock) { + // we call a mutation to the parent event to remove the entry from the events + const filteredEvents = deleteById(parentBlock.events, entry.id); + edit(rundown, { id: parentBlock.id, events: filteredEvents }); + } + } + deleteEntry(entry.id); + + function deleteEntry(idToDelete: EntryId) { + rundown.order = deleteById(rundown.order, idToDelete); + delete rundown.entries[idToDelete]; + } +} + +/** + * Removes all entries from the rundown + */ +function removeAll(rundown: Rundown): Rundown { + rundown.order = []; + rundown.flatOrder = []; + rundown.entries = {}; + + return rundown; +} + +/** + * Reorders an entry in the rundown + * Handle moving across order lists + */ +function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, order: 'before' | 'after' | 'insert') { + // handle moving across parents + const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null; + const toParent = (() => { + if (isOntimeBlock(eventTo)) { + if (order === 'insert') { + return eventTo.id; + } + return null; + } + return eventTo.parent ?? null; + })(); + + if (!isOntimeBlock(eventFrom)) { + eventFrom.parent = toParent; + } + + const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events; + const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events; + + const fromIndex = sourceArray.indexOf(eventFrom.id); + const toIndex = (() => { + const baseIndex = destinationArray.indexOf(eventTo.id); + if (order === 'before') return baseIndex; + // only add one if we are moving down + if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1); + // for insert we add in the end of the array + return destinationArray.length; + })(); + + // Remove from source array + sourceArray.splice(fromIndex, 1); + + // Insert into destination array + destinationArray.splice(toIndex, 0, eventFrom.id); +} + +/** + * Applies delay from given event ID + * Mutates the given rundown + */ +function applyDelay(rundown: Rundown, delay: OntimeDelay) { + const delayIndex = rundownMetadata.flatEntryOrder.indexOf(delay.id); + + // if the delay is empty, or the last element + // there is nothing do apply + if (delay.duration === 0 || delayIndex === rundown.order.length - 1) { + return; + } + + /** + * We iterate through the rundown and apply the delay + * The delay values becomes part of the event schedule + * The delay is applied as if the rundown was flat + */ + let delayValue = delay.duration; + let lastEntry: OntimeEvent | null = null; + let isFirstEvent = true; + + for (let i = delayIndex + 1; i < rundownMetadata.flatEntryOrder.length; i++) { + const currentId = rundownMetadata.flatEntryOrder[i]; + const currentEntry = rundown.entries[currentId]; + + // we don't do operation on other event types + if (!isOntimeEvent(currentEntry)) { + continue; + } + + // we need to remove the link in the first event to maintain the gap + let shouldUnlink = isFirstEvent; + isFirstEvent = false; + + // if the event is not linked, we try and maintain gaps + if (lastEntry !== null) { + // when applying negative delays, we need to unlink the event + // if the previous event was fully consumed by the delay + if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) { + shouldUnlink = true; + } + + if (currentEntry.gap > 0) { + delayValue = Math.max(delayValue - currentEntry.gap, 0); + } + + if (delayValue === 0) { + // we can bail from continuing if there are no further delays to apply + break; + } + } + + // save the current entry before making mutations on its values + lastEntry = { ...currentEntry }; + + if (shouldUnlink) { + currentEntry.linkStart = false; + shouldUnlink = false; + } + + // event times move up by the delay value + // we dont update the delay value since we would need to iterate through the entire dataset + // this is handled by the rundownCache.generate function + currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue); + currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue); + currentEntry.revision += 1; + } +} + +/** + * Swaps the data between two events + * The schedule and metadata are preserved + * TODO: this logic is for now duplcate of Ontime-Utils.swapEventData + */ +function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) { + rundown.entries[eventFrom.id] = { + ...eventTo, + // events keep the ID + id: eventFrom.id, + // events keep the schedule + timeStart: eventFrom.timeStart, + timeEnd: eventFrom.timeEnd, + duration: eventFrom.duration, + linkStart: eventFrom.linkStart, + parent: eventFrom.parent, + // keep schedule metadata + delay: eventFrom.delay, + gap: eventFrom.gap, + dayOffset: eventFrom.dayOffset, + // keep revision number but increment it + revision: eventFrom.revision++, + }; + + rundown.entries[eventTo.id] = { + ...eventFrom, + // events keep the ID + id: eventTo.id, + // events keep the schedule + timeStart: eventTo.timeStart, + timeEnd: eventTo.timeEnd, + duration: eventTo.duration, + linkStart: eventTo.linkStart, + parent: eventTo.parent, + // keep schedule metadata + delay: eventTo.delay, + gap: eventTo.gap, + dayOffset: eventTo.dayOffset, + // keep revision number but increment it + revision: eventTo.revision++, + }; +} + +/** + * Inserts a clone of the given entry into the rundown + * Handles cloning children if the entry is a block + */ +function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry { + if (isOntimeBlock(entry)) { + const newBlock = cloneBlock(entry, getUniqueId(rundown)); + const nestedIds: EntryId[] = []; + + for (let i = 0; i < entry.events.length; i++) { + const nestedEntryId = entry.events[i]; + const nestedEntry = rundown.entries[nestedEntryId]; + if (!nestedEntry) { + continue; + } + + // clone the event and assign it to the new block + const newNestedEntry = cloneEntry(nestedEntry, getUniqueId(rundown)); + (newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id; + + nestedIds.push(newNestedEntry.id); + // we immediately insert the nested entries into the rundown + rundown.entries[newNestedEntry.id] = newNestedEntry; + } + + // indexes + 1 since we are inserting after the cloned block + const atIndex = rundown.order.indexOf(entry.id) + 1; + + newBlock.events = nestedIds; + newBlock.title = `${entry.title || 'Untitled'} (copy)`; + + rundown.entries[newBlock.id] = newBlock; + rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order); + + return newBlock; + } else { + return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, entry.parent); + } +} + +/** + * Groups a list of entries into a block + * It ensures that the entries get reassigned parent and the block gets a list of events + * The block will be created at the index of the first event in the order, not at the lowest index + * Mutates the given rundown + */ +function group(rundown: Rundown, entryIds: EntryId[]): OntimeBlock { + const newBlock = createBlock({ id: getUniqueId(rundown) }); + + const nestedEvents: EntryId[] = []; + let firstIndex = -1; + for (let i = 0; i < entryIds.length; i++) { + const entryId = entryIds[i]; + const entry = rundown.entries[entryId]; + if (!entry || isOntimeBlock(entry)) { + // invalid operation, we skip this entry + continue; + } + + // the block will be created at the first selected event position + // note that this is not the lowest index + if (firstIndex === -1) { + firstIndex = rundown.flatOrder.indexOf(entryId); + } + + nestedEvents.push(entryId); + entry.parent = newBlock.id; + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); + rundown.order = rundown.order.filter((id) => id !== entryId); + } + + newBlock.events = nestedEvents; + const insertIndex = Math.max(0, firstIndex); + // we have filtered the items from the order + // we will insert them now, with only the block at top level ... + rundown.order = insertAtIndex(insertIndex, newBlock.id, rundown.order); + rundown.entries[newBlock.id] = newBlock; + + return newBlock; +} + +/** + * Deletes a block and moves all its children to the top level order + */ +function ungroup(rundown: Rundown, block: OntimeBlock) { + // get the events from the block and merge them into the order where the block was + const nestedEvents = block.events; + const blockIndex = rundown.order.indexOf(block.id); + rundown.order.splice(blockIndex, 1, ...nestedEvents); + + // delete block from entries and remove its reference from the child events + delete rundown.entries[block.id]; + for (let i = 0; i < nestedEvents.length; i++) { + const eventId = nestedEvents[i]; + const entry = rundown.entries[eventId]; + if (!entry) { + throw new Error('Entry not found'); + } + (entry as OntimeEvent | OntimeDelay).parent = null; + } +} + +export const rundownMutation = { + add, + edit, + remove, + removeAll, + reorder, + applyDelay, + swap, + clone, + group, + ungroup, +}; + +/** + * Expose function to add an initial rundown to the system + */ +export function init(initialRundown: Readonly, initialCustomFields: Readonly) { + const rundown = structuredClone(initialRundown); + const customFields = structuredClone(initialCustomFields); + const processedData = processRundown(rundown, customFields); + + // update the cache values + cachedRundown.id = rundown.id; + cachedRundown.title = rundown.title; + projectCustomFields = customFields; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data + const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; + cachedRundown.entries = entries; + cachedRundown.order = order; + cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder + cachedRundown.revision = rundown.revision; + rundownMetadata = metadata; + + // defer writing to the database + setImmediate(async () => { + await getDataProvider().setRundown(cachedRundown.id, cachedRundown); + }); + + return { rundown, rundownMetadata, customFields, revision: rundown.revision }; +} + +export const rundownCache = { + init, + get: () => { + return { + rundown: cachedRundown, + metadata: rundownMetadata, + customFields: projectCustomFields, + }; + }, +}; + +/** + * Utility updates cache after a mutation + * Handles calculating the rundown metadata + * @private should not be called outside of `rundown.dao.ts`, exported for testing + */ +export function processRundown( + initialRundown: Readonly, + customFields: Readonly, +): ProcessedRundownMetadata { + const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog); + + for (let i = 0; i < initialRundown.order.length; i++) { + // we assign a reference to the current entry, this will be mutated in place + const currentEntryId = initialRundown.order[i]; + const currentEntry = initialRundown.entries[currentEntryId]; + if (!currentEntry) { + continue; + } + const { processedEntry } = process(currentEntry, null); + + // if the event is a block, we process the nested entries + // the code here is a copy of the processing of top level events + if (isOntimeBlock(processedEntry)) { + let totalBlockDuration = 0; + let blockStartTime = null; + let blockEndTime = null; + let isFirstLinked = false; + const blockEvents: EntryId[] = []; + + // check if the block contains nested entries + for (let j = 0; j < processedEntry.events.length; j++) { + const nestedEntryId = processedEntry.events[j]; + const nestedEntry = initialRundown.entries[nestedEntryId]; + + if (!nestedEntry) { + continue; + } + + blockEvents.push(nestedEntry.id); + const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process( + nestedEntry, + processedEntry.id, + ); + + // we dont extract metadata of skipped events, + // if this is not a playable event there is nothing else to do + if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) { + continue; + } + + // first start is always the first event + if (blockStartTime === null) { + blockStartTime = processedNestedEntry.timeStart; + isFirstLinked = Boolean(processedNestedEntry.linkStart); + } + + // lastEntry is the event with the latest end time + blockEndTime = processedNestedData.lastEnd; + totalBlockDuration += processedNestedEntry.duration; + } + + // update block metadata + processedEntry.duration = totalBlockDuration; + processedEntry.startTime = blockStartTime; + processedEntry.endTime = blockEndTime; + processedEntry.isFirstLinked = isFirstLinked; + processedEntry.events = blockEvents; + } + } + + return getMetadata(); +} diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index e1c4451c5..dcda09708 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -1,26 +1,23 @@ -import { ErrorResponse, Rundown } from 'ontime-types'; +import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; import express from 'express'; -import { reorderEntry } from '../../services/rundown-service/RundownService.js'; - +import { getCurrentRundown } from './rundown.dao.js'; import { - deletesEventById, - rundownAddToBlock, - rundownApplyDelay, - rundownBatchPut, - rundownCloneEntry, - rundownDelete, - rundownUngroupEntries, - rundownGetAll, - rundownGetById, - rundownGetCurrent, - rundownPost, - rundownPut, - rundownSwap, -} from './rundown.controller.js'; + addEntry, + applyDelay, + batchEditEntries, + cloneEntry, + deleteAllEntries, + deleteEntries, + editEntry, + groupEntries, + reorderEntry, + swapEvents, + ungroupEntries, +} from './rundown.service.js'; import { paramsMustHaveEntryId, rundownArrayOfIds, @@ -33,14 +30,53 @@ import { export const router = express.Router(); -router.get('/', rundownGetAll); -router.get('/current', rundownGetCurrent); -router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend +/** + * Returns all rundowns in the project + */ +router.get('/', async (_req: Request, res: Response) => { + const rundown = getCurrentRundown(); -router.post('/', rundownPostValidator, rundownPost); + // TODO: we currently make a project with only the current rundown + res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]); +}); -router.put('/', rundownPutValidator, rundownPut); -router.put('/batch', rundownBatchPutValidator, rundownBatchPut); +/** + * Returns the current rundown + */ +router.get('/current', async (_req: Request, res: Response) => { + const rundown = getCurrentRundown(); + res.json(rundown); +}); + +router.post('/', rundownPostValidator, async (req: Request, res: Response) => { + try { + const newEvent = await addEntry(req.body); + res.status(201).send(newEvent); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.put('/', rundownPutValidator, async (req: Request, res: Response) => { + try { + const event = await editEntry(req.body); + res.status(200).send(event); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response) => { + try { + const rundown = await batchEditEntries(req.body.ids, req.body.data); + res.status(200).send(rundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response) => { try { @@ -52,11 +88,81 @@ router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Resp res.status(400).send({ message }); } }); -router.patch('/swap', rundownSwapValidator, rundownSwap); -router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay); -router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry); -router.post('/ungroup/:entryId', paramsMustHaveEntryId, rundownUngroupEntries); -router.post('/group', rundownArrayOfIds, rundownAddToBlock); -router.delete('/', rundownArrayOfIds, deletesEventById); -router.delete('/all', rundownDelete); +router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response) => { + try { + const rundown = await swapEvents(req.body.from, req.body.to); + res.status(200).send(rundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.patch( + '/applydelay/:entryId', + paramsMustHaveEntryId, + async (req: Request, res: Response) => { + try { + const newRundown = await applyDelay(req.params.entryId); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } + }, +); + +router.post('/clone/:entryId', paramsMustHaveEntryId, async (req: Request, res: Response) => { + try { + const newRundown = await cloneEntry(req.params.entryId); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.post('/group', rundownArrayOfIds, async (req: Request, res: Response) => { + try { + const newRundown = await groupEntries(req.body.ids); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.post( + '/ungroup/:entryId', + paramsMustHaveEntryId, + async (req: Request, res: Response) => { + try { + const newRundown = await ungroupEntries(req.params.entryId); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } + }, +); + +router.delete('/', rundownArrayOfIds, async (req: Request, res: Response) => { + try { + await deleteEntries(req.body.ids); + res.status(204).send({ message: 'Events deleted' }); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.delete('/all', async (_req: Request, res: Response) => { + try { + const rundown = await deleteAllEntries(); + res.status(204).send(rundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts new file mode 100644 index 000000000..f40ba1c82 --- /dev/null +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -0,0 +1,475 @@ +import { + CustomFields, + EntryId, + EventPostPayload, + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + OntimeEntry, + PatchWithId, + Rundown, +} from 'ontime-types'; + +import { getPreviousId } from '../../services/rundown-service/rundownUtils.js'; +import { updateRundownData } from '../../stores/runtimeState.js'; +import { sendRefetch } from '../../adapters/websocketAux.js'; +import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; + +import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js'; +import { RundownMetadata } from './rundown.types.js'; +import { generateEvent, hasChanges } from './rundown.utils.js'; + +/** + * creates a new entry with given data + */ +export async function addEntry(eventData: EventPostPayload): Promise { + const { rundown, commit } = createTransaction(); + + // we allow the user to provide an ID, but make sure it is unique + if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) { + throw new Error(`Event with ID ${eventData.id} already exists`); + } + + // if the user provides a parent (inside a group), we make sure it exists and it is a group + let parent: EntryId | null = null; + if ('parent' in eventData && eventData.parent != null) { + const maybeParent = rundown.entries[eventData.parent]; + if (!maybeParent || !isOntimeBlock(maybeParent)) { + throw new Error(`Invalid parent event with ID ${eventData.parent}`); + } + parent = eventData.parent; + } + + // normalise the position of the event in the rundown order + const afterId = getPreviousId(rundown, eventData?.after, eventData?.before); + + // generate a fully formed entry from the patch + const newEntry = generateEvent(rundown, eventData, afterId); + + // make mutations to rundown + rundownMutation.add(rundown, newEntry, afterId, parent); + const { rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true }); + }); + + return newEntry; +} + +/** + * Applies a patch to an entry in the rundown + */ +export async function editEntry(patch: PatchWithId): Promise { + const { rundown, commit } = createTransaction(); + const currentEntry = rundown.entries[patch.id]; + + /** + * We validate the patch before applying it + * - disallow edit an entry that does not exist + * - disallow setting the cue to empty string + * - disallow change the type of an entry + */ + + // could the entry have been deleted? + if (!currentEntry) { + throw new Error('Entry not found'); + } + + // we dont allow the user to change the cue to empty string + if (isOntimeEvent(patch) && patch?.cue === '') { + throw new Error('Cue value invalid'); + } + + // we cannot allow patching to a different type + if (patch?.type && currentEntry.type !== patch.type) { + throw new Error('Invalid event type'); + } + + // if nothing changed, nothing to do + if (!hasChanges(currentEntry, patch)) { + return currentEntry; + } + + const { entry, didInvalidate } = rundownMutation.edit(rundown, patch); + const { rundownMetadata, revision } = commit(didInvalidate); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: [entry.id], external: true }); + }); + + return entry; +} + +/** + * Applies a patch to several entries in the rundown + */ +export async function batchEditEntries(ids: EntryId[], patch: Partial): Promise { + const { rundown, commit } = createTransaction(); + + /** + * We can do some validation globally, but mostly we will validate each entry individually + * - disallow setting the cue to empty string + */ + if ('cue' in patch && patch.cue === '') { + throw new Error('Cue value invalid'); + } + + let batchDidInvalidate = false; + const changedIds: EntryId[] = []; + const patchedEntries: OntimeEntry[] = []; + for (let i = 0; i < ids.length; i++) { + const currentId = ids[i]; + const currentEntry = rundown.entries[currentId]; + /** + * Most of the validation needs to be done in regard to the change + * - cannot edit an entry that does not exist + * - disallow change the type of an entry + * - disallow change the ID of an entry + */ + // could the entry have been deleted? + if (!currentEntry) { + continue; + } + + // we cannot allow patching to a different type + if (patch?.type && currentEntry.type !== patch.type) { + throw new Error('Invalid event type'); + } + + // if nothing changed, nothing to do + if (!hasChanges(currentEntry, patch)) { + continue; + } + + const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId }); + + changedIds.push(currentId); + patchedEntries.push(entry); + + if (didInvalidate) { + batchDidInvalidate = true; + } + } + const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: changedIds, external: true }); + }); + + return rundownResult; +} + +/** + * Deletes a known entry from the current rundown + */ +export async function deleteEntries(entryIds: EntryId[]): Promise { + const { rundown, commit } = createTransaction(); + + for (let i = 0; i < entryIds.length; i++) { + const entry = rundown.entries[entryIds[i]]; + if (!entry) { + continue; + } + rundownMutation.remove(rundown, entry); + } + + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true }); + }); + + return rundownResult; +} + +/** + * Deletes all entries from the current rundown + */ +export async function deleteAllEntries(): Promise { + const { rundown, commit } = createTransaction(); + + rundownMutation.removeAll(rundown); + + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: true, external: true }); + }); + + return rundownResult; +} + +/** + * Moves an event to a new position in the rundown + * Handles moving across root orders (a block order and top level order) + * @throws if entryId or destinationId not found + */ +export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') { + const { rundown, commit } = createTransaction(); + + // check that both entries exist + const eventFrom = rundown.entries[entryId]; + const eventTo = rundown.entries[destinationId]; + + if (!eventFrom || !eventTo) { + throw new Error('Event not found'); + } + + rundownMutation.reorder(rundown, eventFrom, eventTo, order); + + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: true, external: true }); + }); + + return rundownResult; +} + +/** + * Applies a delay into the rundown effectively changing the schedule + * The applied delay is deleted + */ +export async function applyDelay(delayId: EntryId): Promise { + const { rundown, commit } = createTransaction(); + + // check that delay exists + const delay = rundown.entries[delayId]; + if (!delay || !isOntimeDelay(delay)) { + throw new Error('Given delay ID not found'); + } + + // apply the delay and delete the it + rundownMutation.applyDelay(rundown, delay); + rundownMutation.remove(rundown, delay); + + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: true, external: true }); + }); + + return rundownResult; +} + +/** + * Swaps the data between two events in the rundown + */ +export async function swapEvents(fromId: EntryId, toId: EntryId): Promise { + const { rundown, commit } = createTransaction(); + const eventFrom = rundown.entries[fromId]; + const eventTo = rundown.entries[toId]; + + // check that both entries exist + if (!eventFrom || !eventTo) { + throw new Error('Event not found'); + } + + // we can only swap events + if (!isOntimeEvent(eventFrom) || !isOntimeEvent(eventTo)) { + throw new Error('Both entries must be events'); + } + + rundownMutation.swap(rundown, eventFrom, eventTo); + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + notifyChanges(rundownMetadata, revision, { timer: true, external: true }); + }); + + return rundownResult; +} + +/** + * Clones an entry, ensuring that all dependencies are preserved + * @throws if the entry to clone does not exist + */ +export async function cloneEntry(entryId: EntryId): Promise { + const { rundown, commit } = createTransaction(); + const originalEntry = rundown.entries[entryId]; + + if (!originalEntry) { + throw new Error('Did not find event to clone'); + } + + const newEntry = rundownMutation.clone(rundown, originalEntry); + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer and external services of change + if (isOntimeBlock(newEntry)) { + notifyChanges(rundownMetadata, revision, { timer: newEntry.events, external: true }); + } else if (isOntimeEvent(newEntry)) { + notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true }); + } else if (isOntimeDelay(newEntry)) { + notifyChanges(rundownMetadata, revision, { external: true }); + } + notifyChanges(rundownMetadata, revision, { timer: true, external: true }); + }); + + return rundownResult; +} + +/** + * Groups a list of entries into a new block + */ +export async function groupEntries(entryIds: EntryId[]): Promise { + const { rundown, commit } = createTransaction(); + + rundownMutation.group(rundown, entryIds); + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // we dont need to notify the timer since the grouping does not affect the runtime + notifyChanges(rundownMetadata, revision, { external: true }); + }); + + return rundownResult; +} + +/** + * Deletes a block and moves all its children to the top level + */ +export async function ungroupEntries(blockId: EntryId): Promise { + const { rundown, commit } = createTransaction(); + + const block = rundown.entries[blockId]; + if (!block || !isOntimeBlock(block)) { + throw new Error(`Block with ID ${blockId} not found or is not a block`); + } + + rundownMutation.ungroup(rundown, block); + const { rundown: rundownResult, rundownMetadata, revision } = commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // we dont need to notify the timer since the grouping does not affect the runtime + notifyChanges(rundownMetadata, revision, { external: true }); + }); + + return rundownResult; +} + +/** + * Forces update in the store + * Called when we make changes to the rundown object + * + * @private - exported for testing + */ +export function updateRuntimeOnChange(rundownMetadata: RundownMetadata) { + // we only declare the amount of playable events + const numEvents = rundownMetadata.timedEventOrder.length; + + // schedule an update for the end of the event loop + updateRundownData({ + numEvents, + ...rundownMetadata, + }); +} + +type NotifyChangesOptions = { + timer?: boolean | string[]; // whether to notify the timer, could be a yes / no or an array of affected IDs + external?: boolean; // whether to notify external services + reload?: boolean; // major change, clients should consider refetching everything +}; + +/** + * Notify services of changes in the rundown + * + * @private - exported for testing + */ +export function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) { + // notify timer service of changed events + if (options.timer) { + // all events were deleted + if (rundownMetadata.playableEventOrder.length === 0) { + runtimeService.stop(); + } else { + /** + * Timer can be + * - true: all events changed + * - an array of changed IDs + * - undefined: filtered above, no notification intended + */ + // timer can be true or an array of changed IDs + const affected = Array.isArray(options.timer) ? options.timer : undefined; + runtimeService.notifyOfChangedEvents(affected); + } + } + + // notify external services of changes + if (options.external) { + const payload = { + target: 'RUNDOWN', + reload: options.reload, + revision, + }; + sendRefetch(payload); + } +} + +/** + * Sets a new rundown in the cache + * and marks it as the currently loaded one + */ +export async function initRundown(rundown: Readonly, customFields: Readonly) { + const { rundownMetadata, revision } = rundownCache.init(rundown, customFields); + + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // notify timer of change + notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload: true }); +} diff --git a/apps/server/src/services/rundown-service/rundown.types.ts b/apps/server/src/api-data/rundown/rundown.types.ts similarity index 100% rename from apps/server/src/services/rundown-service/rundown.types.ts rename to apps/server/src/api-data/rundown/rundown.types.ts diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 064fcd052..31f8a8e2d 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -1,9 +1,57 @@ -import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; -import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils'; +import { + EntryId, + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + OntimeBaseEvent, + OntimeBlock, + OntimeDelay, + OntimeEntry, + OntimeEvent, + Rundown, + SupportedEntry, + TimeStrategy, +} from 'ontime-types'; +import { generateId, getCueCandidate, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils'; -import { event as eventDef, block as blockDef } from '../../models/eventsDefinition.js'; +import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; import { makeString } from '../../utils/parserUtils.js'; +type CompleteEntry = + T extends Partial + ? OntimeEvent + : T extends Partial + ? OntimeDelay + : T extends Partial + ? OntimeBlock + : never; + +/** + * Generates a fully formed RundownEntry of the patch type + */ +export function generateEvent | Partial | Partial>( + rundown: Rundown, + eventData: T, + afterId: EntryId | null, +): CompleteEntry { + if (isOntimeEvent(eventData)) { + return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry; + } + + const id = eventData.id || getUniqueId(rundown); + + if (isOntimeDelay(eventData)) { + return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry; + } + + // TODO(v4): allow user to provide a larger patch of the block entry + if (isOntimeBlock(eventData)) { + return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry; + } + + throw new Error('Invalid event type'); +} + export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial): OntimeEvent { if (Object.keys(patchEvent).length === 0) { return originalEvent; @@ -46,6 +94,26 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial(eventFromRundown: T, patch: Partial): T { + if (isOntimeEvent(eventFromRundown)) { + const newEvent = createPatch(eventFromRundown, patch as Partial); + newEvent.revision++; + return newEvent as T; + } + if (isOntimeBlock(eventFromRundown)) { + const newBlock: OntimeBlock = { ...eventFromRundown, ...patch }; + newBlock.revision++; + return newBlock as T; + } + + // only delay is left + return { ...eventFromRundown, ...patch } as T; +} + /** * @description Enforces formatting for events * @param {object} eventArgs - attributes of event @@ -110,3 +178,125 @@ function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): } return fallback; } + +/** + * Whether a given ID is exists in the current rundown + */ +export function hasId(rundown: Rundown, id: EntryId): boolean { + return Object.hasOwn(rundown.entries, id); +} + +/** + * Returns an ID guaranteed to be unique + */ +export function getUniqueId(rundown: Rundown): EntryId { + let id: EntryId; + do { + id = generateId(); + } while (rundown.entries[id]); + return id; +} + +/** List of event properties which do not need the rundown to be regenerated */ +enum RegenerateWhitelist { + 'id', // adding it for completeness, users cannot change ID + 'type', // adding it for completeness, users cannot change ID + 'cue', + 'title', + 'note', + 'endAction', + 'timerType', + 'countToEnd', + 'isPublic', + 'colour', + 'timeWarning', + 'timeDanger', + 'custom', + 'triggers', +} + +/** + * given a patch, returns whether it invalidates the rundown metadata + */ +export function doesInvalidateMetadata(patch: Partial): boolean { + return Object.keys(patch).some(willCauseRegeneration); +} + +/** + * given a key, returns whether it is whitelisted + */ +export function willCauseRegeneration(key: string): boolean { + return !(key in RegenerateWhitelist); +} + +/** + * Given an event and a patch to that event checks whether there are actual changes to the dataset + * @param existingEvent + * @param newEvent + * @returns + */ +export function hasChanges(existingEvent: T, newEvent: Partial): boolean { + return Object.keys(newEvent).some( + (key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T], + ); +} + +/** + * Deletes the first instance of string from an array of strings + * Used for cases when we want to delete an ID from an array + * + * We keep this just for backend because the use of `toSpliced` does not have enough browser support + */ +export function deleteById(array: EntryId[], deleteId: EntryId): EntryId[] { + const deleteIndex = array.findIndex((id) => id === deleteId); + if (deleteIndex === -1) { + return array; + } + return array.toSpliced(deleteIndex, 1); +} + +/** + * Gathers business logic for how to clone an OntimeEvent + */ +export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent { + const newEntry = structuredClone(entry); + newEntry.id = newId; + newEntry.revision = 0; + return newEntry; +} + +/** + * Gathers business logic for how to clone an OntimeDelay + */ +export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay { + const newEntry = structuredClone(entry); + newEntry.id = newId; + return newEntry; +} + +/** + * Gathers business logic for how to clone an OntimeBlock + */ +export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock { + const newEntry = structuredClone(entry); + newEntry.id = newId; + + // in blocks, we need to remove the events references + newEntry.events = []; + newEntry.revision = 0; + return newEntry; +} + +/** + * Receives an entry and chooses the correct cloning strategy + */ +export function cloneEntry(entry: T, newId: EntryId): T { + if (isOntimeEvent(entry)) { + return cloneEvent(entry, newId) as T; + } else if (isOntimeDelay(entry)) { + return cloneDelay(entry, newId) as T; + } else if (entry.type === 'block') { + return cloneBlock(entry as OntimeBlock, newId) as T; + } + throw new Error(`Unsupported entry type for cloning: ${entry}`); +} diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 6a396f21d..2514d406a 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -25,7 +25,8 @@ export const rundownPutValidator = [ export const rundownBatchPutValidator = [ body('data').isObject().exists(), - body('ids').isArray().exists(), + body('ids').isArray().notEmpty(), + body('ids.*').isString(), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index 1f48995ab..306c3e0bf 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -1,4 +1,4 @@ -import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types'; +import { MessageState, OffsetMode, OntimeEvent, PatchWithId, SimpleDirection, SimplePlayback } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { DeepPartial } from 'ts-essentials'; @@ -11,13 +11,14 @@ import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { eventStore } from '../stores/EventStore.js'; import * as assert from '../utils/assert.js'; import { isEmptyObject } from '../utils/parserUtils.js'; -import { parseProperty, updateEvent } from './integration.utils.js'; +import { parseProperty } from './integration.utils.js'; import { socket } from '../adapters/WebsocketAdapter.js'; import { throttle } from '../utils/throttle.js'; -import { willCauseRegeneration } from '../services/rundown-service/rundownCache.utils.js'; import { coerceEnum } from '../utils/coerceType.js'; +import { editEntry } from '../api-data/rundown/rundown.service.js'; +import { willCauseRegeneration } from '../api-data/rundown/rundown.utils.js'; -const throttledUpdateEvent = throttle(updateEvent, 20); +const throttledEditEvent = throttle(editEntry, 20); let lastRequest: Date | null = null; export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') { @@ -56,7 +57,7 @@ const actionHandlers: Record = { } const data = payload[id as keyof typeof payload]; - const patchEvent: Partial & { id: string } = { id }; + const patchEvent: PatchWithId = { id }; let shouldThrottle = false; @@ -76,11 +77,13 @@ const actionHandlers: Record = { }); if (shouldThrottle) { - if (throttledUpdateEvent(patchEvent)) { + if (throttledEditEvent(patchEvent)) { return { payload: 'throttled' }; } } else { - updateEvent(patchEvent); + editEntry(patchEvent).catch((_error) => { + /** No error handling */ + }); } return { payload: 'success' }; }, diff --git a/apps/server/src/api-integration/integration.utils.ts b/apps/server/src/api-integration/integration.utils.ts index 264411ea7..2af171873 100644 --- a/apps/server/src/api-integration/integration.utils.ts +++ b/apps/server/src/api-integration/integration.utils.ts @@ -1,8 +1,6 @@ -import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types'; +import { EndAction, TimerType, isKeyOfType } from 'ontime-types'; import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils'; -import { editEvent } from '../services/rundown-service/RundownService.js'; -import { getEntryWithId } from '../services/rundown-service/rundownUtils.js'; import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js'; import { getDataProvider } from '../classes/data-provider/DataProvider.js'; @@ -58,19 +56,3 @@ export function parseProperty(property: string, value: unknown) { const parserFn = propertyConversion[property]; return { [property]: parserFn(value) }; } - -/** - * Updates a property of the event with the given id - * @param {Partial} patchEvent - */ -export function updateEvent(patchEvent: Partial & { id: string }) { - const event = getEntryWithId(patchEvent?.id ?? ''); - if (!event) { - throw new Error(`Event with ID ${patchEvent?.id} not found`); - } - - if (!isOntimeEvent(event)) { - throw new Error('Can only update events'); - } - editEvent(patchEvent); -} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 291c04178..7938f975a 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -36,7 +36,7 @@ import { restoreService } from './services/RestoreService.js'; import * as messageService from './services/message-service/MessageService.js'; import { populateDemo } from './setup/loadDemo.js'; import { getState } from './stores/runtimeState.js'; -import { initRundown } from './services/rundown-service/RundownService.js'; +import { initRundown } from './api-data/rundown/rundown.service.js'; import { initialiseProject } from './services/project-service/ProjectService.js'; import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; import { oscServer } from './adapters/OscAdapter.js'; diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts index 3a054d1d8..272308926 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts @@ -1,7 +1,7 @@ import { DatabaseModel, Settings, URLPreset } from 'ontime-types'; import { demoDb } from '../../../models/demoProject.js'; -import { makeOntimeEvent, makeRundown } from '../../../services/rundown-service/__mocks__/rundown.mocks.js'; +import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js'; import { safeMerge } from '../DataProvider.utils.js'; diff --git a/apps/server/src/services/__tests__/rollUtils.test.ts b/apps/server/src/services/__tests__/rollUtils.test.ts index 4acbff29f..9f765007d 100644 --- a/apps/server/src/services/__tests__/rollUtils.test.ts +++ b/apps/server/src/services/__tests__/rollUtils.test.ts @@ -1,7 +1,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; import { loadRoll } from '../rollUtils.js'; -import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.js'; +import { prepareTimedEvents, makeOntimeEvent } from '../../api-data/rundown/__mocks__/rundown.mocks.js'; describe('loadRoll()', () => { const eventlist = [ diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 97b07299b..6aaed5c43 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -23,8 +23,8 @@ import { demoDb } from '../../models/demoProject.js'; import { config } from '../../setup/config.js'; import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js'; import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js'; +import { initRundown } from '../../api-data/rundown/rundown.service.js'; -import { initRundown } from '../rundown-service/RundownService.js'; import { getLastLoadedProject, isLastLoadedProject, diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 2514bac71..8138cf18b 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -1,295 +1,10 @@ -import { - CustomFields, - OntimeBlock, - OntimeDelay, - OntimeEvent, - OntimeEntry, - isOntimeBlock, - isOntimeDelay, - isOntimeEvent, - PatchWithId, - EventPostPayload, - Rundown, - EntryId, -} from 'ontime-types'; -import { getCueCandidate } from 'ontime-utils'; +import { CustomFields, Rundown } from 'ontime-types'; -import { delay as delayDef } from '../../models/eventsDefinition.js'; import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js'; -import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js'; import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; -import { getPreviousId } from './rundownUtils.js'; - -type CompleteEntry = - T extends Partial - ? OntimeEvent - : T extends Partial - ? OntimeDelay - : T extends Partial - ? OntimeBlock - : never; - -/** - * Generates a fully formed RundownEntry of the patch type - */ -function generateEvent | Partial | Partial>( - eventData: T, - afterId?: string, -): CompleteEntry { - if (isOntimeEvent(eventData)) { - const currentRundown = cache.getCurrentRundown(); - return createEvent( - eventData, - getCueCandidate(currentRundown.entries, currentRundown.order, afterId), - ) as CompleteEntry; - } - - const id = eventData.id || cache.getUniqueId(); - - if (isOntimeDelay(eventData)) { - return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry; - } - - // TODO(v4): allow user to provide a larger patch of the block entry - if (isOntimeBlock(eventData)) { - return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry; - } - - throw new Error('Invalid event type'); -} - -/** - * creates a new event with given data - */ -export async function addEvent(eventData: EventPostPayload): Promise { - // 1. we allow the user to provide an ID, but make sure it is unique - if (eventData?.id && cache.hasId(eventData.id)) { - throw new Error(`Event with ID ${eventData.id} already exists`); - } - - // 2. if the user provides a parent (inside a group), we make sure it exists and it is a group - let parent: EntryId | null = null; - if ('parent' in eventData && eventData.parent != null) { - const maybeParent = cache.getCurrentRundown().entries[eventData.parent]; - if (!maybeParent || !isOntimeBlock(maybeParent)) { - throw new Error(`Invalid parent event with ID ${eventData.parent}`); - } - parent = eventData.parent; - } - - // 3. if the user provides an after or before ID, we make sure it exists - if (eventData?.after !== undefined) { - if (!cache.hasId(eventData.after)) { - throw new Error(`Event with ID ${eventData.after} not found`); - } - } - if (eventData?.before !== undefined) { - if (!cache.hasId(eventData.before)) { - throw new Error(`Event with ID ${eventData.before} not found`); - } - } - - const afterId = getPreviousId(eventData?.after, eventData?.before); - - // generate a fully formed entry from the patch - const sanitisedEntry = generateEvent(eventData, afterId); - - // modify rundown - const scopedMutation = cache.mutateCache(cache.add); - const { newEvent } = await scopedMutation({ afterId, parent, entry: sanitisedEntry }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: [sanitisedEntry.id], external: true }); - - // we know this mutation returns an OntimeEntry - return newEvent as OntimeEntry; -} - -/** - * deletes event by its ID - */ -export async function deleteEvent(eventIds: EntryId[]) { - const scopedMutation = cache.mutateCache(cache.remove); - const { didMutate, changeList } = await scopedMutation({ eventIds }); - - if (!didMutate) { - return; - } - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: changeList, external: true }); -} - -/** - * deletes all entries in database - */ -export async function deleteAllEntries() { - const scopedMutation = cache.mutateCache(cache.removeAll); - await scopedMutation({}); - - // notify event loader that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: true, external: true }); -} - -/** - * Apply patch to an element in rundown - * @param patch - */ -export async function editEvent(patch: PatchWithId) { - if (isOntimeEvent(patch) && patch?.cue === '') { - throw new Error('Cue value invalid'); - } - - const scopedMutation = cache.mutateCache(cache.edit); - const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id }); - - // short circuit if nothing changed - if (!didMutate) { - return newEvent; - } - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: [patch.id], external: true }); - - return newEvent; -} - -/** - * Applies a patch to several elements in a rundown - * @param ids - * @param data - */ -export async function batchEditEvents(ids: string[], data: Partial) { - const scopedMutation = cache.mutateCache(cache.batchEdit); - await scopedMutation({ patch: data, eventIds: ids }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: ids, external: true }); -} - -/** - * reorders a given entry - */ -export async function reorderEntry( - entryId: EntryId, - destinationId: EntryId, - order: 'before' | 'after' | 'insert', -): Promise { - const scopedMutation = cache.mutateCache(cache.reorder); - const { changeList, newRundown } = await scopedMutation({ entryId, destinationId, order }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: changeList, external: true }); - - return newRundown; -} - -/** - * Applies a delay into the rundown effectively changing the schedule - * The applied delay is deleted - * @param delayId - */ -export async function applyDelay(delayId: EntryId) { - const scopedMutation = cache.mutateCache(cache.applyDelay); - await scopedMutation({ delayId }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: true, external: true }); -} - -/** - * Clones an entry, ensuring that all dependencies are preserved - */ -export async function cloneEntry(entryId: EntryId) { - const scopedMutation = cache.mutateCache(cache.clone); - const { newRundown, newEvent } = await scopedMutation({ entryId }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - if (isOntimeBlock(newEvent)) { - notifyChanges({ timer: newEvent.events, external: true }); - } else if (isOntimeEvent(newEvent)) { - notifyChanges({ timer: [newEvent.id], external: true }); - } else if (isOntimeDelay(newEvent)) { - notifyChanges({ external: true }); - } - - return newRundown; -} - -/** - * Deletes a block from the rundown and moves all its children to the top level - */ -export async function ungroupEntries(blockId: EntryId) { - const scopedMutation = cache.mutateCache(cache.ungroup); - const { newRundown } = await scopedMutation({ blockId }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // we dont need to modify the timer since the grouping does not affect the runtime - notifyChanges({ external: true }); - - return newRundown; -} - -/** - * Groups a list of entries into a block - */ -export async function groupEntries(entryIds: EntryId[]) { - const scopedMutation = cache.mutateCache(cache.groupEntries); - const { newRundown } = await scopedMutation({ entryIds }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // we dont need to modify the timer since the grouping does not affect the runtime - notifyChanges({ external: true }); - - return newRundown; -} - -/** - * swaps two events - * @param {string} from - id of event from - * @param {string} to - id of event to - * @returns {Promise} - */ -export async function swapEvents(from: string, to: string) { - const scopedMutation = cache.mutateCache(cache.swap); - await scopedMutation({ fromId: from, toId: to }); - - // notify runtime that rundown has changed - updateRuntimeOnChange(); - - // notify timer and external services of change - notifyChanges({ timer: true, external: true }); -} /** * Forces update in the store diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts deleted file mode 100644 index abffdf549..000000000 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { OntimeEvent, SupportedEntry } from 'ontime-types'; -import { MILLIS_PER_HOUR } from 'ontime-utils'; - -import { apply } from '../delayUtils.js'; -import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; - -describe('apply()', () => { - it('applies a positive delay to the rundown', () => { - const testRundown = makeRundown({ - revision: 0, - order: ['delay', '1', '2', '3', '4', '5'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: 10 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }), - '3': makeOntimeBlock({ id: '3' }), - '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }), - '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.revision).toBe(1); - expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); - expect(testRundown.entries).toMatchObject({ - '1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, - '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true }, - '3': { id: '3' }, - '4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false }, - '5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true }, - }); - }); - - it('applies negative delays', () => { - const testRundown = makeRundown({ - revision: 0, - order: ['delay', '1', '2', '3', '4', '5'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: -10 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }), - '3': makeOntimeBlock({ id: '3' }), - '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }), - '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.revision).toBe(1); - expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); - expect(testRundown.entries).toMatchObject({ - '1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, - '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: false }, - '3': { id: '3' }, - '4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false }, - '5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true }, - }); - }); - - it('should account for minimum duration and start when applying negative delays', () => { - const testRundown = makeRundown({ - order: ['delay', '1', '2'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: -50 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', '2']); - expect(testRundown.entries).toMatchObject({ - '1': { - id: '1', - type: SupportedEntry.Event, - timeStart: 0, - timeEnd: 100, - duration: 100, - revision: 2, - } as OntimeEvent, - '2': { - id: '2', - type: SupportedEntry.Event, - timeStart: 50, - timeEnd: 100, - duration: 50, - linkStart: false, - revision: 2, - }, - }); - }); - - it('unlinks events to maintain gaps when applying positive delays', () => { - const testRundown = makeRundown({ - order: ['1', 'delay', '2'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - delay: makeOntimeDelay({ id: 'delay', duration: 50 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', '2']); - expect(testRundown.entries).toMatchObject({ - '1': { - id: '1', - timeStart: 0, - timeEnd: 100, - duration: 100, - revision: 1, - }, - '2': { - id: '2', - timeStart: 150, - timeEnd: 200, - duration: 50, - linkStart: false, - revision: 2, - }, - }); - }); - - it('maintains links if there is no gap', () => { - const testRundown = makeRundown({ - order: ['delay', '1', '2'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: 50 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', '2']); - expect(testRundown.entries).toMatchObject({ - '1': { - id: '1', - timeStart: 50, - timeEnd: 150, - duration: 100, - revision: 2, - }, - '2': { - id: '2', - timeStart: 150, - timeEnd: 200, - duration: 50, - linkStart: true, - revision: 2, - }, - }); - }); - - it('unlinks events to maintain gaps when applying negative delays', () => { - const testRundown = makeRundown({ - order: ['1', 'delay', '2'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - delay: makeOntimeDelay({ id: 'delay', duration: -50 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', '2']); - expect(testRundown.entries).toMatchObject({ - '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }, - '2': { - id: '2', - timeStart: 50, - timeEnd: 100, - duration: 50, - linkStart: false, - revision: 2, - }, - }); - }); - - it('gaps reduce positive delay', () => { - const testRundown = makeRundown({ - order: ['delay', '1', '2', '3', '4', '5'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: 100 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - // gap 50 - '2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), - // gap 0 - '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), - // gap 50 - '4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), - // linked - '5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); - expect(testRundown.entries).toMatchObject({ - '1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, - // gap 50 (100 - 50) - '2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, - // gap 50 (50 - 50) - '3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, - // gap (delay is 0) - '4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, - // linked - '5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true }, - }); - }); - - it('gaps reduce positive delay (2)', () => { - const testRundown = makeRundown({ - order: ['delay', '1', '2'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }), - '1': makeOntimeEvent({ - id: '1', - gap: 0, - dayOffset: 0, - timeStart: 46800000, // 13:00:00 - timeEnd: 50400000, // 14:00:00 - duration: MILLIS_PER_HOUR, - }), - // gap 1h - '2': makeOntimeEvent({ - id: '2', - gap: 1 * MILLIS_PER_HOUR, - dayOffset: 0, - timeStart: 54000000, // 15:00:00 - timeEnd: 57600000, // 16:00:00 - duration: MILLIS_PER_HOUR, - }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', '2']); - expect(testRundown.entries).toMatchObject({ - '1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, - // gap 1h (2h - 1h) - '2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 }, - }); - }); - - it('removes empty delays without applying changes', () => { - const testRundown = makeRundown({ - order: ['delay', '1'], - entries: { - delay: makeOntimeDelay({ id: 'delay', duration: 0 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1']); - expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); - }); - - it('removes delays in last position without applying changes', () => { - const testRundown = makeRundown({ - order: ['1', 'delay'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - delay: makeOntimeDelay({ id: 'delay', duration: 100 }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1']); - expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); - }); - - it('unlinks events to across blocks is it is the first event after the delay', () => { - const testRundown = makeRundown({ - order: ['1', 'delay', 'block', '2'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - delay: makeOntimeDelay({ id: 'delay', duration: 50 }), - block: makeOntimeBlock({ id: 'block' }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), - }, - }); - - apply('delay', testRundown); - expect(testRundown.order).toMatchObject(['1', 'block', '2']); - - expect(testRundown.entries).toMatchObject({ - '1': { - id: '1', - timeStart: 0, - timeEnd: 100, - duration: 100, - revision: 1, - }, - block: { id: 'block' }, - '2': { - id: '2', - timeStart: 150, - timeEnd: 200, - duration: 50, - linkStart: false, - revision: 2, - }, - }); - }); -}); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 840f922b0..29bd078a9 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -1,26 +1,4 @@ -import { CustomFields, OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; -import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; - -import { demoDb } from '../../../models/demoProject.js'; - -import { - add, - batchEdit, - edit, - generate, - remove, - reorder, - swap, - createCustomField, - editCustomField, - removeCustomField, - customFieldChangelog, - ungroup, - groupEntries, - clone, -} from '../rundownCache.js'; -import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; -import type { ProcessedRundownMetadata } from '../rundownCache.utils.js'; +import { createCustomField, editCustomField, removeCustomField, customFieldChangelog } from '../rundownCache.js'; beforeAll(() => { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -35,1191 +13,6 @@ beforeAll(() => { }); }); -describe('generate()', () => { - test('benchmark function execution time', () => { - const t1 = performance.now(); - let result: ProcessedRundownMetadata | null = null; - for (let i = 0; i < 100; i++) { - result = generate(demoDb.rundowns.default, demoDb.customFields); - } - const t2 = performance.now(); - console.warn( - 'Rundown generation took', - t2 - t1, - 'milliseconds for 100x', - Object.keys(result?.entries ?? {}).length, - 'events', - ); - }); - - it('generates metadata from given rundown', () => { - const initResult = generate(demoDb.rundowns.default, demoDb.customFields); - - expect(initResult.order.length).toBe(12); - expect(initResult.flatEntryOrder.length).toBe(17); - expect(initResult.timedEventOrder.length).toBe(14); - expect(initResult.playableEventOrder.length).toBe(14); - expect(initResult.firstStart).toBe(36000000); - expect(initResult.lastEnd).toBe(61800000); - expect(initResult.totalDays).toBe(0); - expect(initResult.totalDelay).toBe(0); - }); - - it('creates normalised versions of a given rundown', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1' }), - '2': makeOntimeBlock({ id: '2' }), - '3': makeOntimeDelay({ id: '3' }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(3); - expect(initResult.order).toStrictEqual(['1', '2', '3']); - expect(initResult.entries['1'].type).toBe(SupportedEntry.Event); - expect(initResult.entries['2'].type).toBe(SupportedEntry.Block); - expect(initResult.entries['3'].type).toBe(SupportedEntry.Delay); - }); - - it('calculates delays versions of a given rundown', () => { - const rundown = makeRundown({ - order: ['1', '2'], - entries: { - '1': makeOntimeDelay({ id: '1', duration: 100 }), - '2': makeOntimeEvent({ id: '2', timeStart: 1, timeEnd: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(2); - expect((initResult.entries['2'] as OntimeEvent).delay).toBe(100); - expect(initResult.totalDelay).toBe(100); - }); - - it('accounts for gaps in rundown when calculating delays', () => { - const rundown = makeRundown({ - order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), - delay: makeOntimeDelay({ id: 'delay', duration: 200 }), - '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), - block: makeOntimeBlock({ id: 'block', title: 'break' }), - '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), - 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), - '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(7); - expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); - expect((initResult.entries['2'] as OntimeEvent).delay).toBe(200); - expect((initResult.entries['3'] as OntimeEvent).delay).toBe(100); - expect((initResult.entries['4'] as OntimeEvent).delay).toBe(0); - expect(initResult.totalDelay).toBe(0); - expect(initResult.totalDuration).toBe(700 - 100); - }); - - it('accounts for overlaps in rundown', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), - '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), - '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start - }); - - it('accounts for overlaps in rundown (with added gap)', () => { - const rundown = makeRundown({ - order: ['1', '2', '3', '4'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), - '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), - '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), - '4': makeOntimeEvent({ id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start - }); - - it('accounts for overlaps in rundown (with multiple days)', () => { - const rundown = makeRundown({ - order: ['1', '2', '3', '4'], - entries: { - '1': makeOntimeEvent({ - id: '1', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 10 * MILLIS_PER_HOUR, - duration: MILLIS_PER_HOUR, - }), - '2': makeOntimeEvent({ - id: '2', - timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, - timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, - duration: 30 * MILLIS_PER_MINUTE, - }), - '3': makeOntimeEvent({ - id: '3', - timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, - timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, - duration: MILLIS_PER_HOUR, - }), - '4': makeOntimeEvent({ - id: '4', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 10 * MILLIS_PER_HOUR, - duration: MILLIS_PER_HOUR, - }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start - }); - - it('maintains delays over gaps', () => { - const rundown = makeRundown({ - order: ['1', 'delay', '2'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - delay: makeOntimeDelay({ id: 'delay', duration: 500 }), - '2': makeOntimeEvent({ id: '2', timeStart: 500, timeEnd: 600, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.entries['2']).toMatchObject({ - gap: 400, - delay: 500, - }); - expect(initResult.totalDelay).toBe(500); - expect(initResult.totalDuration).toBe(600); - }); - - it('maintains delays over gaps when there are cumulative delays', () => { - const rundown = makeRundown({ - order: ['delay1', '1', 'delay2', '2'], - entries: { - delay1: makeOntimeDelay({ id: 'delay', duration: 100 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - delay2: makeOntimeDelay({ id: 'delay', duration: 300 }), - '2': makeOntimeEvent({ id: '2', timeStart: 300, timeEnd: 400, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.entries['1']).toMatchObject({ - gap: 0, - delay: 100, - }); - expect(initResult.entries['2']).toMatchObject({ - gap: 200, - delay: 300, // first delay was fully absorbed - }); - expect(initResult.totalDelay).toBe(300); - expect(initResult.totalDuration).toBe(400); - }); - - it('handles negative delays on gaps', () => { - const rundown = makeRundown({ - order: ['delay1', '1', 'delay2', '2'], - entries: { - delay1: makeOntimeDelay({ id: 'delay', duration: -100 }), - '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - delay2: makeOntimeDelay({ id: 'delay', duration: -300 }), - '2': makeOntimeEvent({ id: '2', timeStart: 300, timeEnd: 400, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.entries['1']).toMatchObject({ - gap: 0, - delay: -100, - }); - expect(initResult.entries['2']).toMatchObject({ - gap: 200, - delay: -400, - }); - expect(initResult.totalDelay).toBe(-400); - expect(initResult.totalDuration).toBe(400); - }); - - it('handles negative delays', () => { - const rundown = makeRundown({ - order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), - delay: makeOntimeDelay({ id: 'delay', duration: -200 }), - '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), - block: makeOntimeBlock({ id: 'block', title: 'break' }), - '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), - 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), - '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(7); - expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); - expect((initResult.entries['2'] as OntimeEvent).delay).toBe(-200); - expect((initResult.entries['3'] as OntimeEvent).delay).toBe(-200); - expect((initResult.entries['4'] as OntimeEvent).delay).toBe(-200); - expect(initResult.totalDelay).toBe(-200); - expect(initResult.totalDuration).toBe(700 - 100); - }); - - it('links times across events', () => { - const rundown = makeRundown({ - order: ['1', '2', 'block', 'delay', '3'], - entries: { - '1': makeOntimeEvent({ - id: '1', - timeStart: 1, - duration: 1, - timeEnd: 2, - timeStrategy: TimeStrategy.LockEnd, - }), - '2': makeOntimeEvent({ - id: '2', - timeStart: 11, - duration: 1, - timeEnd: 12, - linkStart: true, - timeStrategy: TimeStrategy.LockEnd, - }), - block: makeOntimeBlock({ id: 'block' }), - delay: makeOntimeDelay({ id: 'delay' }), - '3': makeOntimeEvent({ - id: '3', - timeStart: 21, - duration: 1, - timeEnd: 22, - linkStart: true, - timeStrategy: TimeStrategy.LockEnd, - }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(5); - expect((initResult.entries['2'] as OntimeEvent).timeStart).toBe(2); - expect((initResult.entries['2'] as OntimeEvent).timeEnd).toBe(12); - expect((initResult.entries['2'] as OntimeEvent).duration).toBe(10); - - expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(12); - expect((initResult.entries['3'] as OntimeEvent).timeEnd).toBe(22); - expect((initResult.entries['3'] as OntimeEvent).duration).toBe(10); - }); - - it('links times across events, reordered', () => { - const rundown = makeRundown({ - order: ['1', '3', '2'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }), - '3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: true }), - '2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: true }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(3); - expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(2); - }); - - it('calculates total duration', () => { - const rundown = makeRundown({ - order: ['1', '2', 'skipped', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), - '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), - skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 100 }), - '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(4); - expect(initResult.totalDuration).toBe(500 - 100); - }); - - it('calculates total duration with 0 duration events without causing a next day', () => { - const rundown = makeRundown({ - order: ['1', '2', 'skipped', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 100, duration: 0 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 300, duration: 200 }), - skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 0 }), - '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.order.length).toBe(4); - expect(initResult.totalDuration).toBe(500 - 100); - }); - - it('calculates total duration across days with gap', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ - id: '1', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - }), - '2': makeOntimeEvent({ - id: '2', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - }), - '3': makeOntimeEvent({ - id: '2', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR); - }); - - it('calculates total duration across days', () => { - const rundown = makeRundown({ - order: ['1', '2'], - entries: { - '1': makeOntimeEvent({ - id: '1', - timeStart: 12 * MILLIS_PER_HOUR, - timeEnd: 22 * MILLIS_PER_HOUR, - duration: 10 * MILLIS_PER_HOUR, - }), - '2': makeOntimeEvent({ - id: '2', - timeStart: 22 * MILLIS_PER_HOUR, - timeEnd: 8 * MILLIS_PER_HOUR, - duration: (24 - 22 + 8) * MILLIS_PER_HOUR, - }), - }, - }); - - const initResult = generate(rundown, {}); - const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR); - expect(initResult.totalDuration).toBe(expectedDuration); - }); - - it('handles updating event sequence', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ - id: '1', - timeStart: 0, - timeEnd: 600000, - duration: 600000, - timeStrategy: TimeStrategy.LockDuration, - linkStart: false, - }), - '2': makeOntimeEvent({ - id: '2', - timeStart: 600000, - timeEnd: 601000, - duration: 85801000, // <------------- value out of sync - timeStrategy: TimeStrategy.LockEnd, - linkStart: true, - }), - '3': makeOntimeEvent({ - id: '3', - timeStart: 100, // <------------- value out of sync - timeEnd: 602000, - duration: 0, - timeStrategy: TimeStrategy.LockEnd, - linkStart: true, - }), - }, - }); - - const initResult = generate(rundown, {}); - expect(initResult.entries).toMatchObject({ - '1': { - timeStart: 0, - timeEnd: 600000, - duration: 600000, - timeStrategy: 'lock-duration', - linkStart: false, - }, - '2': { - timeStart: 600000, - timeEnd: 601000, - duration: 1000, - timeStrategy: 'lock-end', - linkStart: true, - }, - '3': { - timeStart: 601000, - timeEnd: 602000, - duration: 1000, - timeStrategy: 'lock-end', - linkStart: true, - }, - }); - }); - - describe('custom properties feature', () => { - it('creates a map of custom properties', () => { - const customProperties: CustomFields = { - lighting: { - label: 'lighting', - type: 'string', - colour: 'red', - }, - sound: { - label: 'sound', - type: 'string', - colour: 'red', - }, - }; - - const rundown = makeRundown({ - order: ['1', '2'], - entries: { - '1': makeOntimeEvent({ - id: '1', - custom: { - lighting: 'event 1 lx', - }, - }), - '2': makeOntimeEvent({ - id: '2', - custom: { - lighting: 'event 2 lx', - sound: 'event 2 sound', - }, - }), - }, - }); - const initResult = generate(rundown, customProperties); - expect(initResult.order.length).toBe(2); - expect(initResult.assignedCustomFields).toMatchObject({ - lighting: ['1', '2'], - sound: ['2'], - }); - expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' }); - expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({ - lighting: 'event 2 lx', - sound: 'event 2 sound', - }); - }); - }); -}); - -describe('generate() v4', () => { - describe('handle of event groups', () => { - it('correctly parses group metadata', () => { - const rundown = makeRundown({ - order: ['1'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['100', '200', '300'] }), - '100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), - '200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }), - '300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }), - }, - }); - const generatedRundown = generate(rundown, {}); - - expect(generatedRundown.order).toMatchObject(['1']); - expect(generatedRundown.totalDuration).toBe(300); - expect(generatedRundown.totalDelay).toBe(0); - expect(generatedRundown.entries).toMatchObject({ - '1': { - type: SupportedEntry.Block, - events: ['100', '200', '300'], - startTime: 100, - endTime: 400, - duration: 300, - isFirstLinked: false, - }, - '100': { type: SupportedEntry.Event, parent: '1' }, - '200': { type: SupportedEntry.Event, parent: '1' }, - '300': { type: SupportedEntry.Event, parent: '1' }, - }); - }); - - it('treats groups as invisible for gap calculations', () => { - const rundown = makeRundown({ - order: ['0', '1', '2', '3'], - entries: { - '0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }), - '1': makeOntimeBlock({ id: '1', events: ['101', '102', '103'] }), - '101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), - '102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), - '103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }), - '2': makeOntimeBlock({ id: '2', events: ['201', '202', '203'] }), - '201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }), - '202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }), - '203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }), - '3': makeOntimeBlock({ id: '3', events: ['301', '302', '303'] }), - '301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }), - '302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }), - '303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }), - }, - }); - const generatedRundown = generate(rundown, {}); - - expect(generatedRundown.order).toMatchObject(['0', '1', '2', '3']); - expect(generatedRundown.totalDuration).toBe(1200); - expect(generatedRundown.totalDelay).toBe(0); - expect(generatedRundown.entries).toMatchObject({ - '0': { type: SupportedEntry.Event, parent: null }, - '1': { - type: SupportedEntry.Block, - events: ['101', '102', '103'], - startTime: 100, - endTime: 400, - duration: 300, - isFirstLinked: false, - }, - '101': { parent: '1', gap: 90, linkStart: false }, - '102': { parent: '1' }, - '103': { parent: '1' }, - '2': { - type: SupportedEntry.Block, - events: ['201', '202', '203'], - startTime: 500, - endTime: 800, - duration: 300, - isFirstLinked: false, - }, - '201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false }, - '202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 }, - '203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 }, - '3': { - type: SupportedEntry.Block, - events: ['301', '302', '303'], - startTime: 900, - endTime: 1200, - duration: 300, - isFirstLinked: false, - }, - '301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false }, - '302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 }, - '303': { id: '303', timeStart: 1100, timeEnd: 1200, duration: 100 }, - }); - }); - }); -}); - -describe('add() mutation', () => { - test('adds an event an empty rundown', () => { - const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); - const rundown = makeRundown({}); - const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown }); - expect(newRundown.order.length).toBe(1); - expect(newRundown.entries['mock']).toMatchObject(mockEvent); - }); - - test('adds an event at the top if no afterId is given', () => { - const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); - const rundown = makeRundown({ - flatOrder: ['1'], - order: ['1'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: '1' }), - }, - }); - const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown }); - expect(newRundown.order).toStrictEqual(['mock', '1']); - expect(newRundown.flatOrder).toStrictEqual(['mock', '1']); - expect(newRundown.entries['mock']).toMatchObject(mockEvent); - }); - - test('adds an event at the top of the block if no after is given', () => { - const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); - const rundown = makeRundown({ - flatOrder: ['1', '1a'], - order: ['1'], - entries: { - '1': makeOntimeBlock({ id: '1' }), - '1a': makeOntimeEvent({ id: '1a', parent: '1' }), - }, - }); - const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: '1', rundown }); - expect(newRundown.order).toStrictEqual(['1']); - expect(newRundown.flatOrder).toStrictEqual(['1', 'mock', '1a']); - expect(newRundown.entries['mock']).toMatchObject(mockEvent); - }); - - test('adds an event at the a given location inside a block', () => { - const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); - const rundown = makeRundown({ - flatOrder: ['1', '1a'], - order: ['1'], - entries: { - '1': makeOntimeBlock({ id: '1' }), - '1a': makeOntimeEvent({ id: '1a', parent: '1' }), - }, - }); - const { newRundown } = add({ afterId: '1a', entry: mockEvent, parent: '1', rundown }); - expect(newRundown.order).toStrictEqual(['1']); - expect(newRundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); - expect(newRundown.entries['mock']).toMatchObject(mockEvent); - }); -}); - -describe('remove() mutation', () => { - test('deletes an event from the rundown', () => { - const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); - const rundown = makeRundown({ - order: ['mock'], - entries: { - mock: mockEvent, - }, - }); - - const { newRundown } = remove({ eventIds: [mockEvent.id], rundown }); - expect(newRundown.order.length).toBe(0); - }); - - test('deletes multiple events from the rundown', () => { - const rundown = makeRundown({ - order: ['1', '2', '3', '4', '5', '6'], - entries: { - '1': makeOntimeEvent({ id: '1' }), - '2': makeOntimeBlock({ id: '2' }), - '3': makeOntimeDelay({ id: '3' }), - '4': makeOntimeEvent({ id: '4' }), - '5': makeOntimeEvent({ id: '5' }), - '6': makeOntimeEvent({ id: '6' }), - }, - }); - - const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown }); - expect(newRundown.order.length).toBe(3); - expect(newRundown.entries[newRundown.order[0]].id).toBe('4'); - }); - - test('deletes a nested event', () => { - const rundown = makeRundown({ - order: ['1'], - flatOrder: ['1', '11', '12', '13'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['11', '12', '13'] }), - '11': makeOntimeEvent({ id: '11', parent: '1' }), - '12': makeOntimeDelay({ id: '12', parent: '1' }), - '13': makeOntimeEvent({ id: '13', parent: '1' }), - }, - }); - - const { newRundown } = remove({ eventIds: ['12'], rundown }); - expect(newRundown.order).toStrictEqual(['1']); - expect(newRundown.entries).toMatchObject({ - '1': { id: '1' }, - '11': { id: '11' }, - '13': { id: '13' }, - }); - expect((newRundown.entries['1'] as OntimeBlock).events).toStrictEqual(['11', '13']); - }); -}); - -describe('edit() mutation', () => { - test('edits an event in the rundown', () => { - const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); - const mockEventPatch = makeOntimeEvent({ cue: 'patched' }); - const rundown = makeRundown({ - order: ['mock'], - entries: { - mock: mockEvent, - }, - }); - - const { newRundown, newEvent } = edit({ - eventId: mockEvent.id, - patch: mockEventPatch, - rundown, - }); - expect(newRundown.order.length).toBe(1); - expect(newEvent).toMatchObject({ - id: 'mock', - cue: 'patched', - type: SupportedEntry.Event, - }); - }); -}); - -describe('batchEdit() mutation', () => { - it('should correctly apply the patch to the events with the given IDs', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1' }), - '2': makeOntimeEvent({ id: '2', cue: 'data2' }), - '3': makeOntimeEvent({ id: '3', cue: 'data3' }), - }, - }); - - const eventIds = ['1', '3']; - const patch = { cue: 'newData' }; - - const { newRundown } = batchEdit({ rundown, eventIds, patch }); - - expect(newRundown.entries).toMatchObject({ - '1': { id: '1', type: SupportedEntry.Event, cue: 'newData' }, - '2': { id: '2', type: SupportedEntry.Event, cue: 'data2' }, - '3': { id: '3', type: SupportedEntry.Event, cue: 'newData' }, - }); - }); -}); - -describe('reorder() mutation', () => { - it('moves an event into a block', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - flatOrder: ['1', '2', '3'], - entries: { - '1': makeOntimeBlock({ id: '1', events: [] }), - '2': makeOntimeEvent({ id: '2', parent: null }), - '3': makeOntimeEvent({ id: '3', parent: null }), - }, - }); - - const { newRundown } = reorder({ - rundown: rundown, - entryId: '3', - destinationId: '1', - order: 'insert', - }); - - expect(newRundown.order).toStrictEqual(['1', '2']); - // expect(newRundown.flatOrder).toStrictEqual(['1', '3', '2']); - // expect(changeList).toStrictEqual(['1', '3', '2']); - expect(rundown.entries['1']).toMatchObject({ - events: ['3'], - }); - expect(rundown.entries['3']).toMatchObject({ - parent: '1', - }); - }); - - it('adds an event into a block', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '11', '2'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['11'] }), - '11': makeOntimeEvent({ id: '11', parent: '1' }), - '2': makeOntimeEvent({ id: '2', parent: null }), - }, - }); - - const { newRundown } = reorder({ - rundown: rundown, - entryId: '2', - destinationId: '11', - order: 'before', - }); - - expect(newRundown.order).toStrictEqual(['1']); - // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11']); - // expect(changeList).toStrictEqual(['1', '2', '11']); - expect(rundown.entries['1']).toMatchObject({ - events: ['2', '11'], - }); - expect(rundown.entries['2']).toMatchObject({ - parent: '1', - }); - }); - - it('moves an event after another', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - flatOrder: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1' }), - '2': makeOntimeEvent({ id: '2', cue: 'data2' }), - '3': makeOntimeEvent({ id: '3', cue: 'data3' }), - }, - }); - - // move first event to the end - const { newRundown } = reorder({ - rundown: rundown, - entryId: '1', - destinationId: '2', - order: 'after', - }); - - expect(newRundown.order).toStrictEqual(['2', '1', '3']); - // expect(newRundown.flatOrder).toStrictEqual(['2', '3', '1']); - // expect(changeList).toStrictEqual(['2', '3', '1']); - }); - - it('moves an event before another', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - flatOrder: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1' }), - '2': makeOntimeEvent({ id: '2', cue: 'data2' }), - '3': makeOntimeEvent({ id: '3', cue: 'data3' }), - }, - }); - - // move last event to the beginning - const { newRundown } = reorder({ - rundown: rundown, - entryId: '3', - destinationId: '1', - order: 'before', - }); - - expect(newRundown.order).toStrictEqual(['3', '1', '2']); - // expect(newRundown.flatOrder).toStrictEqual(['3', '1', '2']); - // expect(changeList).toStrictEqual(['3', '1', '2']); - }); - - it('moves an event out of a block', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '11', '2'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['11'] }), - '11': makeOntimeEvent({ id: '11', parent: '1' }), - '2': makeOntimeEvent({ id: '2', parent: null }), - }, - }); - - const { newRundown, changeList } = reorder({ - rundown: rundown, - entryId: '11', - destinationId: '2', - order: 'before', - }); - - expect(newRundown.order).toStrictEqual(['1', '11', '2']); - expect(newRundown.flatOrder).toStrictEqual(['1', '11', '2']); - expect(changeList).toStrictEqual(['1', '11', '2']); - expect(rundown.entries['1']).toMatchObject({ - events: [], - }); - expect(rundown.entries['2']).toMatchObject({ - parent: null, - }); - }); - - it('moves an event between blocks', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '11', '2', '22'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['11'] }), - '11': makeOntimeEvent({ id: '11', parent: '1' }), - '2': makeOntimeBlock({ id: '2', events: ['22'] }), - '22': makeOntimeEvent({ id: '22', parent: '2' }), - }, - }); - - const { newRundown } = reorder({ - rundown: rundown, - entryId: '11', - destinationId: '22', - order: 'before', - }); - - expect(newRundown.order).toStrictEqual(['1', '2']); - // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); - // expect(changeList).toStrictEqual(['1', '2', '11', '22']); - expect(rundown.entries['1']).toMatchObject({ - events: [], - }); - expect(rundown.entries['2']).toMatchObject({ - events: ['11', '22'], - }); - expect(rundown.entries['11']).toMatchObject({ - parent: '2', - }); - }); - - it('moves an event into an empty block', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '2', '22'], - entries: { - '1': makeOntimeBlock({ id: '1', events: [] }), - '2': makeOntimeBlock({ id: '2', events: ['22'] }), - '22': makeOntimeEvent({ id: '22', parent: '2' }), - }, - }); - - const { newRundown } = reorder({ - rundown: rundown, - entryId: '22', - destinationId: '1', - order: 'insert', - }); - - expect(newRundown.order).toStrictEqual(['1', '2']); - // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); - // expect(changeList).toStrictEqual(['1', '2', '11', '22']); - expect(rundown.entries['1']).toMatchObject({ - events: ['22'], - }); - expect(rundown.entries['2']).toMatchObject({ - events: [], - }); - expect(rundown.entries['22']).toMatchObject({ - parent: '1', - }); - }); - - it('moves an event out of a block (up)', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '11', '2', '22'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['11'] }), - '11': makeOntimeEvent({ id: '11', parent: '1' }), - '2': makeOntimeBlock({ id: '2', events: ['22'] }), - '22': makeOntimeEvent({ id: '22', parent: '2' }), - }, - }); - - const { newRundown } = reorder({ - rundown: rundown, - entryId: '22', - destinationId: '2', - order: 'before', - }); - - expect(newRundown.order).toStrictEqual(['1', '22', '2']); - // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); - // expect(changeList).toStrictEqual(['1', '2', '11', '22']); - expect(rundown.entries['1']).toMatchObject({ - events: ['11'], - }); - expect(rundown.entries['11']).toMatchObject({ - parent: '1', - }); - expect(rundown.entries['2']).toMatchObject({ - events: [], - }); - expect(rundown.entries['22']).toMatchObject({ - parent: null, - }); - }); - - it('moves an event out of a block (down)', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '11', '2', '22'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['11'] }), - '11': makeOntimeEvent({ id: '11', parent: '1' }), - '2': makeOntimeBlock({ id: '2', events: ['22'] }), - '22': makeOntimeEvent({ id: '22', parent: '2' }), - }, - }); - - const { newRundown } = reorder({ - rundown: rundown, - entryId: '11', - destinationId: '1', - order: 'after', - }); - - expect(newRundown.order).toStrictEqual(['1', '11', '2']); - // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); - // expect(changeList).toStrictEqual(['1', '2', '11', '22']); - expect(rundown.entries['1']).toMatchObject({ - events: [], - }); - expect(rundown.entries['11']).toMatchObject({ - parent: null, - }); - expect(rundown.entries['2']).toMatchObject({ - events: ['22'], - }); - expect(rundown.entries['22']).toMatchObject({ - parent: '2', - }); - }); -}); - -describe('clone() mutation', () => { - it('clones an event and adds it to the rundown', () => { - const rundown = makeRundown({ - order: ['1'], - flatOrder: ['1'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), - }, - }); - - const { newRundown, newEvent } = clone({ rundown, entryId: '1' }); - - expect(newRundown.order).toStrictEqual(['1', newEvent!.id]); - expect(newRundown.flatOrder).toStrictEqual(['1', newEvent!.id]); - }); - - it('clones an event inside a block and adds it to the rundown', () => { - const rundown = makeRundown({ - order: ['1'], - flatOrder: ['1', '1a'], - entries: { - '1': makeOntimeBlock({ id: '1', events: ['1a'] }), - '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), - }, - }); - - const { newRundown, newEvent } = clone({ rundown, entryId: '1a' }); - - expect(newRundown.order).toStrictEqual(['1']); - expect(newRundown.flatOrder).toStrictEqual(['1', '1a', newEvent!.id]); - expect(newRundown.entries['1']).toMatchObject({ events: ['1a', newEvent!.id] }); - expect(newRundown.entries[newEvent!.id]).toMatchObject({ - type: SupportedEntry.Event, - parent: '1', - cue: 'nested', - }); - }); - - it('clones a block and its nested elements', () => { - const rundown = makeRundown({ - order: ['1'], - flatOrder: ['1', '1a'], - entries: { - '1': makeOntimeBlock({ id: '1', title: 'top', events: ['1a'] }), - '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), - }, - }); - - const { newRundown, newEvent } = clone({ rundown, entryId: '1' }); - - expect(newRundown.order).toStrictEqual(['1', newEvent!.id]); - expect(newRundown.flatOrder).toStrictEqual(['1', '1a', expect.any(String), expect.any(String)]); - expect(newRundown.entries[newEvent!.id]).toMatchObject({ - type: SupportedEntry.Block, - events: [expect.any(String)], - }); - }); -}); - -describe('ungroup() mutation', () => { - it('should correctly dissolve a block into its events', () => { - const rundown = makeRundown({ - order: ['1', '2'], - flatOrder: ['1', '2', '21', '22'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), - '2': makeOntimeBlock({ id: '2', events: ['21', '22'] }), - '21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }), - '22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }), - }, - }); - - const { newRundown } = ungroup({ - rundown, - blockId: '2', - }); - - expect(newRundown.order).toStrictEqual(['1', '21', '22']); - expect(newRundown.flatOrder).toStrictEqual(['1', '21', '22']); - expect(newRundown.entries['2']).toBeUndefined(); - expect(newRundown.entries).toMatchObject({ - '1': { id: '1', type: SupportedEntry.Event, cue: 'data1', parent: null }, - '21': { id: '21', type: SupportedEntry.Event, cue: 'data21', parent: null }, - '22': { id: '22', type: SupportedEntry.Event, cue: 'data22', parent: null }, - }); - }); -}); - -describe('groupEntries() mutation', () => { - it('groups a list of existing events into a new block', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - flatOrder: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', parent: null }), - '2': makeOntimeEvent({ id: '2', parent: null }), - '3': makeOntimeEvent({ id: '3', parent: null }), - }, - }); - - const { newRundown } = groupEntries({ - rundown, - entryIds: ['1', '2'], - }); - - const blockId = newRundown.order[0]; - expect(blockId).toStrictEqual(expect.any(String)); - expect(newRundown.order).toStrictEqual([expect.any(String), '3']); - expect(newRundown.flatOrder).toStrictEqual([expect.any(String), '1', '2', '3']); - expect(newRundown.entries).toMatchObject({ - [blockId]: { - type: SupportedEntry.Block, - events: ['1', '2'], - }, - '1': { id: '1', type: SupportedEntry.Event, parent: blockId }, - '2': { id: '2', type: SupportedEntry.Event, parent: blockId }, - '3': { id: '3', type: SupportedEntry.Event, parent: null }, - }); - }); -}); - -describe('swap() mutation', () => { - it('should correctly swap data between events', () => { - const rundown = makeRundown({ - order: ['1', '2', '3'], - entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1', timeStart: 1, revision: 4 }), - '2': makeOntimeEvent({ id: '2', cue: 'data2', timeStart: 2, revision: 8 }), - '3': makeOntimeEvent({ id: '3', cue: 'data3', timeStart: 3, revision: 12 }), - }, - }); - - // swap first and second event - const { newRundown } = swap({ - rundown: rundown, - fromId: rundown.order[0], - toId: rundown.order[1], - }); - - expect(newRundown.order).toStrictEqual(['1', '2', '3']); - - expect(newRundown.entries['1']).toMatchObject({ - id: '1', - cue: 'data2', - timeStart: 1, - revision: 5, - }); - - expect(newRundown.entries['2']).toMatchObject({ - id: '2', - cue: 'data1', - timeStart: 2, - revision: 9, - }); - - expect(newRundown.entries['3']).toMatchObject({ - id: '3', - cue: 'data3', - timeStart: 3, - revision: 12, - }); - }); -}); - describe('custom fields flow', () => { describe('createCustomField()', () => { it('creates a field from given parameters', () => { diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts index 81bc3ac11..9586d22d1 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts @@ -1,11 +1,5 @@ -import { CustomFields, EndAction, OntimeEvent, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types'; -import { - addToCustomAssignment, - calculateDayOffset, - handleCustomField, - hasChanges, - isDataStale, -} from '../rundownCache.utils.js'; +import { CustomFields, SupportedEntry } from 'ontime-types'; +import { addToCustomAssignment, calculateDayOffset, handleCustomField } from '../rundownCache.utils.js'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; @@ -135,55 +129,6 @@ describe('handleCustomField()', () => { }); }); -describe('isDataStale()', () => { - it('is stale if data contains timers', () => { - const needsRecompute = [ - { timeStart: 10 }, - { timeEnd: 10 }, - { duration: 10 }, - { linkStart: true }, - { timerStrategy: TimeStrategy.LockDuration }, - ]; - - for (const testCase of needsRecompute) { - expect(isDataStale(testCase)).toBe(true); - } - expect.assertions(needsRecompute.length); - }); - - it('is not stale if data contains auxiliary dataset', () => { - expect( - isDataStale({ - cue: 'cue', - title: 'title', - note: 'note', - endAction: EndAction.LoadNext, - timerType: TimerType.Clock, - isPublic: false, - colour: 'colour', - timeWarning: 1, - timeDanger: 2, - custom: { - lighting: '3', - }, - }), - ).toBe(false); - }); -}); - -describe('hasChanges()', () => { - it('identifies objects with new values', () => { - const newEvent = { id: '1', title: 'new-title' } as OntimeEvent; - const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent; - expect(hasChanges(existing, newEvent)).toBe(true); - }); - it('identifies objects with all same values', () => { - const newEvent = { id: '1', title: 'title' } as OntimeEvent; - const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent; - expect(hasChanges(existing, newEvent)).toBe(false); - }); -}); - describe('calculateDayOffset', () => { it('returns 0 if there is no previous event', () => { expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts index eefd30140..242631c01 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts @@ -1,26 +1,24 @@ +import { makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js'; import { getPreviousId } from '../rundownUtils.js'; -// Mock cache module -vi.mock('../rundownCache.js', () => ({ - getEventOrder: () => ({ - flatOrder: ['a', 'b', 'c', 'd'], - }), -})); - describe('getPreviousId', () => { + const rundown = makeRundown({ + flatOrder: ['a', 'b', 'c', 'd'], + }); + it('returns afterId if provided', () => { - expect(getPreviousId('b')).toBe('b'); + expect(getPreviousId(rundown, 'b')).toBe('b'); }); it('returns the previous id before beforeId if provided', () => { - expect(getPreviousId(undefined, 'c')).toBe('b'); + expect(getPreviousId(rundown, undefined, 'c')).toBe('b'); }); it('returns undefined if neither afterId nor beforeId is provided', () => { - expect(getPreviousId()).toBeUndefined(); + expect(getPreviousId(rundown)).toBeNull(); }); it('returns undefined if beforeId is not found', () => { - expect(getPreviousId(undefined, 'z')).toBeUndefined(); + expect(getPreviousId(rundown, undefined, 'z')).toBeNull(); }); }); diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts deleted file mode 100644 index b5ed59e7c..000000000 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Rundown, EntryId, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types'; -import { deleteAtIndex } from 'ontime-utils'; - -/** - * Applies delay from given event ID, deletes the delay event after - * Mutates the given rundown - * @throws if event ID not found or is not a delay - */ -export function apply(delayId: EntryId, rundown: Rundown): Rundown { - const delayEvent = rundown.entries[delayId]; - - if (!delayEvent || !isOntimeDelay(delayEvent)) { - throw new Error('Given delay ID not found'); - } - - const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId); - - // if the delay is empty, or the last element - // we can just delete it with no further operations - if (delayEvent.duration === 0 || delayIndex === rundown.order.length - 1) { - delete rundown.entries[delayId]; - rundown.order = deleteAtIndex(delayIndex, rundown.order); - return rundown; - } - - /** - * We apply the delay to the rundown - * This logic is mostly in sync with rundownCache.generate - * The difference is that here it will become part of the schedule, - * so we cant leave the work for the generate function - */ - let delayValue = delayEvent.duration; - let lastEntry: OntimeEvent | null = null; - let isFirstEvent = true; - - for (let i = delayIndex + 1; i < rundown.order.length; i++) { - const currentId = rundown.order[i]; - const currentEntry = rundown.entries[currentId]; - - // we don't do operation on other event types - if (!isOntimeEvent(currentEntry)) { - continue; - } - - // we need to remove the link in the first event to maintain the gap - let shouldUnlink = isFirstEvent; - isFirstEvent = false; - - // if the event is not linked, we try and maintain gaps - if (lastEntry !== null) { - // when applying negative delays, we need to unlink the event - // if the previous event was fully consumed by the delay - if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) { - shouldUnlink = true; - } - - if (currentEntry.gap > 0) { - delayValue = Math.max(delayValue - currentEntry.gap, 0); - } - - if (delayValue === 0) { - // we can bail from continuing if there are no further delays to apply - break; - } - } - - // save the current entry before making mutations on its values - lastEntry = { ...currentEntry }; - - if (shouldUnlink) { - currentEntry.linkStart = false; - shouldUnlink = false; - } - - // event times move up by the delay value - // we dont update the delay value since we would need to iterate through the entire dataset - // this is handled by the rundownCache.generate function - currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue); - currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue); - currentEntry.revision += 1; - } - - delete rundown.entries[delayId]; - rundown.order = deleteAtIndex(delayIndex, rundown.order); - rundown.revision += 1; - - return rundown; -} diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 23f090ae0..d48a6895f 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -7,27 +7,16 @@ import { isOntimeEvent, isPlayableEvent, OntimeBlock, - OntimeEvent, OntimeEntry, Rundown, RundownEntries, - OntimeDelay, } from 'ontime-types'; -import { generateId, insertAtIndex, swapEventData, customFieldLabelToKey, mergeAtIndex } from 'ontime-utils'; +import { generateId, insertAtIndex, customFieldLabelToKey } from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js'; -import type { RundownMetadata } from './rundown.types.js'; -import { apply } from './delayUtils.js'; -import { - cloneBlock, - cloneEntry, - hasChanges, - isDataStale, - makeRundownMetadata, - type ProcessedRundownMetadata, -} from './rundownCache.utils.js'; +import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; +import { makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js'; let currentRundownId: EntryId = ''; let currentRundown: Rundown = { @@ -373,370 +362,6 @@ export function add({ rundown, afterId, parent, entry }: AddArgs): Required; -/** - * Remove entries in a rundown - * It handles element relationships specifically when dealing with nested items - * - when removing a nested item, remove the reference from the parent block - * - when removing a block, remove all nested items - */ -export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { - /** - * changelist will hold a list of entries that need to be removed - * it will then be returned to the caller as a list of actually deleted entries - */ - const changeList: EntryId[] = []; - - for (let i = 0; i < eventIds.length; i++) { - const entry = rundown.entries[eventIds[i]]; - // add the top level entry to the changeList - changeList.push(entry.id); - - if (isOntimeBlock(entry)) { - // for ontime blocks, we need to iterate through the children and delete them - changeList.concat([...entry.events]); - } else if (entry.parent) { - // at this point, we are handling entries inside a block, so we need to remove the references - const parentBlock = rundown.entries[entry.parent] as OntimeBlock; - const parentEvents = parentBlock.events.filter((id) => id !== eventIds[i]); - - // we call a mutation to the parent event to - // - remove this entry from the events - // - reduce the children count - edit({ - rundown, - eventId: entry.parent, - patch: { - events: parentEvents, - }, - }); - } - } - - // delete all entries in the changeList - for (let i = 0; i < changeList.length; i++) { - const entryId = changeList[i]; - rundown.order = rundown.order.filter((id) => id !== entryId); - rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); - delete rundown.entries[entryId]; - } - - const didMutate = changeList.length > 0; - if (didMutate) setIsStale(); - return { newRundown: rundown, didMutate, changeList }; -} - -/** - * Remove all entries of a rundown - */ -export function removeAll(): MutatingReturn { - setIsStale(); - return { - newRundown: { - id: '', - title: '', - order: [], - flatOrder: [], - entries: {}, - revision: 0, - }, - didMutate: true, - }; -} - -/** - * Utility function for patching an existing event with new data - */ -function makeEvent(eventFromRundown: T, patch: Partial): T { - if (isOntimeEvent(eventFromRundown)) { - const newEvent = createPatch(eventFromRundown, patch as Partial); - newEvent.revision++; - return newEvent as T; - } - if (isOntimeBlock(eventFromRundown)) { - const newEvent: OntimeBlock = { ...eventFromRundown, ...patch }; - newEvent.revision++; - return newEvent as T; - } - - return { ...eventFromRundown, ...patch } as T; -} - -type EditArgs = MutationParams<{ eventId: EntryId; patch: Partial }>; -/** - * Apply patch to an entry with given id - */ -export function edit({ rundown, eventId, patch }: EditArgs): Required { - const entry = rundown.entries[eventId]; - if (!entry) { - // there should be no reason for the entry not to be found - // check if it exists in the rundown order - rundown.order = rundown.order.filter((id) => id !== eventId); - throw new Error('Entry not found'); - } - - // we cannot allow patching to a different type - if (patch?.type && entry.type !== patch.type) { - throw new Error('Invalid event type'); - } - - // if nothing changed, nothing to do - if (!hasChanges(entry, patch)) { - return { newRundown: rundown, changeList: [eventId], newEvent: entry, didMutate: false }; - } - - const newEvent = makeEvent(entry, patch); - rundown.entries[newEvent.id] = newEvent; - - // check whether the data warrants recalculation of cache - const makeStale = isDataStale(patch); - - if (makeStale) { - setIsStale(); - } else { - rundown.entries[newEvent.id] = newEvent; - } - - return { newRundown: rundown, changeList: [newEvent.id], newEvent, didMutate: true }; -} - -type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial }>; -/** - * Apply patch to multiple entries - */ -export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn { - for (const eventId of eventIds) { - edit({ rundown, eventId, patch }); - } - return { newRundown: rundown, didMutate: true }; -} - -type ReorderArgs = MutationParams<{ - entryId: EntryId; - destinationId: EntryId; - order: 'before' | 'after' | 'insert'; -}>; -/** - * Moves an event to a new position in the rundown - * Handles moving across root orders (a block order and top level order) - * @throws if entryId or destinationId not found - * @throws if trying to insert an event into a block inside another block - */ -export function reorder({ rundown, entryId, destinationId, order }: ReorderArgs): Required { - const eventFrom = rundown.entries[entryId]; - const eventTo = rundown.entries[destinationId]; - - if (!eventFrom || !eventTo) { - throw new Error('Event not found'); - } - - const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null; - const toParent = (() => { - if (isOntimeBlock(eventTo)) { - if (order === 'insert') { - return eventTo.id; - } - return null; - } - return eventTo.parent ?? null; - })(); - - if (!isOntimeBlock(eventFrom)) { - eventFrom.parent = toParent; - } - - const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events; - const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events; - - const fromIndex = sourceArray.indexOf(entryId); - const toIndex = (() => { - const baseIndex = destinationArray.indexOf(destinationId); - if (order === 'before') return baseIndex; - // only add one if we are moving down - if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1); - // for insert we add in the end of the array - return destinationArray.length; - })(); - - // Remove from source array - sourceArray.splice(fromIndex, 1); - // Insert into destination array - destinationArray.splice(toIndex, 0, entryId); - - // changelist is derived from the flat order - const changeList = rundown.flatOrder.slice(Math.min(fromIndex, toIndex), rundown.flatOrder.length); - - return { newRundown: rundown, changeList, newEvent: eventFrom, didMutate: true }; -} - -type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>; -/** - * Apply a delay - * Mutates the given rundown - */ -export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn { - apply(delayId, rundown); - setIsStale(); - return { newRundown: rundown, didMutate: true }; -} - -type CloneEntryArgs = MutationParams<{ entryId: EntryId }>; -/** - * Apply a delay - * Mutates the given rundown - */ -export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn { - const entry = rundown.entries[entryId]; - if (!entry) { - throw new Error('Entry not found'); - } - - if (isOntimeBlock(entry)) { - const newBlock = cloneBlock(entry, getUniqueId()); - const nestedIds: EntryId[] = []; - - for (let i = 0; i < entry.events.length; i++) { - const nestedEntryId = entry.events[i]; - const nestedEntry = rundown.entries[nestedEntryId]; - if (!nestedEntry) { - continue; - } - - // clone the event and assign it to the new block - const newNestedEntry = cloneEntry(nestedEntry, getUniqueId()); - (newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id; - - nestedIds.push(newNestedEntry.id); - // we immediately insert the nested entries into the rundown - rundown.entries[newNestedEntry.id] = newNestedEntry; - } - // indexes + 1 since we are inserting after the cloned block - const atIndex = rundown.order.indexOf(entryId) + 1; - // we need to find the index of the last entry - const lastNestedIdInOriginal = entry.events.at(-1) ?? '0'; - const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1; - - newBlock.events = nestedIds; - newBlock.title = `${entry.title || 'Untitled'} (copy)`; - - rundown.entries[newBlock.id] = newBlock; - rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order); - rundown.flatOrder = mergeAtIndex(flatIndex, [newBlock.id, ...nestedIds], rundown.flatOrder); - - return { newRundown: rundown, didMutate: true, newEvent: newBlock }; - } else { - return add({ rundown, afterId: entryId, parent: entry.parent, entry: cloneEntry(entry, getUniqueId()) }); - } -} - -type UngroupArgs = MutationParams<{ blockId: EntryId }>; -/** - * Deletes a block and moves all its children to the top level order - * Mutates the given rundown - * @throws if block ID not found - */ -export function ungroup({ rundown, blockId }: UngroupArgs): MutatingReturn { - const block = rundown.entries[blockId]; - if (!isOntimeBlock(block)) { - throw new Error('Block with ID not found'); - } - - // get the events from the block and merge them into the order where the block was - const nestedEvents = block.events; - const blockIndex = rundown.order.indexOf(blockId); - rundown.order.splice(blockIndex, 1, ...nestedEvents); - rundown.flatOrder = rundown.flatOrder.filter((id) => id !== blockId); - - // delete block from entries and remove its reference from the child events - delete rundown.entries[blockId]; - for (let i = 0; i < nestedEvents.length; i++) { - const eventId = nestedEvents[i]; - const entry = rundown.entries[eventId]; - if (!entry) { - throw new Error('Entry not found'); - } - (entry as OntimeEvent | OntimeDelay).parent = null; - } - - return { newRundown: rundown, didMutate: true }; -} - -type GroupArgs = MutationParams<{ entryIds: EntryId[] }>; -/** - * Groups a list of entries into a block - * It ensures that the entries get reassigned parent and the block gets a list of events - * The block will be created at the index of the first event in the order, not at the lowest index - * Mutates the given rundown - * @throws if any of the entries is a block - * @throws if any of the entries is not found - */ -export function groupEntries({ rundown, entryIds }: GroupArgs): MutatingReturn { - const block = createBlock({ id: getUniqueId() }); - - const nestedEvents: EntryId[] = []; - let firstIndex = -1; - for (let i = 0; i < entryIds.length; i++) { - const entryId = entryIds[i]; - const entry = rundown.entries[entryId]; - if (!entry) { - throw new Error('Entry not found'); - } - - if (isOntimeBlock(entry)) { - throw new Error('Cannot group a block'); - } - - if (entry.parent !== null) { - throw new Error('Entry already has a parent'); - } - - // the block will be created at the first selected event position - // note that this is not the lowest index - if (firstIndex === -1) { - firstIndex = rundown.flatOrder.indexOf(entryId); - } - - nestedEvents.push(entryId); - entry.parent = block.id; - rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); - rundown.order = rundown.order.filter((id) => id !== entryId); - } - - block.events = nestedEvents; - const insertIndex = Math.max(0, firstIndex); - // we have filtered the items from the order - // we will insert them now, with only the block at top level ... - rundown.order = insertAtIndex(insertIndex, block.id, rundown.order); - /// ... and the nested elements after the block in the flat order - rundown.flatOrder = mergeAtIndex(insertIndex, [block.id, ...nestedEvents], rundown.flatOrder); - rundown.entries[block.id] = block; - - return { newRundown: rundown, didMutate: true }; -} - -type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; -/** - * Swap two entries - */ -export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn { - const fromEvent = rundown.entries[fromId]; - const toEvent = rundown.entries[toId]; - - if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) { - throw new Error('Swap only available for OntimeEvents'); - } - - const [newFrom, newTo] = swapEventData(fromEvent, toEvent); - - rundown.entries[fromId] = newFrom; - rundown.entries[toId] = newTo; - newFrom.revision++; - newTo.revision++; - - setIsStale(); - return { newRundown: rundown, didMutate: true }; -} - /** * Utility for invalidating service cache if a custom field is used */ diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index 672430a8c..073a39f8f 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -3,19 +3,16 @@ import { CustomFieldLabel, CustomFields, OntimeEntry, - OntimeBaseEvent, EntryId, isOntimeEvent, isPlayableEvent, isOntimeDelay, PlayableEvent, RundownEntries, - OntimeDelay, - OntimeBlock, } from 'ontime-types'; import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; -import type { RundownMetadata } from './rundown.types.js'; +import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; /** * Utility function to add an entry, mutates given assignedCustomFields in place @@ -65,49 +62,6 @@ export function handleCustomField( } } -/** List of event properties which do not need the rundown to be regenerated */ -enum RegenerateWhitelist { - 'id', - 'cue', - 'title', - 'note', - 'endAction', - 'timerType', - 'countToEnd', - 'isPublic', - 'colour', - 'timeWarning', - 'timeDanger', - 'custom', - 'triggers', -} - -/** - * given a patch, returns whether all keys are whitelisted - */ -export function isDataStale(patch: Partial): boolean { - return Object.keys(patch).some(willCauseRegeneration); -} - -/** - * given a key, returns whether it is whitelisted - */ -export function willCauseRegeneration(key: string): boolean { - return !(key in RegenerateWhitelist); -} - -/** - * Given an event and a patch to that event checks whether there are actual changes to the dataset - * @param existingEvent - * @param newEvent - * @returns - */ -export function hasChanges(existingEvent: T, newEvent: Partial): boolean { - return Object.keys(newEvent).some( - (key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T], - ); -} - /** * Utility for calculating if the current events should have a day offset * @param current the current event under test @@ -149,6 +103,10 @@ export type ProcessedRundownMetadata = RundownMetadata & { previousEntry: OntimeEntry | null; // The entry processed in the previous iteration }; +/** + * Factory function to create a rundown metadata processor + * @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata + */ export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record) { let rundownMeta: ProcessedRundownMetadata = { totalDelay: 0, @@ -185,6 +143,9 @@ export function makeRundownMetadata(customFields: CustomFields, customFieldChang return { process, getMetadata }; } +/** + * Processes a single entry and updates the rundown metadata + */ function processEntry( rundownMetadata: ProcessedRundownMetadata, customFields: CustomFields, @@ -288,37 +249,3 @@ function processEntry( return { processedData, processedEntry: currentEntry }; } - -export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent { - const newEntry = structuredClone(entry); - newEntry.id = newId; - newEntry.revision = 0; - return newEntry; -} - -export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay { - const newEntry = structuredClone(entry); - newEntry.id = newId; - return newEntry; -} - -export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock { - const newEntry = structuredClone(entry); - newEntry.id = newId; - - // in blocks, we need to remove the events references - newEntry.events = []; - newEntry.revision = 0; - return newEntry; -} - -export function cloneEntry(entry: T, newId: EntryId): T { - if (isOntimeEvent(entry)) { - return cloneEvent(entry, newId) as T; - } else if (isOntimeDelay(entry)) { - return cloneDelay(entry, newId) as T; - } else if (entry.type === 'block') { - return cloneBlock(entry as OntimeBlock, newId) as T; - } - throw new Error(`Unsupported entry type for cloning: ${entry}`); -} diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index ac51f62c3..2d917d6ab 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -8,21 +8,16 @@ import { ProjectRundowns, } from 'ontime-types'; -import * as cache from './rundownCache.js'; +import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; -/** - * returns entire unfiltered rundown - */ -export function getCurrentRundown(): Rundown { - return cache.getCurrentRundown(); -} +import * as cache from './rundownCache.js'; /** * returns the the project rundown and the order arrays */ export function getRundownData() { return { - rundown: cache.getCurrentRundown(), + rundown: getCurrentRundown(), rundownOrder: cache.getEventOrder(), }; } @@ -178,17 +173,16 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): * Receives an insertion order and returns the reference to an event ID * after which we will insert the new event */ -export function getPreviousId(afterId?: EntryId, beforeId?: EntryId): EntryId | undefined { +export function getPreviousId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null { if (afterId) { return afterId; } if (beforeId) { - const flatOrder = cache.getEventOrder().flatOrder; - const atIndex = flatOrder.findIndex((id) => id === beforeId); - if (atIndex < 1) return undefined; - return flatOrder[atIndex - 1]; + const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId); + if (atIndex < 1) return null; + return rundown.flatOrder[atIndex - 1]; } - return; + return null; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 051aae0c6..6bd236295 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -23,6 +23,7 @@ import { eventStore } from '../../stores/EventStore.js'; import { triggerReportEntry } from '../../api-data/report/report.service.js'; import { timerConfig } from '../../setup/config.js'; import { triggerAutomations } from '../../api-data/automation/automation.service.js'; +import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { EventTimer } from '../EventTimer.js'; import { RestorePoint, restoreService } from '../RestoreService.js'; @@ -32,7 +33,6 @@ import { getEventAtIndex, getNextEventWithCue, getEntryWithId, - getCurrentRundown, getTimedEvents, getRundownData, } from '../rundown-service/rundownUtils.js'; diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index 19d9bbcea..842b1d110 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -15,8 +15,8 @@ import got from 'got'; import { parseExcel } from '../../utils/parser.js'; import { logger } from '../../classes/Logger.js'; import { parseRundowns } from '../../api-data/rundown/rundown.parser.js'; +import { getCurrentRundown, getProjectCustomFields } from '../../api-data/rundown/rundown.dao.js'; -import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js'; import { getRundownOrThrow } from '../rundown-service/rundownUtils.js'; import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js'; @@ -323,7 +323,7 @@ export async function upload(sheetId: string, options: ImportMap) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); } - const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), 'not-used', options); + const { rundownMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options); const rundown = getCurrentRundown(); const titleRow = Object.values(rundownMetadata)[0]['row']; const updateRundown = Array(); @@ -409,7 +409,7 @@ export async function download( throw new Error('Sheet: No data found in the worksheet'); } - const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options); + const dataFromSheet = parseExcel(googleResponse.data.values, getProjectCustomFields(), 'Rundown', options); const rundownId = dataFromSheet.rundown.id; const dataModel: Pick = { diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 2d78f35dc..f7510dcc7 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1,11 +1,7 @@ import { PlayableEvent, Playback, TimerPhase } from 'ontime-types'; -import { initRundown } from '../../services/rundown-service/RundownService.js'; -import { - makeOntimeBlock, - makeOntimeEvent, - makeRundown, -} from '../../services/rundown-service/__mocks__/rundown.mocks.js'; +import { makeOntimeBlock, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js'; +import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { type RuntimeState, @@ -71,18 +67,6 @@ beforeAll(() => { }); describe('mutation on runtimeState', () => { - beforeEach(async () => { - clearState(); - vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => { - const actual = (await importOriginal()) as object; - - return { - ...actual, - initRunddown: vi.fn().mockReturnValue(undefined), - }; - }); - }); - afterEach(() => { vi.clearAllMocks(); }); diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index aa4ab490d..377da48d0 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -1,7 +1,7 @@ -import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js'; import type { OntimeEntry } from '../../definitions/core/Rundown.type.js'; -export type PatchWithId = Partial & { id: string }; +export type PatchWithId = Partial & { id: string }; + export type EventPostPayload = Partial & { after?: string; before?: string; diff --git a/packages/utils/src/cue-utils/cueUtils.test.ts b/packages/utils/src/cue-utils/cueUtils.test.ts index c77d48e20..78dbe7653 100644 --- a/packages/utils/src/cue-utils/cueUtils.test.ts +++ b/packages/utils/src/cue-utils/cueUtils.test.ts @@ -40,7 +40,7 @@ describe('getCueCandidate()', () => { '1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '11', type: SupportedEntry.Event } as OntimeEvent, }; - const cue = getCueCandidate(entries, ['1', '2']); + const cue = getCueCandidate(entries, ['1', '2'], null); expect(cue).toBe('1'); }); @@ -49,7 +49,7 @@ describe('getCueCandidate()', () => { '1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent, }; - const cue = getCueCandidate(entries, ['1', '2']); + const cue = getCueCandidate(entries, ['1', '2'], null); expect(cue).toBe('0.1'); }); }); @@ -124,7 +124,7 @@ describe('findCueName() with mixed events', () => { '1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '11', type: SupportedEntry.Event } as OntimeEvent, }; - const cue = getCueCandidate(entries, ['1', '2']); + const cue = getCueCandidate(entries, ['1', '2'], null); expect(cue).toBe('1'); }); @@ -133,7 +133,7 @@ describe('findCueName() with mixed events', () => { '1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent, }; - const cue = getCueCandidate(entries, ['1', '2']); + const cue = getCueCandidate(entries, ['1', '2'], null); expect(cue).toBe('0.1'); }); }); diff --git a/packages/utils/src/cue-utils/cueUtils.ts b/packages/utils/src/cue-utils/cueUtils.ts index 632b14840..f078a1a36 100644 --- a/packages/utils/src/cue-utils/cueUtils.ts +++ b/packages/utils/src/cue-utils/cueUtils.ts @@ -41,9 +41,9 @@ export function getIncrement(input: string): string { /** * Gets suitable name for a new event cue */ -export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId?: string): string { +export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId: EntryId | null): string { // we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1 - if (insertAfterId === undefined || order.length === 0) { + if (insertAfterId === null || order.length === 0) { return addAtTop(); }