From 72c6b499a872954c1b3565adad04eb63c31c1f10 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 1 Oct 2024 21:49:49 +0200 Subject: [PATCH] fix: apply delay --- .../__tests__/delayUtils.test.ts | 430 ++++++++---------- .../services/rundown-service/delayUtils.ts | 80 +++- 2 files changed, 247 insertions(+), 263 deletions(-) diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts index 5454d860b0..69aa2df44f 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -1,249 +1,193 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; import { apply } from '../delayUtils.js'; -describe('apply() ', () => { - describe('in a rundown without the delay field, persisted rundown', () => { - it('applies delays', () => { - const delayId = '1'; - const testRundown: OntimeRundown = [ - { id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, - { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - ]; - - const expected = [ - { id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - ]; - - const updatedRundown = apply(delayId, testRundown); - expect(updatedRundown).toStrictEqual(expected); - }); - it('applies negative delays', () => { - const delayId = '1'; - const testRundown: OntimeRundown = [ - { id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, - { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - ]; - - const expected = [ - { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - ]; - - const updatedRundown = apply(delayId, testRundown); - expect(updatedRundown).toStrictEqual(expected); - }); - it('maintains constant duration', () => { - const delayId = '1'; - const testRundown: OntimeRundown = [ - { id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, - { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, - ]; - - const expected = [ - { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent, - ]; - - const updatedRundown = apply(delayId, testRundown); - expect(updatedRundown).toStrictEqual(expected); - }); +/** + * Small utility to fill in the necessary data for the test + */ +function makeOntimeEvent(event: Partial): OntimeEvent { + return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent; +} + +/** + * Small utility to make a delay event + */ +function makeOntimeDelay(duration: number): OntimeDelay { + return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay; +} + +describe('apply()', () => { + it('applies a positive delay to the rundown', () => { + const testRundown = [ + makeOntimeDelay(10), + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + { id: '3', type: SupportedEvent.Block } as OntimeBlock, + makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), + makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + ]; + + const updatedRundown = apply('delay', testRundown); + expect(updatedRundown).toMatchObject([ + { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, + { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, + { id: '3' }, + { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, + { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, + ]); + }); + + it('applies negative delays', () => { + const testRundown = [ + makeOntimeDelay(-10), + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + { id: '3', type: SupportedEvent.Block } as OntimeBlock, + makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), + makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + ]; + + const updatedRundown = apply('delay', testRundown); + expect(updatedRundown).toMatchObject([ + { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, + { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, + { id: '3' }, + { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, + { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, + ]); + }); + + it('should account for minimum duration and start when applying negative delays', () => { + const testRundown: OntimeRundown = [ + makeOntimeDelay(-50), + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), + ]; + + const expected = [ + { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 50, + timeEnd: 100, + duration: 50, + linkStart: null, + revision: 2, + } as OntimeEvent, + ]; + + const updatedRundown = apply('delay', testRundown); + expect(updatedRundown).toMatchObject(expected); }); - describe('in a rundown with the delay field, cached rundown', () => { - it('applies delays', () => { - const delayId = '1'; - const testRundown: OntimeRundown = [ - { id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, - { - id: '2', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: 10, - } as OntimeEvent, - { - id: '3', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: 10, - } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { - id: '5', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: 0, - } as OntimeEvent, - ]; - - const expected = [ - { - id: '2', - type: SupportedEvent.Event, - timeStart: 10, - timeEnd: 20, - duration: 10, - revision: 2, - delay: 0, - } as OntimeEvent, - { - id: '3', - type: SupportedEvent.Event, - timeStart: 10, - timeEnd: 20, - duration: 10, - revision: 2, - delay: 0, - } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { - id: '5', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: 0, - } as OntimeEvent, - ]; - - const updatedRundown = apply(delayId, testRundown); - expect(updatedRundown).toStrictEqual(expected); - }); - it('applies negative delays', () => { - const delayId = '1'; - const testRundown: OntimeRundown = [ - { id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, - { - id: '2', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: -10, - } as OntimeEvent, - { - id: '3', - type: SupportedEvent.Event, - timeStart: 20, - timeEnd: 40, - duration: 20, - revision: 1, - delay: -10, - } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { - id: '5', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: 0, - } as OntimeEvent, - ]; - - const expected = [ - { - id: '2', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 2, - delay: 0, - } as OntimeEvent, - { - id: '3', - type: SupportedEvent.Event, - timeStart: 10, - timeEnd: 30, - duration: 20, - revision: 2, - delay: 0, - } as OntimeEvent, - { id: '4', type: SupportedEvent.Block } as OntimeBlock, - { - id: '5', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: 0, - } as OntimeEvent, - ]; - - const updatedRundown = apply(delayId, testRundown); - expect(updatedRundown).toStrictEqual(expected); - }); - it('maintains constant duration', () => { - const delayId = '1'; - const testRundown: OntimeRundown = [ - { id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, - { - id: '2', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 1, - delay: -30, - } as OntimeEvent, - { - id: '3', - type: SupportedEvent.Event, - timeStart: 20, - timeEnd: 40, - duration: 20, - revision: 1, - delay: -30, - } as OntimeEvent, - ]; - - const expected = [ - { - id: '2', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 10, - duration: 10, - revision: 2, - delay: 0, - } as OntimeEvent, - { - id: '3', - type: SupportedEvent.Event, - timeStart: 0, - timeEnd: 20, - duration: 20, - revision: 2, - delay: 0, - } as OntimeEvent, - ]; - - const updatedRundown = apply(delayId, testRundown); - expect(updatedRundown).toStrictEqual(expected); - }); + + it('unlinks events to maintain gaps when applying positive delays', () => { + const testRundown = [ + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + makeOntimeDelay(50), + makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + ]; + + expect(apply('delay', testRundown)).toMatchObject([ + { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 150, + timeEnd: 200, + duration: 50, + linkStart: null, + revision: 2, + } as OntimeEvent, + ]); + }); + + it('maintains links if there is no gap', () => { + const testRundown = [ + makeOntimeDelay(50), + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + ]; + + expect(apply('delay', testRundown)).toMatchObject([ + { id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 150, + timeEnd: 200, + duration: 50, + linkStart: '1', + revision: 2, + } as OntimeEvent, + ]); + }); + + it('unlinks events to maintain gaps when applying negative delays', () => { + const testRundown = [ + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + makeOntimeDelay(-50), + makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + ]; + + expect(apply('delay', testRundown)).toMatchObject([ + { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 50, + timeEnd: 100, + duration: 50, + linkStart: null, + revision: 2, + } as OntimeEvent, + ]); + }); + + it('gaps reduce positive delay', () => { + const testRundown: OntimeRundown = [ + makeOntimeDelay(100), + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + // gap 50 + makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }), + // gap 50 + makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }), + // gap 50 + makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }), + // linked + makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), + ]; + + const updatedRundown = apply('delay', testRundown); + expect(updatedRundown).toMatchObject([ + { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, + // gap 50 (100 - 50) + { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, + // gap 50 (50 - 50) + { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 }, + // gap (delay is 0) + { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, + // linked + { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' }, + ]); + }); + + it('removes empty delays without applying changes', () => { + const testRundown: OntimeRundown = [ + makeOntimeDelay(0), + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + ]; + + const updatedRundown = apply('delay', testRundown); + expect(updatedRundown).toMatchObject([makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 })]); + }); + + it('removes delays in last position without applying changes', () => { + const testRundown: OntimeRundown = [ + makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + makeOntimeDelay(100), + ]; + + const updatedRundown = apply('delay', testRundown); + expect(updatedRundown).toMatchObject([makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 })]); }); }); diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index e5805aa855..a2dcf5188e 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -1,6 +1,7 @@ -import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types'; +import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js'; +import { getTimeFromPrevious } from 'ontime-utils'; /** * Calculates all delays in a given rundown @@ -92,10 +93,7 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number { /** * Applies delay from given event ID, deletes the delay event after - * @param eventId - * @param rundown * @throws {Error} if event ID not found or is not a delay - * @returns */ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { const delayIndex = rundown.findIndex((event) => event.id === eventId); @@ -109,27 +107,69 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { throw new Error('Given event ID is not a delay'); } - const updatedRundown = [...rundown]; - const delayValue = delayEvent.duration; - - if (delayValue === 0 || delayIndex === rundown.length - 1) { - // nothing to apply - return updatedRundown; + // if the delay is empty, or the last element, we can just delete it + if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) { + return deleteAtIndex(delayIndex, rundown); } + /** + * We apply the delay to the rundown + * This logic is mostly in sync with rundownCache.generate + */ + const updatedRundown = structuredClone(rundown); + let delayValue = delayEvent.duration; + let lastEntry: OntimeEvent | null = null; + for (let i = delayIndex + 1; i < rundown.length; i++) { - const currentEvent = updatedRundown[i]; - - if (isOntimeBlock(currentEvent)) { - break; - } else if (isOntimeEvent(currentEvent)) { - currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue); - currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue); - if (currentEvent.delay) { - currentEvent.delay = currentEvent.delay - delayValue; + const currentEntry = updatedRundown[i]; + + // we need to remove the link in the first event to maintain the gap + let shouldUnlink = i === delayIndex + 1; + + // we don't do operation on other event types + if (!isOntimeEvent(currentEntry)) { + continue; + } + + // if the event is not linked, we try and maintain gaps + if (lastEntry !== null && currentEntry.linkStart !== null) { + const timeFromPrevious: number = getTimeFromPrevious( + currentEntry.timeStart, + lastEntry.timeStart, + lastEntry.timeEnd, + lastEntry.duration, + ); + + // when applying negative delays, we need to unlink the event + // if the previous event was fully consumed by the delay + if (delayValue < 0 && lastEntry.timeStart + delayValue < 0) { + shouldUnlink = true; + } + + if (timeFromPrevious > 0) { + delayValue = Math.max(delayValue - timeFromPrevious, 0); + } + + if (delayValue === 0) { + // we can bail from continuing if there are no further delays to apply + break; } - currentEvent.revision += 1; } + + // save the current entry before making mutations on its values + lastEntry = { ...currentEntry }; + + if (shouldUnlink) { + currentEntry.linkStart = null; + 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; } return deleteAtIndex(delayIndex, updatedRundown);