import { type Day, CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry, TimeStrategy, } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; import { demoDb } from '../../../models/demoProject.js'; import { makeCustomField, makeOntimeDelay, makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown, } from '../__mocks__/rundown.mocks.js'; import { createTransaction, customFieldMutation, processRundown, rundownCache, rundownMutation, } from '../rundown.dao.js'; import { type ProcessedRundownMetadata } from '../rundown.parser.js'; const setRundownMock = vi.fn(); const setCustomFieldsMock = vi.fn(); vi.mock('../../../classes/data-provider/DataProvider.js', () => { return { getDataProvider: vi.fn().mockImplementation(() => { return { setRundown: setRundownMock, setCustomFields: setCustomFieldsMock, }; }), }; }); afterAll(() => { vi.clearAllMocks(); }); describe('createTransaction', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers(); }); it('should return a snapshot of the cached data and an commit function', () => { const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true }); expect(rundown).toBeDefined(); expect(customFields).toBeDefined(); expect(typeof commit).toBe('function'); }); it('should return the updated data after commit is called and writes are scheduled', () => { const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true }); rundown.title = 'Another Title'; customFields['newField'] = { label: 'New Field', type: 'text', colour: 'blue', }; const updated = commit(); vi.runAllTimers(); expect(updated.rundown.title).toBe('Another Title'); expect(updated.customFields).toHaveProperty('newField'); expect(setRundownMock).toHaveBeenCalledOnce(); expect(setCustomFieldsMock).toHaveBeenCalledOnce(); }); }); 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', ); expect(t2 - t1).lessThan(120); }); it('generates metadata from given rundown', () => { const initResult = processRundown(demoDb.rundowns.default, demoDb.customFields); expect(initResult.order.length).toBe(4); expect(initResult.flatEntryOrder.length).toBe(14); expect(initResult.timedEventOrder.length).toBe(6); expect(initResult.playableEventOrder.length).toBe(6); expect(initResult.firstStart).toBe(MILLIS_PER_HOUR * 10); expect(initResult.lastEnd).toBe(MILLIS_PER_HOUR * 14); 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': makeOntimeGroup({ 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.Group); 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', 'group', '3', 'another-group', '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 }), group: makeOntimeGroup({ id: 'group', title: 'break' }), '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), 'another-group': makeOntimeGroup({ id: 'another-group', 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', 'group', '3', 'another-group', '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 }), group: makeOntimeGroup({ id: 'group', title: 'break' }), '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), 'another-group': makeOntimeGroup({ id: 'another-group', 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', 'group', '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, }), group: makeOntimeGroup({ id: 'group' }), 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: 'text', colour: 'red', }, sound: { label: 'sound', type: 'text', 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.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': makeOntimeGroup({ id: '1', entries: ['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.Group, entries: ['100', '200', '300'], timeStart: 100, timeEnd: 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': makeOntimeGroup({ id: '1', entries: ['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': makeOntimeGroup({ id: '2', entries: ['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': makeOntimeGroup({ id: '3', entries: ['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.Group, entries: ['101', '102', '103'], timeStart: 100, timeEnd: 400, duration: 300, isFirstLinked: false, }, '101': { parent: '1', gap: 90, linkStart: false }, '102': { parent: '1' }, '103': { parent: '1' }, '2': { type: SupportedEntry.Group, entries: ['201', '202', '203'], timeStart: 500, timeEnd: 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.Group, entries: ['301', '302', '303'], timeStart: 900, timeEnd: 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 group if no after is given', () => { const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const rundown = makeRundown({ flatOrder: ['1', '1a'], order: ['1'], entries: { '1': makeOntimeGroup({ id: '1' }), '1a': makeOntimeEvent({ id: '1a', parent: '1' }), }, }); rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup); 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 group', () => { const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const rundown = makeRundown({ flatOrder: ['1', '1a'], order: ['1'], entries: { '1': makeOntimeGroup({ id: '1' }), '1a': makeOntimeEvent({ id: '1a', parent: '1' }), }, }); rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup); 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 group and its children', () => { const rundown = makeRundown({ order: ['1', '4'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['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': makeOntimeGroup({ id: '1', entries: ['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({ entries: ['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.renumber()', () => { it('sets cues from integer start and increment with no fractional part', () => { const rundown = makeRundown({ order: ['a', 'b', 'c'], entries: { a: makeOntimeEvent({ id: 'a', cue: 'old-a' }), b: makeOntimeEvent({ id: 'b', cue: 'old-b' }), c: makeOntimeEvent({ id: 'c', cue: 'old-c' }), }, }); rundownMutation.renumber( rundown, ['a', 'b', 'c'], 'Q', { integer: 10, faction: 0, precision: 0 }, { integer: 2, faction: 0, precision: 0 }, ); expect((rundown.entries['a'] as OntimeEvent).cue).toBe('Q10'); expect((rundown.entries['b'] as OntimeEvent).cue).toBe('Q12'); expect((rundown.entries['c'] as OntimeEvent).cue).toBe('Q14'); }); it('pads fractional segment to maxPrecision and steps faction by increment', () => { const rundown = makeRundown({ order: ['a', 'b', 'c', 'd'], entries: { a: makeOntimeEvent({ id: 'a' }), b: makeOntimeEvent({ id: 'b' }), c: makeOntimeEvent({ id: 'c' }), d: makeOntimeEvent({ id: 'd' }), }, }); rundownMutation.renumber( rundown, ['a', 'b', 'c', 'd'], '', { integer: 1, faction: 0, precision: 2 }, { integer: 0, faction: 25, precision: 2 }, ); expect((rundown.entries['a'] as OntimeEvent).cue).toBe('1.00'); expect((rundown.entries['b'] as OntimeEvent).cue).toBe('1.25'); expect((rundown.entries['c'] as OntimeEvent).cue).toBe('1.50'); expect((rundown.entries['d'] as OntimeEvent).cue).toBe('1.75'); }); it('throws when an id is not an event', () => { const rundown = makeRundown({ order: ['e', 'd'], entries: { e: makeOntimeEvent({ id: 'e' }), d: makeOntimeDelay({ id: 'd' }), }, }); expect(() => rundownMutation.renumber( rundown, ['e', 'd'], 'X', { integer: 1, faction: 0, precision: 0 }, { integer: 1, faction: 0, precision: 0 }, ), ).toThrowError('A given id was not an event'); }); it('handles mixed precision', () => { const rundown = makeRundown({ order: ['a', 'b', 'c', 'd'], entries: { a: makeOntimeEvent({ id: 'a' }), b: makeOntimeEvent({ id: 'b' }), c: makeOntimeEvent({ id: 'c' }), d: makeOntimeEvent({ id: 'd' }), }, }); const inc = { integer: 0, faction: 5, precision: 1 }; // 0.5 const start = { integer: 1, faction: 5, precision: 2 }; // 1.05 rundownMutation.renumber(rundown, ['a', 'b', 'c', 'd'], 'X', start, inc); expect((rundown.entries['a'] as OntimeEvent).cue).toBe('X1.05'); expect((rundown.entries['b'] as OntimeEvent).cue).toBe('X1.55'); expect((rundown.entries['c'] as OntimeEvent).cue).toBe('X1.105'); expect((rundown.entries['d'] as OntimeEvent).cue).toBe('X1.155'); }); }); describe('rundownMutation.reorder()', () => { it('moves an event into a group', () => { const rundown = makeRundown({ order: ['1', '2', '3'], entries: { '1': makeOntimeGroup({ id: '1', entries: [] }), '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({ entries: ['3'], }); expect(rundown.entries['3']).toMatchObject({ parent: '1', }); }); it('adds an event into a group', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['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({ entries: ['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 and before a group', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['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({ entries: [], }); expect(rundown.entries['11']).toMatchObject({ parent: null, }); expect(rundown.entries['2']).toMatchObject({ parent: null, }); }); it('moves an event out and after a group', () => { const rundown = makeRundown({ order: ['1', 'group', '2'], flatOrder: ['1', 'group', '11', '2'], entries: { '1': makeOntimeEvent({ id: '1', parent: null }), group: makeOntimeGroup({ id: 'group', entries: ['11'] }), '11': makeOntimeEvent({ id: '11', parent: 'group' }), '2': makeOntimeEvent({ id: '2', parent: null }), }, }); rundownMutation.reorder(rundown, rundown.entries['11'], rundown.entries['group'], 'after'); expect(rundown.order).toStrictEqual(['1', 'group', '11', '2']); expect(rundown.entries['group']).toMatchObject({ entries: [], }); expect(rundown.entries['11']).toMatchObject({ parent: null, }); expect(rundown.entries['2']).toMatchObject({ parent: null, }); }); it('moves an event between groups', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2', '22'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['11'] }), '11': makeOntimeEvent({ id: '11', parent: '1' }), '2': makeOntimeGroup({ id: '2', entries: ['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({ entries: [], }); expect(rundown.entries['2']).toMatchObject({ entries: ['11', '22'], }); expect(rundown.entries['11']).toMatchObject({ parent: '2', }); }); it('moves an event into an empty group', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '2', '22'], entries: { '1': makeOntimeGroup({ id: '1', entries: [] }), '2': makeOntimeGroup({ id: '2', entries: ['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({ entries: ['22'], }); expect(rundown.entries['2']).toMatchObject({ entries: [], }); expect(rundown.entries['22']).toMatchObject({ parent: '1', }); }); it('moves an event out of a group (up)', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2', '22'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['11'] }), '11': makeOntimeEvent({ id: '11', parent: '1' }), '2': makeOntimeGroup({ id: '2', entries: ['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({ entries: ['11'], }); expect(rundown.entries['11']).toMatchObject({ parent: '1', }); expect(rundown.entries['2']).toMatchObject({ entries: [], }); expect(rundown.entries['22']).toMatchObject({ parent: null, }); }); it('moves an event out of a group (down)', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2', '22'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['11'] }), '11': makeOntimeEvent({ id: '11', parent: '1' }), '2': makeOntimeGroup({ id: '2', entries: ['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({ entries: [], }); expect(rundown.entries['11']).toMatchObject({ parent: null, }); expect(rundown.entries['2']).toMatchObject({ entries: ['22'], }); expect(rundown.entries['22']).toMatchObject({ parent: '2', }); }); it('moves a group (up)', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2', '22'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['11'] }), '11': makeOntimeEvent({ id: '11', parent: '1' }), '2': makeOntimeGroup({ id: '2', entries: ['22'] }), '22': makeOntimeEvent({ id: '22', parent: '2' }), }, }); rundownMutation.reorder(rundown, rundown.entries['2'], rundown.entries['1'], 'before'); expect(rundown.order).toStrictEqual(['2', '1']); }); it('moves a group (down)', () => { const rundown = makeRundown({ order: ['1', '2'], flatOrder: ['1', '11', '2', '22'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['11'] }), '11': makeOntimeEvent({ id: '11', parent: '1' }), '2': makeOntimeGroup({ id: '2', entries: ['22'] }), '22': makeOntimeEvent({ id: '22', parent: '2' }), }, }); rundownMutation.reorder(rundown, rundown.entries['1'], rundown.entries['2'], 'after'); expect(rundown.order).toStrictEqual(['2', '1']); }); }); 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': makeOntimeGroup({ 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': makeOntimeGroup({ 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 as Day, 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 as Day, 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 groups is it is the first event after the delay', () => { const testRundown = makeRundown({ order: ['1', 'delay', 'group', '2'], entries: { '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), delay: makeOntimeDelay({ id: 'delay', duration: 50 }), group: makeOntimeGroup({ id: 'group' }), '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, }, group: { id: 'group' }, '2': { id: '2', timeStart: 150, timeEnd: 200, duration: 50, linkStart: false, revision: 2, }, }); }); it('applies a delay from inside a group', () => { const testRundown = makeRundown({ order: ['1', 'group', '2', '3'], entries: { '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), group: makeOntimeGroup({ id: 'group', entries: ['delay'] }), delay: makeOntimeDelay({ id: 'delay', duration: 100, parent: 'group' }), '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', 'group', '2', '3'], entries: { '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), delay: makeOntimeDelay({ id: 'delay', duration: 100 }), group: makeOntimeGroup({ id: 'group', entries: ['group-1'] }), 'group-1': makeOntimeEvent({ id: 'group-1', timeStart: 100, timeEnd: 200, duration: 100, linkStart: true, parent: 'group', }), '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, }, 'group-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 at the top level of 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 group', () => { const testRundown = makeRundown({ order: ['1'], entries: { '1': makeOntimeGroup({ id: '1', entries: ['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({ entries: ['1a', newEntry.id] }); expect(testRundown.entries[newEntry.id]).toMatchObject({ type: SupportedEntry.Event, parent: '1', cue: 'nested', }); }); it('clones a group and its nested elements', () => { const testRundown = makeRundown({ order: ['1'], entries: { '1': makeOntimeGroup({ id: '1', title: 'top', entries: ['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.Group, entries: [expect.any(String)], }); expect((testRundown.entries[newEntry.id] as OntimeGroup).entries[0]).not.toBe('1a'); }); it('clones an entry from a group inside another group', () => { const testRundown = makeRundown({ order: ['group1', 'group2'], entries: { group1: makeOntimeGroup({ id: 'group1', entries: ['event1'] }), group2: makeOntimeGroup({ id: 'group2', entries: ['event2'] }), event1: makeOntimeEvent({ id: 'event1', cue: 'nested-event', parent: 'group1' }), event2: makeOntimeEvent({ id: 'event2', cue: 'nested-event', parent: 'group2' }), }, }); const newEntry = rundownMutation.clone(testRundown, testRundown.entries['event2'], { after: 'event1', }) as OntimeEvent; // new event is added to group expect(testRundown.entries['group1']).toMatchObject({ entries: ['event1', newEntry.id], }); // new references the parent group expect(newEntry.parent).toBe('group1'); // the flat rundown remains unchanged expect(testRundown.order).toStrictEqual(['group1', 'group2']); }); it('clones an event and inserts it before another event', () => { const testRundown = makeRundown({ order: ['1', '2'], entries: { '1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }), '2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }), }, }); const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { before: '2' }); // Verify the rundown order is updated correctly expect(testRundown.order).toStrictEqual(['1', newEntry.id, '2']); // Verify the cloned entry is added to the rundown expect(testRundown.entries[newEntry.id]).toMatchObject({ type: SupportedEntry.Event, cue: 'event1', parent: null, }); }); }); describe('rundownMutation.group()', () => { it('groups a list of existing events into a new group entry', () => { 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 groupId = rundown.order[0]; expect(groupId).toStrictEqual(expect.any(String)); expect(rundown.order).toStrictEqual([expect.any(String), '3']); expect(rundown.entries).toMatchObject({ [groupId]: { type: SupportedEntry.Group, entries: ['1', '2'], }, '1': { id: '1', type: SupportedEntry.Event, parent: groupId }, '2': { id: '2', type: SupportedEntry.Event, parent: groupId }, '3': { id: '3', type: SupportedEntry.Event, parent: null }, }); }); it('BUG: creates a group in between groups', () => { const rundown = makeRundown({ order: ['group-1', '1', '2', '3', '4', 'group-2'], entries: { 'group-1': makeOntimeGroup({ id: 'group-1', entries: ['1a', '1b'] }), 'group-2': makeOntimeGroup({ id: 'group-2', entries: ['2a', '2b'] }), '1': makeOntimeEvent({ id: '1', parent: null }), '2': makeOntimeEvent({ id: '2', parent: null }), '3': makeOntimeEvent({ id: '3', parent: null }), '4': makeOntimeEvent({ id: '4', parent: null }), '1a': makeOntimeEvent({ id: '1a', parent: 'group-1' }), '1b': makeOntimeEvent({ id: '1b', parent: 'group-1' }), '2a': makeOntimeEvent({ id: '2a', parent: 'group-2' }), '2b': makeOntimeEvent({ id: '2b', parent: 'group-2' }), }, }); rundownMutation.group(rundown, ['2', '3']); const groupId = rundown.order[2]; expect(groupId).toStrictEqual(expect.any(String)); expect(rundown.order).toStrictEqual(['group-1', '1', expect.any(String), '4', 'group-2']); expect(rundown.entries).toMatchObject({ [groupId]: { type: SupportedEntry.Group, entries: ['2', '3'], }, '2': { id: '2', type: SupportedEntry.Event, parent: groupId }, '3': { id: '3', type: SupportedEntry.Event, parent: groupId }, }); }); }); describe('rundownMutation.ungroup()', () => { it('should correctly dissolve a group into its events', () => { const testRundown = makeRundown({ order: ['1', '2'], entries: { '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), '2': makeOntimeGroup({ id: '2', entries: ['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 OntimeGroup); 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 }, }); }); }); describe('customFieldMutation.add()', () => { it('adds a custom field given object', () => { const customFields = { one: makeCustomField({ label: 'one' }), }; customFieldMutation.add(customFields, 'two', makeCustomField({ label: 'two' })); expect(customFields).toMatchObject({ one: { label: 'one' }, two: { label: 'two' }, }); }); }); describe('customFieldMutation.edit()', () => { it('changes properties of an existing custom field', () => { const customFields = { one: makeCustomField({ label: 'one', colour: 'blue' }), }; customFieldMutation.edit(customFields, 'one', customFields.one, { colour: 'red' }); expect(customFields).toMatchObject({ one: { label: 'one', colour: 'red' }, }); }); it('changing the label makes a new key', () => { const customFields = { one: makeCustomField({ label: 'one', colour: 'blue' }), }; const { oldKey, newKey } = customFieldMutation.edit(customFields, 'one', customFields.one, { label: 'two', colour: 'red', }); expect(oldKey).toBe('one'); expect(newKey).not.toEqual(oldKey); expect(customFields).toMatchObject({ [oldKey]: { label: 'one', colour: 'blue' }, [newKey]: { label: 'two', colour: 'red' }, }); }); }); describe('customFieldMutation.remove()', () => { it('deletes a custom field from the object', () => { const customFields = { one: makeCustomField({ label: 'one', colour: 'blue' }), }; customFieldMutation.remove(customFields, 'one'); expect(customFields).not.toHaveProperty('one'); }); }); describe('customFieldMutation.renameUsages()', () => { it('renames all custom field entries in a given rundown', () => { const rundown = makeRundown({ order: ['1', '2', '3'], entries: { '1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }), '2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }), '3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }), }, }); customFieldMutation.renameUsages(rundown, 'one', 'new-one'); expect(rundown.entries).toMatchObject({ '1': { id: '1', custom: { 'new-one': 'value1' } }, '2': { id: '2', custom: { 'new-one': 'value2' } }, '3': { id: '3', custom: { two: 'value3' } }, }); }); it('renames usages inside groups and milestones', () => { const rundown = makeRundown({ order: ['group', 'm1'], entries: { group: makeOntimeGroup({ id: 'group', entries: ['e1'] }), e1: makeOntimeEvent({ id: 'e1', parent: 'group', custom: { one: 'v' } }), m1: makeOntimeMilestone({ id: 'm1', custom: { two: 'keep' } }), }, }); customFieldMutation.renameUsages(rundown, 'one', 'new-one'); expect((rundown.entries['e1'] as OntimeEvent).custom).toMatchObject({ 'new-one': 'v' }); expect((rundown.entries['m1'] as OntimeMilestone).custom).toMatchObject({ two: 'keep' }); }); }); describe('customFieldMutation.removeUsages()', () => { it('deletes all custom field entries in a given rundown', () => { const rundown = makeRundown({ order: ['1', '2', '3'], entries: { '1': makeOntimeEvent({ id: '1', custom: { one: 'value1' } }), '2': makeOntimeEvent({ id: '2', custom: { one: 'value2' } }), '3': makeOntimeEvent({ id: '3', custom: { two: 'value3' } }), }, }); customFieldMutation.removeUsages(rundown, 'one'); expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one'); expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one'); }); });