fix: apply delay

This commit is contained in:
Carlos Valente
2024-10-01 21:49:49 +02:00
committed by Carlos Valente
parent 086bd5b644
commit 7877af571f
2 changed files with 291 additions and 257 deletions
@@ -1,249 +1,243 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { apply } from '../delayUtils.js'; import { apply } from '../delayUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('apply() ', () => { /**
describe('in a rundown without the delay field, persisted rundown', () => { * Small utility to fill in the necessary data for the test
it('applies delays', () => { */
const delayId = '1'; function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
const testRundown: OntimeRundown = [ return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
{ 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, * Small utility to make a delay event
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, */
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, function makeOntimeDelay(duration: number): OntimeDelay {
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
]; }
const updatedRundown = apply(delayId, testRundown); describe('apply()', () => {
expect(updatedRundown).toStrictEqual(expected); it('applies a positive delay to the rundown', () => {
}); const testRundown = [
it('applies negative delays', () => { makeOntimeDelay(10),
const delayId = '1'; makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
const testRundown: OntimeRundown = [ makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
{ id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, ];
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
];
const expected = [ const updatedRundown = apply('delay', testRundown);
{ id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, expect(updatedRundown).not.toBe(testRundown);
{ id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent, expect(updatedRundown).toMatchObject([
{ id: '4', type: SupportedEvent.Block } as OntimeBlock, { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, { 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 },
const updatedRundown = apply(delayId, testRundown); { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' },
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);
});
}); });
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 = [ it('applies negative delays', () => {
{ const testRundown = [
id: '2', makeOntimeDelay(-10),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
timeStart: 10, makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }),
timeEnd: 20, { id: '3', type: SupportedEvent.Block } as OntimeBlock,
duration: 10, makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }),
revision: 2, makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }),
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); const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toStrictEqual(expected); expect(updatedRundown).toMatchObject([
}); { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
it('applies negative delays', () => { { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
const delayId = '1'; { id: '3' },
const testRundown: OntimeRundown = [ { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null },
{ id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' },
{ ]);
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 = [ it('should account for minimum duration and start when applying negative delays', () => {
{ const testRundown: OntimeRundown = [
id: '2', makeOntimeDelay(-50),
type: SupportedEvent.Event, makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
timeStart: 0, makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }),
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); const expected = [
expect(updatedRundown).toStrictEqual(expected); { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent,
}); {
it('maintains constant duration', () => { id: '2',
const delayId = '1'; type: SupportedEvent.Event,
const testRundown: OntimeRundown = [ timeStart: 50,
{ id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, timeEnd: 100,
{ duration: 50,
id: '2', linkStart: null,
type: SupportedEvent.Event, revision: 2,
timeStart: 0, } as OntimeEvent,
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 = [ const updatedRundown = apply('delay', testRundown);
{ expect(updatedRundown).toMatchObject(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); it('unlinks events to maintain gaps when applying positive delays', () => {
expect(updatedRundown).toStrictEqual(expected); 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('gaps reduce positive delay (2)', () => {
const testRundown: OntimeRundown = [
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
}),
// gap 1h
makeOntimeEvent({
id: '2',
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
}),
];
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject([
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
// gap 1h (2h - 1h)
{ id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
]);
});
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([{ 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([{ 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 = [
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeDelay(50),
{ id: 'block', type: SupportedEvent.Block } as OntimeBlock,
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: 'block', type: SupportedEvent.Block },
{
id: '2',
type: SupportedEvent.Event,
timeStart: 150,
timeEnd: 200,
duration: 50,
linkStart: null,
revision: 2,
} as OntimeEvent,
]);
}); });
}); });
@@ -1,6 +1,5 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types'; import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
/** /**
* Calculates all delays in a given rundown * Calculates all delays in a given rundown
@@ -92,10 +91,7 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
/** /**
* Applies delay from given event ID, deletes the delay event after * 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 * @throws {Error} if event ID not found or is not a delay
* @returns
*/ */
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId); const delayIndex = rundown.findIndex((event) => event.id === eventId);
@@ -109,27 +105,71 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
throw new Error('Given event ID is not a delay'); throw new Error('Given event ID is not a delay');
} }
const updatedRundown = [...rundown]; // if the delay is empty, or the last element, we can just delete it
const delayValue = delayEvent.duration; if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) {
return deleteAtIndex(delayIndex, rundown);
if (delayValue === 0 || delayIndex === rundown.length - 1) {
// nothing to apply
return updatedRundown;
} }
for (let i = delayIndex + 1; i < rundown.length; i++) { /**
const currentEvent = updatedRundown[i]; * 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;
let isFirstEvent = true;
if (isOntimeBlock(currentEvent)) { for (let i = delayIndex + 1; i < updatedRundown.length; i++) {
break; const currentEntry = updatedRundown[i];
} else if (isOntimeEvent(currentEvent)) {
currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue); // we don't do operation on other event types
currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue); if (!isOntimeEvent(currentEntry)) {
if (currentEvent.delay) { continue;
currentEvent.delay = currentEvent.delay - delayValue;
}
currentEvent.revision += 1;
} }
// 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) {
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 (currentEntry.linkStart && 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;
}
}
// 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); return deleteAtIndex(delayIndex, updatedRundown);