refactor: migrate custom fields to transactions

refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
This commit is contained in:
Carlos Valente
2025-06-06 21:08:30 +02:00
committed by arc-alex
parent f3b4ea0155
commit 2498e59156
75 changed files with 2060 additions and 2480 deletions
@@ -1,18 +1,35 @@
import { CustomFields, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { makeOntimeEvent, makeRundown, makeOntimeBlock, makeOntimeDelay } from '../__mocks__/rundown.mocks.js';
import {
makeOntimeEvent,
makeRundown,
makeOntimeBlock,
makeOntimeDelay,
makeCustomField,
} from '../__mocks__/rundown.mocks.js';
import { createTransaction, processRundown, rundownCache, rundownMutation } from '../rundown.dao.js';
import {
createTransaction,
customFieldMutation,
processRundown,
rundownCache,
rundownMutation,
} from '../rundown.dao.js';
import { demoDb } from '../../../models/demoProject.js';
import { ProcessedRundownMetadata } from '../../../services/rundown-service/rundownCache.utils.js';
import type { AssignedMap } from '../rundown.types.js';
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
const setRundownMock = vi.fn();
const setCustomFieldsMock = vi.fn();
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation(() => undefined),
setRundown: setRundownMock,
setCustomFields: setCustomFieldsMock,
};
}),
};
@@ -24,17 +41,39 @@ afterAll(() => {
});
describe('createTransaction', () => {
it('should return a snapshot of the cached rundown and an commit function', () => {
const { rundown, commit } = 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 rundown after commit is called and update the db', () => {
const { rundown, commit } = createTransaction();
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: 'string',
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();
});
});
@@ -1568,3 +1607,119 @@ describe('rundownMutation.ungroup()', () => {
});
});
});
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' } }),
},
});
const assigned: AssignedMap = {
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.renameUsages(rundown, assigned, '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' } },
});
expect(assigned).toStrictEqual({
'new-one': ['1', '2'],
two: ['3'],
});
});
});
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' } }),
},
});
const assigned: AssignedMap = {
one: ['1', '2'],
two: ['3'],
};
customFieldMutation.removeUsages(rundown, assigned, 'one');
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
expect(assigned).toStrictEqual({
two: ['3'],
});
});
});
@@ -1,19 +1,18 @@
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown, CustomFields } from 'ontime-types';
import { defaultRundown } from '../../../models/dataModel.js';
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
import { parseRundowns, parseRundown } from '../rundown.parser.js';
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js';
describe('parseRundowns()', () => {
it('returns a default project rundown if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseRundowns({}, errorEmitter);
expect(result.customFields).toEqual({});
expect(result.rundowns).toStrictEqual({ default: defaultRundown });
const result = parseRundowns({}, {}, errorEmitter);
expect(result).toStrictEqual({ default: defaultRundown });
// one for not having custom fields
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(2);
expect(errorEmitter).toHaveBeenCalledTimes(1);
});
it('ensures the rundown IDs are consistent', () => {
@@ -27,14 +26,14 @@ describe('parseRundowns()', () => {
'3': r2,
},
},
{},
errorEmitter,
);
expect(result.rundowns).toMatchObject({
expect(result).toMatchObject({
'1': r1,
'2': r2,
});
// one for not having a rundown
expect(errorEmitter).toHaveBeenCalledTimes(1);
expect(errorEmitter).toHaveBeenCalledTimes(0);
});
});
@@ -183,6 +182,37 @@ describe('parseRundown()', () => {
expect(Object.keys(parsedRundown.entries).length).toEqual(2);
});
it('parses customFields', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'on' } }),
'2': makeOntimeEvent({ id: '2', custom: { sound: 'loud' } }),
},
revision: 1,
} as Rundown;
const customFields: CustomFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'red',
label: 'sound',
},
};
const parsedRundown = parseRundown(rundown, customFields);
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'on' });
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
});
it('parses events nested in blocks', () => {
const rundown = {
id: 'test',
@@ -203,3 +233,50 @@ describe('parseRundown()', () => {
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
});
describe('addToCustomAssignment()', () => {
it('adds given entry to assignedCustomFields', () => {
const assignedCustomFields = {};
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
});
});
describe('handleCustomField()', () => {
it('creates a map of where custom fields are used', () => {
const customFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'red',
label: 'sound',
},
} as CustomFields;
const event = makeOntimeEvent({
type: SupportedEntry.Event,
id: '2',
timeStart: 0,
linkStart: true,
custom: {
lighting: 'on',
},
});
const assignedCustomFields = {};
const result = handleCustomField(customFields, event, assignedCustomFields);
expect(result).toBeUndefined();
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
expect(event.custom).toStrictEqual({
lighting: 'on',
});
});
});
@@ -1,8 +1,10 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js';
import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
import { makeRundown } from '../__mocks__/rundown.mocks.js';
describe('test event validator', () => {
it('validates a good object', () => {
@@ -130,7 +132,7 @@ describe('hasChanges()', () => {
});
});
describe('deleteById', () => {
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');
@@ -156,3 +158,83 @@ describe('deleteById', () => {
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('getInsertAfterId()', () => {
const rundown = makeRundown({
flatOrder: ['a', 'b', 'c', 'd'],
});
it('returns afterId if provided', () => {
expect(getInsertAfterId(rundown, 'b')).toBe('b');
});
it('returns the previous id before beforeId if provided', () => {
expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
});
it('returns undefined if neither afterId nor beforeId is provided', () => {
expect(getInsertAfterId(rundown)).toBeNull();
});
it('returns undefined if beforeId is not found', () => {
expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
});
});