Files
ontime/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts
T
Alex Christoffer Rasmussen 41a09bf5c3 fix: send refetch on all rundown edits (#2098)
* fix(server): send refetch on all rundown edits

* fix(test): cuesheet tabing

* chore: remove unneeded async

* refactor: small cleanups

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* chore: remove unneded async/await

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-06-20 12:21:11 +02:00

290 lines
9.1 KiB
TypeScript

import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import {
calculateDayOffset,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
hasChanges,
makeDeepClone,
} 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 });
});
});