mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-05 14:29:20 +00:00
793 lines
28 KiB
TypeScript
793 lines
28 KiB
TypeScript
import { deepEqual } from 'fast-equals';
|
|
import {
|
|
EndAction,
|
|
OntimeEntry,
|
|
OntimeEvent,
|
|
OntimeGroup,
|
|
OntimeMilestone,
|
|
TimeStrategy,
|
|
TimerLifeCycle,
|
|
TimerType,
|
|
Trigger,
|
|
} from 'ontime-types';
|
|
import {
|
|
MILLIS_PER_HOUR,
|
|
MILLIS_PER_MINUTE,
|
|
createDelay,
|
|
createEvent,
|
|
createGroup,
|
|
createMilestone,
|
|
} from 'ontime-utils';
|
|
import { assertType } from 'vitest';
|
|
|
|
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
|
import { parseRundown } from '../rundown.parser.js';
|
|
import {
|
|
calculateDayOffset,
|
|
cloneEntryData,
|
|
deleteById,
|
|
doesInvalidateMetadata,
|
|
getIntegerAndFraction,
|
|
hasChanges,
|
|
makeDeepClone,
|
|
mergeRundownPreservingFields,
|
|
isLoadedPlayable,
|
|
eventDurationMatchGroupTarget,
|
|
} from '../rundown.utils.js';
|
|
|
|
describe('test event validator', () => {
|
|
it('validates a good object', () => {
|
|
const event = {
|
|
title: 'test',
|
|
};
|
|
const validated = createEvent(event, 1);
|
|
|
|
expect(validated).toEqual(
|
|
expect.objectContaining({
|
|
title: expect.any(String),
|
|
note: expect.any(String),
|
|
timeStart: expect.any(Number),
|
|
timeEnd: expect.any(Number),
|
|
countToEnd: expect.any(Boolean),
|
|
skip: expect.any(Boolean),
|
|
revision: expect.any(Number),
|
|
type: expect.any(String),
|
|
id: expect.any(String),
|
|
cue: '2',
|
|
colour: expect.any(String),
|
|
custom: expect.any(Object),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('fails an empty object', () => {
|
|
const event = {};
|
|
const validated = createEvent(event, 1);
|
|
expect(validated).toEqual(null);
|
|
});
|
|
|
|
it('makes objects strings', () => {
|
|
const event = {
|
|
title: 2,
|
|
note: '1899-12-30T08:00:10.000Z',
|
|
};
|
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
const validated = createEvent(event, 1);
|
|
if (validated === null) {
|
|
throw new Error('unexpected value');
|
|
}
|
|
expect(typeof validated.title).toEqual('string');
|
|
expect(typeof validated.note).toEqual('string');
|
|
});
|
|
|
|
it('enforces numbers on times', () => {
|
|
const event = {
|
|
timeStart: false,
|
|
timeEnd: '2',
|
|
};
|
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
const validated = createEvent(event);
|
|
if (validated === null) {
|
|
throw new Error('unexpected value');
|
|
}
|
|
assertType<number>(validated.timeStart);
|
|
assertType<number>(validated.timeEnd);
|
|
assertType<number>(validated.duration);
|
|
expect(validated.timeStart).toEqual(0);
|
|
expect(validated.timeEnd).toEqual(2);
|
|
expect(validated.duration).toEqual(2);
|
|
});
|
|
|
|
it('handles bad objects', () => {
|
|
const event = {
|
|
title: {},
|
|
};
|
|
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
|
const validated = createEvent(event);
|
|
if (validated === null) {
|
|
throw new Error('unexpected value');
|
|
}
|
|
expect(typeof validated.title).toEqual('string');
|
|
});
|
|
});
|
|
|
|
describe('doesInvalidateMetadata()', () => {
|
|
it('is stale if data contains timers', () => {
|
|
const needsRecompute = [
|
|
{ timeStart: 10 },
|
|
{ timeEnd: 10 },
|
|
{ duration: 10 },
|
|
{ linkStart: true },
|
|
{ timerStrategy: TimeStrategy.LockDuration },
|
|
];
|
|
|
|
for (const testCase of needsRecompute) {
|
|
expect(doesInvalidateMetadata(testCase)).toBe(true);
|
|
}
|
|
expect.assertions(needsRecompute.length);
|
|
});
|
|
|
|
it('is not stale if data contains auxiliary dataset', () => {
|
|
expect(
|
|
doesInvalidateMetadata({
|
|
cue: 'cue',
|
|
title: 'title',
|
|
note: 'note',
|
|
endAction: EndAction.LoadNext,
|
|
timerType: TimerType.Clock,
|
|
colour: 'colour',
|
|
timeWarning: 1,
|
|
timeDanger: 2,
|
|
custom: {
|
|
lighting: '3',
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('hasChanges()', () => {
|
|
it('identifies objects with new values', () => {
|
|
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
|
|
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
|
expect(hasChanges(existing, newEvent)).toBe(true);
|
|
});
|
|
it('identifies objects with all same values', () => {
|
|
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
|
|
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
|
expect(hasChanges(existing, newEvent)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deleteById()', () => {
|
|
it('should delete the first instance of the specified ID from the array', () => {
|
|
const array = ['id1', 'id2', 'id3', 'id4'];
|
|
const result = deleteById(array, 'id2');
|
|
expect(result).toStrictEqual(['id1', 'id3', 'id4']);
|
|
expect(result).not.toBe(array); // Ensure a new array is returned
|
|
});
|
|
|
|
it('should not modify the array if the specified ID does not exist', () => {
|
|
const array = ['id1', 'id2', 'id3', 'id4'];
|
|
const result = deleteById(array, 'id5');
|
|
expect(result).toStrictEqual(['id1', 'id2', 'id3', 'id4']);
|
|
});
|
|
|
|
it('should return the same array if it is empty', () => {
|
|
const array: string[] = [];
|
|
const result = deleteById(array, 'id1');
|
|
expect(result).toStrictEqual([]);
|
|
});
|
|
|
|
it('should handle scenarios where the delete id is not found', () => {
|
|
const array = ['id1', 'id2', 'id3'];
|
|
const result = deleteById(array, 'id4');
|
|
expect(result).toStrictEqual(['id1', 'id2', 'id3']);
|
|
});
|
|
});
|
|
|
|
describe('calculateDayOffset()', () => {
|
|
it('returns 0 if there is no previous event', () => {
|
|
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
|
});
|
|
|
|
it('returns 0 if the previous event duration is 0', () => {
|
|
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
|
|
});
|
|
|
|
it('returns 0 if event starts after previous', () => {
|
|
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
|
|
});
|
|
|
|
it('returns 1 if event starts before previous', () => {
|
|
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
|
|
});
|
|
|
|
it('returns 1 if event starts at the same time as one before', () => {
|
|
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
|
|
});
|
|
|
|
it('should account for an event that crossed midnight and there is a overlap', () => {
|
|
expect(
|
|
calculateDayOffset(
|
|
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
|
|
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
|
),
|
|
).toBe(1);
|
|
});
|
|
|
|
it('should account for an event that crossed midnight and there is a gap', () => {
|
|
expect(
|
|
calculateDayOffset(
|
|
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
|
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
|
|
),
|
|
).toBe(1);
|
|
});
|
|
|
|
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
|
|
expect(
|
|
calculateDayOffset(
|
|
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
|
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
|
),
|
|
).toBe(1);
|
|
});
|
|
|
|
it('should account for an event that finishes exactly at midnight', () => {
|
|
expect(
|
|
calculateDayOffset(
|
|
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
|
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
|
|
),
|
|
).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('makeDeepClone()', () => {
|
|
it('deep clones a group along with its nested entries', () => {
|
|
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
|
|
const rundown = makeRundown({
|
|
entries: {
|
|
group1,
|
|
event1: makeOntimeEvent({ id: 'event1', title: 'Event 1', parent: 'group1' }),
|
|
event2: makeOntimeEvent({ id: 'event2', title: 'Event 2', parent: 'group1' }),
|
|
},
|
|
order: ['group1'],
|
|
flatOrder: ['group1', 'event1', 'event2'],
|
|
});
|
|
|
|
const { newGroup, nestedEntries } = makeDeepClone(group1, rundown);
|
|
|
|
expect(newGroup).toMatchObject({
|
|
id: expect.any(String),
|
|
title: 'Group 1 (copy)',
|
|
entries: [expect.any(String), expect.any(String)],
|
|
revision: 0,
|
|
});
|
|
expect(newGroup.id).not.toEqual('group1');
|
|
expect(newGroup.entries.length).toEqual(group1.entries.length);
|
|
|
|
expect(nestedEntries).toMatchObject([
|
|
{
|
|
id: expect.any(String),
|
|
title: 'Event 1',
|
|
parent: newGroup.id,
|
|
revision: 0,
|
|
},
|
|
{
|
|
id: expect.any(String),
|
|
title: 'Event 2',
|
|
parent: newGroup.id,
|
|
revision: 0,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getIntegerAndFraction()', () => {
|
|
test('integer without fraction', () => {
|
|
expect(getIntegerAndFraction('123')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
|
});
|
|
|
|
test('integer and fraction', () => {
|
|
expect(getIntegerAndFraction('123.456')).toStrictEqual({ integer: 123, faction: 456, precision: 3 });
|
|
});
|
|
|
|
test('invalid integer', () => {
|
|
expect(() => getIntegerAndFraction('abc.456')).toThrowError('input can not be converted to a number');
|
|
});
|
|
|
|
test('indicate precision just with zeros', () => {
|
|
expect(getIntegerAndFraction('123.000')).toStrictEqual({ integer: 123, faction: 0, precision: 3 });
|
|
});
|
|
|
|
test('invalid fraction', () => {
|
|
expect(() => getIntegerAndFraction('123.abc')).toThrowError('input can not be converted to a number');
|
|
});
|
|
|
|
test('floating separator', () => {
|
|
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
|
|
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
|
|
* any field it does not provide (e.g. automations) is kept from the existing event.
|
|
*/
|
|
describe('mergeRundownPreservingFields()', () => {
|
|
const automation: Trigger = {
|
|
id: 'trigger-onair',
|
|
title: 'Go on air',
|
|
trigger: TimerLifeCycle.onStart,
|
|
automationId: 'automation-onair',
|
|
};
|
|
|
|
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
title: 'Main show',
|
|
revision: 3,
|
|
order: ['welcome', 'keynote'],
|
|
entries: {
|
|
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
|
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
|
|
},
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
title: 'From spreadsheet',
|
|
revision: 0,
|
|
order: ['welcome', 'lunch'],
|
|
entries: {
|
|
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
|
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
|
|
},
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
|
|
// identity and revision come from the current rundown
|
|
expect(merged.id).toBe('show-rundown');
|
|
expect(merged.title).toBe('Main show');
|
|
expect(merged.revision).toBe(4);
|
|
// structure and order come from the incoming rundown
|
|
expect(merged.order).toEqual(['welcome', 'lunch']);
|
|
expect(merged.flatOrder).toEqual(incoming.flatOrder);
|
|
});
|
|
|
|
it('deletes current entries that are absent from the incoming rundown', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['welcome', 'keynote'],
|
|
entries: {
|
|
welcome: makeOntimeEvent({ id: 'welcome' }),
|
|
keynote: makeOntimeEvent({ id: 'keynote' }),
|
|
},
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['welcome'],
|
|
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
|
|
expect(merged.entries.welcome).toBeDefined();
|
|
expect(merged.entries.keynote).toBeUndefined();
|
|
});
|
|
|
|
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
|
|
// the incoming group fully replaces the previous event, no old data is carried over
|
|
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
|
|
});
|
|
|
|
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
|
|
});
|
|
|
|
// the sheet mapped title and note
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
|
|
const keynote = merged.entries.keynote as OntimeEvent;
|
|
|
|
expect(keynote.title).toBe('Opening keynote');
|
|
// an empty provided value replaces the current one
|
|
expect(keynote.note).toBe('');
|
|
});
|
|
|
|
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: {
|
|
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
|
|
},
|
|
});
|
|
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
|
|
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: {
|
|
keynote: makeOntimeEvent({
|
|
id: 'keynote',
|
|
title: 'Opening keynote',
|
|
note: 'from a stale column',
|
|
triggers: [],
|
|
}),
|
|
},
|
|
});
|
|
|
|
// only title is mapped, so note and automations keep the existing values, not the incoming ones
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
const keynote = merged.entries.keynote as OntimeEvent;
|
|
|
|
expect(keynote.title).toBe('Opening keynote');
|
|
expect(keynote.note).toBe('green room');
|
|
expect(keynote.triggers).toEqual([automation]);
|
|
});
|
|
|
|
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: {
|
|
keynote: makeOntimeEvent({
|
|
id: 'keynote',
|
|
title: 'Keynote',
|
|
custom: { lighting: 'warm', song: 'intro theme' },
|
|
}),
|
|
},
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: {
|
|
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
|
|
},
|
|
});
|
|
|
|
// the sheet mapped the title and the lighting custom field, but not song
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
|
|
const keynote = merged.entries.keynote as OntimeEvent;
|
|
|
|
expect(keynote.title).toBe('Opening keynote');
|
|
expect(keynote.custom.lighting).toBe('cold');
|
|
// an unmapped custom field is kept from the existing event
|
|
expect(keynote.custom.song).toBe('intro theme');
|
|
});
|
|
|
|
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
|
});
|
|
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
|
|
|
|
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
|
|
});
|
|
|
|
it('keeps the existing time strategy when the provided times are ambiguous', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
|
});
|
|
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
|
|
|
|
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
|
|
});
|
|
|
|
it('merges a matched group, keeping the fields the sheet cannot express', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['session'],
|
|
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
|
|
});
|
|
// the sheet cannot express a group's target duration, so the incoming group does not carry one
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['session'],
|
|
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
const session = merged.entries.session as OntimeGroup;
|
|
|
|
expect(session.title).toBe('New session');
|
|
expect(session.targetDuration).toBe(3_600_000);
|
|
});
|
|
|
|
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['reminder'],
|
|
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['reminder'],
|
|
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
|
|
});
|
|
|
|
// only title is mapped, so the milestone's note keeps the existing value
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
const reminder = merged.entries.reminder as OntimeMilestone;
|
|
|
|
expect(reminder.title).toBe('Green room reminder');
|
|
expect(reminder.note).toBe('call talent');
|
|
});
|
|
|
|
it('does not mutate the current rundown and deep-clones the kept automations', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
|
|
// kept automations are a copy, not a shared reference to the current rundown
|
|
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
|
|
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
|
|
});
|
|
|
|
it('keeps the not-provided fields through a parseRundown round-trip', () => {
|
|
const current = makeRundown({
|
|
id: 'show-rundown',
|
|
order: ['keynote'],
|
|
entries: {
|
|
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
|
|
},
|
|
});
|
|
const incoming = makeRundown({
|
|
id: 'spreadsheet-rundown',
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
|
|
});
|
|
|
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
|
const parsed = parseRundown(merged, {});
|
|
const keynote = parsed.entries.keynote as OntimeEvent;
|
|
|
|
expect(keynote.triggers).toEqual([automation]);
|
|
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
|
|
});
|
|
});
|
|
|
|
describe('isLoadedPlayable()', () => {
|
|
it('returns true when the loaded event still exists and is playable', () => {
|
|
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
|
|
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
|
|
});
|
|
|
|
it('returns false when the loaded event was removed', () => {
|
|
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
|
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
|
});
|
|
|
|
it('returns false when the loaded event is now skipped', () => {
|
|
const rundown = makeRundown({
|
|
order: ['keynote'],
|
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
|
|
});
|
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
|
});
|
|
|
|
it('returns false when the matched entry is no longer an event', () => {
|
|
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
|
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('eventDurationMatchGroupTarget()', () => {
|
|
it('returns unchanged duration when group already matches target', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_HOUR,
|
|
groupDuration: MILLIS_PER_HOUR,
|
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
|
});
|
|
expect(result).toStrictEqual(null);
|
|
});
|
|
|
|
it('increases event duration when group is shorter than target', () => {
|
|
// Group is 1h short of target, so event duration increases by 1h
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_HOUR * 2, // 2h
|
|
groupDuration: MILLIS_PER_HOUR, // 1h
|
|
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
|
});
|
|
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
|
|
});
|
|
|
|
it('decreases event duration when group is longer than target', () => {
|
|
// Group is 30m over target, so event duration decreases by 30m
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_HOUR, // 1h
|
|
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
|
|
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
|
});
|
|
expect(result).toStrictEqual(0);
|
|
});
|
|
|
|
it('handles zero target duration', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: 0,
|
|
groupDuration: MILLIS_PER_HOUR,
|
|
eventDuration: MILLIS_PER_HOUR,
|
|
});
|
|
expect(result).toStrictEqual(0);
|
|
});
|
|
|
|
it('handles zero group duration', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_HOUR,
|
|
groupDuration: 0,
|
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
|
});
|
|
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
|
|
});
|
|
|
|
it('handles zero event duration', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_HOUR,
|
|
groupDuration: MILLIS_PER_MINUTE * 30,
|
|
eventDuration: 0,
|
|
});
|
|
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
|
|
});
|
|
|
|
it('handles all zero values', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: 0,
|
|
groupDuration: 0,
|
|
eventDuration: 0,
|
|
});
|
|
expect(result).toStrictEqual(null);
|
|
});
|
|
|
|
it('returns null when result would be negative', () => {
|
|
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_MINUTE * 30,
|
|
groupDuration: MILLIS_PER_HOUR * 2,
|
|
eventDuration: MILLIS_PER_HOUR,
|
|
});
|
|
expect(result).toStrictEqual(null);
|
|
});
|
|
|
|
it('handles large durations', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: MILLIS_PER_HOUR * 24, // 24h
|
|
groupDuration: MILLIS_PER_HOUR * 12, // 12h
|
|
eventDuration: MILLIS_PER_HOUR, // 1h
|
|
});
|
|
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
|
|
});
|
|
|
|
it('returns null when targetDuration is null', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: null,
|
|
groupDuration: MILLIS_PER_HOUR,
|
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
|
});
|
|
expect(result).toStrictEqual(null);
|
|
});
|
|
|
|
it('returns null when duration would be over 24h', () => {
|
|
const result = eventDurationMatchGroupTarget({
|
|
targetDuration: 30 * MILLIS_PER_HOUR,
|
|
groupDuration: MILLIS_PER_HOUR,
|
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
|
});
|
|
expect(result).toStrictEqual(null);
|
|
});
|
|
});
|
|
|
|
describe('cloneEntryData()', () => {
|
|
const trigger: Trigger = {
|
|
id: 'trigger-1',
|
|
title: 'Go on air',
|
|
trigger: TimerLifeCycle.onStart,
|
|
automationId: 'automation-1',
|
|
};
|
|
|
|
// the real factories, so these are complete entries exactly as the rundown holds them
|
|
const entries: [string, OntimeEntry][] = [
|
|
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
|
|
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
|
|
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
|
|
['delay', createDelay({ id: 'delay-1', duration: 10 })],
|
|
];
|
|
|
|
/**
|
|
* Fails if any nested object or array in the clone is the same reference as the source,
|
|
* so a field added later that needs a copy of its own is caught here without the clone
|
|
* having to enumerate fields.
|
|
*/
|
|
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
|
|
if (typeof source !== 'object' || source === null) return;
|
|
expect(clone, `${path} is shared with the source`).not.toBe(source);
|
|
const cloneRecord = clone as Record<string, unknown>;
|
|
const sourceRecord = source as Record<string, unknown>;
|
|
for (const key of Object.keys(sourceRecord)) {
|
|
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
|
|
}
|
|
}
|
|
|
|
/** the two halves of the structuredClone contract: same value, no shared references */
|
|
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
|
|
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
|
|
});
|
|
|
|
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
|
|
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
|
|
});
|
|
|
|
/**
|
|
* Regression: normalising an absent container to an empty one makes deepEqual report a
|
|
* change on every comparison, which would have the runtime re-broadcast and re-save the
|
|
* restore point on every tick. See PR #2178.
|
|
*/
|
|
it.each([
|
|
['event', makeOntimeEvent({ id: 'partial' })],
|
|
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
|
|
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
|
|
const clone = cloneEntryData(entry);
|
|
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
|
|
// key holding undefined, and it is key presence that decides the deepEqual below
|
|
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
|
|
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
|
|
// entry; if the clone gains a key, every tick looks like a change
|
|
expect(deepEqual(clone, entry)).toBe(true);
|
|
});
|
|
|
|
it('throws on an entry type it does not know how to clone', () => {
|
|
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
|
|
'Unsupported entry type for cloning',
|
|
);
|
|
});
|
|
});
|