Files
ontime/apps/server/src/utils/__tests__/parser.test.ts
T
Alex Christoffer Rasmussen cd913fc144 small strict improvements (#1428)
* small `strict` improvements

* fix type

* use isObject
2025-01-06 14:07:36 +01:00

1914 lines
47 KiB
TypeScript

/* eslint-disable no-console -- we are mocking the console */
import { assertType, vi } from 'vitest';
import {
CustomFields,
DatabaseModel,
EndAction,
OntimeEvent,
OntimeRundown,
ProjectData,
Settings,
SupportedEvent,
TimerType,
TimeStrategy,
ViewSettings,
} from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import * as cache from '../../services/rundown-service/rundownCache.js';
const requiredSettings = {
app: 'ontime',
version: 'any',
};
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test json parser with valid def', () => {
const testData: Partial<DatabaseModel> = {
rundown: [
{
id: '4b31',
cue: 'Guest Welcoming',
type: SupportedEvent.Event,
title: 'Guest Welcoming',
note: '',
endAction: EndAction.PlayNext,
timerType: TimerType.Clock,
timeStart: 31500000,
timeEnd: 32400000,
duration: 32400000 - 31500000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: '',
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
id: 'f24d',
cue: 'Good Morning',
type: SupportedEvent.Event,
title: 'Good Morning',
note: '',
endAction: EndAction.PlayNext,
timerType: TimerType.CountUp,
timeStart: 32400000,
timeEnd: 36000000,
duration: 36000000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: true,
colour: 'red',
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
id: 'bbc5',
cue: 'Stage 2 setup',
type: SupportedEvent.Event,
title: 'Stage 2 setup',
note: '',
endAction: 'wrong action' as EndAction, // testing
timerType: TimerType.Clock,
timeStart: 32400000,
timeEnd: 37200000,
duration: 37200000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: '',
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
// testing incomplete dataset
id: '5b3e',
cue: 'Working Procedures',
type: SupportedEvent.Event,
title: 'Working Procedures',
note: '',
endAction: EndAction.None,
timerType: TimerType.Clock,
timeStart: 37200000,
timeEnd: 39000000,
duration: 39000000 - 37200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 0,
timeDanger: 0,
} as OntimeEvent,
{
id: '8e2c',
cue: 'Lunch',
title: 'Lunch',
type: SupportedEvent.Event,
note: '',
endAction: EndAction.None,
timerType: TimerType.Clock,
timeStart: 39600000,
timeEnd: 45000000,
duration: 37200000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: '',
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
id: '08e9',
cue: 'A day being carlos',
title: 'A day being carlos',
type: SupportedEvent.Event,
note: '',
endAction: EndAction.None,
timerType: TimerType.Clock,
timeStart: 46800000,
timeEnd: 50400000,
duration: 37200000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: true,
colour: '',
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
// testing incomplete dataset
id: 'e25a',
cue: 'Hamburgers and Cheese',
title: 'Hamburgers and Cheese',
type: SupportedEvent.Event,
note: '',
endAction: EndAction.None,
timerType: TimerType.Clock,
timeStart: 54000000,
timeEnd: 57600000,
duration: 37200000 - 32400000,
isPublic: true,
colour: '',
revision: 0,
timeWarning: 0,
timeDanger: 0,
} as OntimeEvent,
] as OntimeRundown,
project: {
title: 'This is a test definition',
backstageUrl: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
} as ProjectData,
settings: {
app: 'ontime',
version: '2.0.0',
timeFormat: '24',
} as Settings,
viewSettings: {} as ViewSettings,
};
const { data } = parseDatabaseModel(testData);
it('has 7 events', () => {
const length = data.rundown.length;
expect(length).toBe(7);
});
it('first event is as a match', () => {
const first = data.rundown[0];
const expected = {
title: 'Guest Welcoming',
type: 'event',
id: '4b31',
};
expect(first).toMatchObject(expected);
});
it('second event is as a match', () => {
const second = data.rundown[1];
const expected = {
title: 'Good Morning',
type: 'event',
id: 'f24d',
};
expect(second).toMatchObject(expected);
});
it('third event end action is set as the default value', () => {
const third = data.rundown[2];
expect((third as OntimeEvent).endAction).toStrictEqual(EndAction.None);
});
it('fourth event timer type is set as the default value', () => {
const fourth = data.rundown[3];
expect((fourth as OntimeEvent).timerType).toStrictEqual(TimerType.Clock);
});
it('loaded event settings', () => {
const eventTitle = data.project.title;
expect(eventTitle).toBe('This is a test definition');
});
it('endMessage to exist but be empty', () => {
const endMessage = data.viewSettings.endMessage;
expect(endMessage).toBeDefined();
expect(endMessage).toBe('');
});
it('settings are for right app and version', () => {
const settings = data.settings;
expect(settings.app).toBe('ontime');
expect(settings.version).toEqual(expect.any(String));
});
});
describe('test parser edge cases', () => {
it('stringifies necessary values', () => {
const testData = {
settings: { ...requiredSettings },
rundown: [
{
cue: 101,
type: 'event',
},
{
cue: 101.1,
type: 'event',
},
],
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseDatabaseModel(testData);
expect(typeof (data.rundown[0] as OntimeEvent).cue).toBe('string');
expect(typeof (data.rundown[1] as OntimeEvent).cue).toBe('string');
});
it('generates missing ids', () => {
const testData = {
settings: { ...requiredSettings },
rundown: [
{
title: 'Test Event',
type: 'event',
},
],
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseDatabaseModel(testData);
expect(data.rundown[0].id).toBeDefined();
});
it('detects duplicate Ids', () => {
console.log = vi.fn();
const testData = {
settings: { ...requiredSettings },
rundown: [
{
title: 'Test Event 1',
type: 'event',
id: '1',
},
{
title: 'Test Event 2',
type: 'event',
id: '1',
},
],
};
//@ts-expect-error -- we know this is wrong, testing imports outside domain
const { data, errors } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(1);
expect(errors.length).toBe(7);
});
it('handles incomplete datasets', () => {
console.log = vi.fn();
const testData = {
settings: { ...requiredSettings },
rundown: [
{
title: 'Test Event 1',
id: '1',
},
{
title: 'Test Event 2',
id: '1',
},
],
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(0);
});
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel(testData)).toThrow();
});
});
describe('test corrupt data', () => {
it('handles some empty events', () => {
const emptyEvents = {
rundown: [
{},
{},
{},
{
title: 'Test Event 1',
type: 'event',
id: '1',
},
{
title: 'Test Event 2',
type: 'event',
id: '2',
},
{},
{},
{},
],
project: {
title: 'All about Carlos demo event',
description: 'description',
publicUrl: 'www.carlosvalente.com',
backstageUrl: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
endMessage: '',
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
timeFormat: '24',
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseDatabaseModel(emptyEvents);
expect(data.rundown.length).toBe(2);
});
it('handles all empty events', () => {
const emptyEvents = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
project: {
title: 'All about Carlos demo event',
description: 'description',
publicUrl: 'www.carlosvalente.com',
backstageUrl: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
endMessage: '',
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
timeFormat: '24',
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseDatabaseModel(emptyEvents);
expect(data.rundown.length).toBe(0);
});
it('handles missing project data', () => {
const emptyProjectData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
project: {},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
lock: null,
timeFormat: '24',
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data: parsedDef } = parseDatabaseModel(emptyProjectData);
expect(parsedDef.project).toStrictEqual(dbModel.project);
});
it('handles missing settings', () => {
const missingSettings = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
settings: {
app: 'ontime',
version: '2.0.0',
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data } = parseDatabaseModel(missingSettings);
expect(data.settings).toStrictEqual(dbModel.settings);
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
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),
isPublic: 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('test aliases import', () => {
it('imports a well defined urlPreset', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: '2.0.0',
},
urlPresets: [
{
enabled: false,
alias: 'testalias',
pathAndParams: 'testpathAndParams',
},
],
} as unknown as DatabaseModel;
const parsed = parseUrlPresets(testData);
expect(parsed.length).toBe(1);
// generates missing id
expect(parsed[0].alias).toBeDefined();
});
});
describe('test views import', () => {
it('imports data from file', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: '2.0.0',
},
viewSettings: {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
endMessage: '',
overrideStyles: false,
// known error: properties do not exist
notAthing: true,
},
// known error: views does not exist
views: {
overrideStyles: true,
},
};
const expectedParsedViewSettings = {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
freezeEnd: false,
endMessage: '',
overrideStyles: false,
};
// @ts-expect-error -- we know the above is incorrect
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
it('imports defaults to model', () => {
const testData = {
rundown: [],
settings: {
app: 'ontime',
version: '2.0.0',
},
} as unknown as DatabaseModel;
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(dbModel.viewSettings);
});
});
describe('test import of v2 datamodel', () => {
it('ignores deprecated fields and generates new ones', () => {
const v2ProjectFile = {
rundown: [
{ type: SupportedEvent.Block, title: 'block-title', id: 'block-id' },
{ type: SupportedEvent.Delay, duration: 0 },
{ type: SupportedEvent.Event, title: 'event-title', id: 'event-id' },
],
project: {
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
endMessage: '',
},
aliases: [],
userFields: {
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
},
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
onWarning: [],
onDanger: [],
},
},
http: {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const { data: parsed, _errors } = parseDatabaseModel(v2ProjectFile);
expect(parsed.rundown.length).toBe(3);
expect(parsed.rundown[0]).toMatchObject({ type: SupportedEvent.Block });
expect(parsed.rundown[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
title: expect.any(String),
}),
);
expect(parsed.rundown[1]).toMatchObject({ type: SupportedEvent.Delay });
expect(parsed.rundown[1]).toEqual(
expect.objectContaining({
id: expect.any(String),
duration: expect.any(Number),
}),
);
expect(parsed.rundown[2]).toMatchObject({ type: SupportedEvent.Event });
expect(parsed.rundown[2]).toEqual(
expect.objectContaining({
id: expect.any(String),
cue: expect.any(String),
title: expect.any(String),
note: expect.any(String),
endAction: expect.any(String),
timerType: expect.any(String),
linkStart: null,
timeStrategy: expect.any(String),
timeStart: expect.any(Number),
timeEnd: expect.any(Number),
duration: expect.any(Number),
isPublic: expect.any(Boolean),
skip: expect.any(Boolean),
colour: expect.any(String),
revision: expect.any(Number),
timeWarning: expect.any(Number),
timeDanger: expect.any(Number),
custom: expect.any(Object),
}),
);
// @ts-expect-error -- checking if the field is removed
expect(parsed?.userFields).toBeUndefined();
expect(parsed.osc).toMatchObject({ subscriptions: [] });
expect(parsed.http).toMatchObject({ enabledOut: false, subscriptions: [] });
});
});
describe('makeString()', () => {
it('converts variables to string', () => {
const cases = [
{
val: 2,
expected: '2',
},
{
val: 2.22222222,
expected: '2.22222222',
},
{
val: ['testing'],
expected: 'testing',
},
{
val: ' testing ',
expected: 'testing',
},
{
val: { doing: 'testing' },
expected: 'fallback',
},
{
val: undefined,
expected: 'fallback',
},
];
cases.forEach(({ val, expected }) => {
const converted = makeString(val, 'fallback');
expect(converted).toBe(expected);
});
});
});
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
} as ImportMap;
const result = getCustomFieldData(importMap, {});
expect(result.customFields).toStrictEqual({
lighting: {
type: 'string',
colour: '',
label: 'lighting',
},
sound: {
type: 'string',
colour: '',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
} as ImportMap;
const customFields: CustomFields = {
lighting: { label: 'lx', type: 'string', colour: 'red' },
sound: { label: 'sound', type: 'string', colour: 'green' },
};
const result = getCustomFieldData(importMap, customFields);
expect(result.customFields).toStrictEqual({
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'green',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
});
describe('parseExcel()', () => {
it('parses the example file', () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
[
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Count to end',
'Public',
'Skip',
'Notes',
't0',
'Test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'cue',
],
[
'07:00:00',
'08:00:10',
'Guest Welcome',
'',
'',
'x', // <-- count to end
'x', // <-- public
'',
'Ballyhoo',
'a0',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'a8',
'a9',
'red',
101,
],
[
'08:00:00',
'08:30:00',
'A song from the hearth',
'load-next',
'clock',
'x', // <-- count to end
'', // <-- public
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
102,
],
[],
];
// partial import map with only custom fields
const importMap = {
custom: {
user0: 't0',
User1: 'Test1',
user2: 'test2',
user3: 'test3',
user4: 'test4',
user5: 'test5',
user6: 'test6',
user7: 'test7',
user8: 'test8',
user9: 'test9',
},
};
// TODO: update tests once import is resolved
const expectedParsedRundown = [
{
timeStart: 25200000,
timeEnd: 28810000,
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
isPublic: true,
countToEnd: true,
skip: false,
note: 'Ballyhoo',
custom: {
user0: 'a0',
User1: 'a1',
user2: 'a2',
user3: 'a3',
user4: 'a4',
user5: 'a5',
user6: 'a6',
user7: 'a7',
user8: 'a8',
user9: 'a9',
},
colour: 'red',
type: 'event',
cue: '101',
},
{
timeStart: 28800000,
timeEnd: 30600000,
title: 'A song from the hearth',
timerType: 'clock',
countToEnd: true,
endAction: 'load-next',
isPublic: false,
skip: true,
note: 'Rainbow chase',
custom: {
user0: 'b0',
user5: 'b5',
},
colour: '#F00',
type: 'event',
cue: '102',
},
];
const existingCustomFields: CustomFields = {
user0: { type: 'string', colour: 'red', label: 'user0' },
User1: { type: 'string', colour: 'green', label: 'user1' },
user2: { type: 'string', colour: 'blue', label: 'user2' },
};
const parsedData = parseExcel(testdata, existingCustomFields, importMap);
expect(parsedData.customFields).toStrictEqual({
user0: {
type: 'string',
colour: 'red',
label: 'user0',
},
User1: {
type: 'string',
colour: 'green',
label: 'User1',
},
user2: {
type: 'string',
colour: 'blue',
label: 'user2',
},
user3: {
type: 'string',
colour: '',
label: 'user3',
},
user4: {
type: 'string',
colour: '',
label: 'user4',
},
user5: {
type: 'string',
colour: '',
label: 'user5',
},
user6: {
type: 'string',
colour: '',
label: 'user6',
},
user7: {
type: 'string',
colour: '',
label: 'user7',
},
user8: {
type: 'string',
colour: '',
label: 'user8',
},
user9: {
type: 'string',
colour: '',
label: 'user9',
},
});
expect(parsedData.rundown.length).toBe(2);
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
});
it('parses a file without custom fields', () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
[
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Public',
'Skip',
'Notes',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'cue',
],
[
'1899-12-30T07:00:00.000Z',
'1899-12-30T08:00:10.000Z',
'Guest Welcome',
'',
'',
'x',
'',
'Ballyhoo',
'a0',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'a8',
'a9',
'red',
101,
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'load-next',
'clock',
'',
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
102,
],
[],
];
// partial import map with only custom fields
const importMap = {
custom: {
niu1: 'niu1',
niu2: 'niu2',
},
};
// TODO: update tests once import is resolved
const expectedParsedRundown = [
{
//timeStart: 28800000,
//timeEnd: 32410000,
title: 'Guest Welcome',
timerType: 'count-down',
endAction: 'none',
isPublic: true,
skip: false,
note: 'Ballyhoo',
custom: {},
colour: 'red',
type: 'event',
cue: '101',
},
{
//timeStart: 32400000,
//timeEnd: 34200000,
title: 'A song from the hearth',
timerType: 'clock',
endAction: 'load-next',
isPublic: false,
skip: true,
note: 'Rainbow chase',
custom: {},
colour: '#F00',
type: 'event',
cue: '102',
},
];
const parsedData = parseExcel(testdata, {}, importMap);
expect(parsedData.customFields).toStrictEqual({
niu1: {
type: 'string',
colour: '',
label: 'niu1',
},
niu2: {
type: 'string',
colour: '',
label: 'niu2',
},
});
expect(parsedData.rundown.length).toBe(2);
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
});
it('ignores unknown event types', () => {
const testdata = [
[
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Public',
'Skip',
'Notes',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'cue',
],
[
'1899-12-30T07:00:00.000Z',
'1899-12-30T08:00:10.000Z',
'Guest Welcome',
'',
'skip',
'x',
'',
'Ballyhoo',
'a0',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'a8',
'a9',
'red',
101,
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'load-next',
'clock',
'',
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
102,
],
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testdata, {}, importMap);
expect(result.rundown.length).toBe(1);
expect((result.rundown.at(0) as OntimeEvent).title).toBe('A song from the hearth');
});
it('imports blocks', () => {
const testdata = [
[
'Time Start',
'Time End',
'Title',
'End Action',
'Timer type',
'Public',
'Skip',
'Notes',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'cue',
],
[
'',
'',
'',
'',
'block',
'x',
'',
'Ballyhoo',
'a0',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'a8',
'a9',
'red',
101,
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'load-next',
'clock',
'',
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
102,
],
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testdata, {}, importMap);
expect(result.rundown.length).toBe(2);
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Block);
});
it('imports as events if there is no timer type column', () => {
const testdata = [
[
'Time Start',
'Time End',
'Title',
'End Action',
'Public',
'Skip',
'Notes',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'cue',
],
['', '', '', '', 'x', '', 'Ballyhoo', 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'red', 101],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'load-next',
'',
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
102,
],
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testdata, {}, importMap);
expect(result.rundown.length).toBe(2);
expect(result.rundown[0]).toMatchObject({
type: SupportedEvent.Event,
timerType: TimerType.CountDown,
});
expect(result.rundown[1]).toMatchObject({
type: SupportedEvent.Event,
timerType: TimerType.CountDown,
});
});
it('imports as events if timer type is empty or has whitespace', () => {
const testdata = [
[
'Time Start',
'Time End',
'Title',
'End Action',
'Public',
'Skip',
'Notes',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'cue',
'Timer type',
],
[
'',
'',
'',
'',
'x',
'',
'Ballyhoo',
'a0',
'a1',
'a2',
'a3',
'a4',
'a5',
'a6',
'a7',
'a8',
'a9',
'red',
101,
' ',
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'load-next',
'',
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
102,
undefined,
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'load-next',
'',
'x',
'Rainbow chase',
'b0',
'',
'',
'',
'',
'b5',
'',
'',
'',
'',
'#F00',
103,
' count-up ',
],
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testdata, {}, importMap);
expect(result.rundown.length).toBe(3);
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown);
expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
expect((result.rundown.at(2) as OntimeEvent).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(2) as OntimeEvent).timerType).toBe(TimerType.CountUp);
});
it('am/pm conversion to 24h', () => {
const testData = [
['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'],
['4:30:00', '4:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['9:45:00', '10:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
['16:30:00', '16:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['21:45:00', '22:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
['4:30:00AM', '4:36:00AM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['9:45:00AM', '10:56:00AM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
['4:30:00PM', '4:36:00PM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
['9:45:00PM', '10:56:00PM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testData, {}, importMap);
const { rundown } = parseRundown(result);
const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000);
expect((events.at(1) as OntimeEvent).timeStart).toEqual(35100000);
expect((events.at(2) as OntimeEvent).timeStart).toEqual(59400000);
expect((events.at(3) as OntimeEvent).timeStart).toEqual(78300000);
expect((events.at(4) as OntimeEvent).timeStart).toEqual(16200000);
expect((events.at(5) as OntimeEvent).timeStart).toEqual(35100000);
expect((events.at(6) as OntimeEvent).timeStart).toEqual(59400000);
expect((events.at(7) as OntimeEvent).timeStart).toEqual(78300000);
});
it('handle leading and trailing whitespace', () => {
const testData = [
['Time Start', 'Time End', ' Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour ', 'cue'],
['4:30:00', '4:30:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
];
const importMap = {
worksheet: 'event schedule',
timeStart: ' time start',
timeEnd: 'time end ',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testData, {}, importMap);
const { rundown } = parseRundown(result);
const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000); //<--leading white space in MAP
expect((events.at(0) as OntimeEvent).timeEnd).toEqual(16200000); //<--trailing white space in MAP
expect((events.at(0) as OntimeEvent).title).toEqual('A song from the hearth'); //<--leading white space in Excel data
expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data
});
it('parses link start and checks that is applicable', () => {
const testData = [
[
'Time Start',
'Time End',
'Title',
'End Action',
'Public',
'Skip',
'Notes',
'Colour',
'cue',
'Link Start',
'Timer type',
],
['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'],
['9:45:00', '10:56:00', 'B', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'],
['10:00:00', '16:36:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], // <-- incorrect start times are overridden
['21:45:00', '22:56:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'],
['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'],
['00:0:00', '23:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], // <-- link past blocks
[],
];
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
};
const result = parseExcel(testData, {}, importMap);
const parseResult = parseRundown(result);
cache.init(parseResult.rundown, parseResult.customFields);
const { rundown, order } = cache.get();
const firstId = order.at(0); // A
const secondId = order.at(1); // B
const thirdId = order.at(2); // C
const fourthId = order.at(3); // D
const fifthId = order.at(4); // Block
const sixthId = order.at(5); // E
if (!firstId || !secondId || !thirdId || !fourthId || !fifthId || !sixthId) {
throw new Error('Unexpected value');
}
expect(rundown).toMatchObject({
[firstId]: {
title: 'A',
timeStart: 16200000,
},
[secondId]: {
title: 'B',
timeStart: (rundown[firstId] as OntimeEvent).timeEnd,
linkStart: (rundown[firstId] as OntimeEvent).id,
},
[thirdId]: {
title: 'C',
timeStart: (rundown[secondId] as OntimeEvent).timeEnd,
linkStart: (rundown[secondId] as OntimeEvent).id,
},
[fourthId]: {
title: 'D',
timeStart: 78300000,
linkStart: null,
},
[fifthId]: {
title: 'BLOCK',
type: SupportedEvent.Block,
},
[sixthId]: {
title: 'E',
timeStart: (rundown[fourthId] as OntimeEvent).timeEnd,
linkStart: (rundown[fourthId] as OntimeEvent).id,
},
});
});
it('#971 BUG: parses time fields and booleans', () => {
const testData = [
[
'Cue',
'Colour',
'Time Start',
'Time End',
'Duration',
'Link Start',
'Title',
'Note',
'Timer Type',
'End Action',
'Warning time',
'Danger time',
'Public',
'Skip',
],
[
'SETUP',
'',
'1899-12-30T07:15:00.000Z',
'1899-12-30T08:30:00.000Z',
'',
'false',
'Setup',
'',
'count-down',
'none',
'15',
'00:05:00',
'FALSE',
'TRUE',
],
[
'MEET1',
'#779BE7',
'1899-12-30T08:30:00.000Z',
'1899-12-30T10:00:00.000Z',
'',
'false',
'Meeting 1',
'',
'count-down',
'none',
15,
'00:05:00',
'TRUE',
'FALSE',
],
[
'MEET2',
'#779BE7',
'1899-12-30T10:00:00.000Z',
'',
'60',
'false',
'Meeting 2',
'',
'count-down',
'none',
'13',
'5',
'TRUE',
'FALSE',
],
[
'lunch',
'#77C785',
'',
'1899-12-30T11:30:00.000Z',
'',
'true',
'Lunch',
'',
'count-down',
'none',
13,
5,
'FALSE',
'FALSE',
],
[
'MEET3',
'#779BE7',
'1899-12-30T11:30:00.000Z',
'',
90,
false,
'Meeting 3',
'',
'count-up',
'none',
'11',
5,
'TRUE',
'FALSE',
],
['MEET4', '#779BE7', '', '', 30, true, 'Meeting 4', '', 'count-up', 'none', 11, '00:05:00', 'TRUE', 'FALSE'],
];
const parsedData = parseExcel(testData, {});
const { rundown } = parsedData;
// '15' as a string is parsed by smart time entry as minutes
expect(rundown[0]).toMatchObject({
cue: 'SETUP',
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// elements in bug report
// 15 is a number, in which case we parse it as a minutes value
expect(rundown[1]).toMatchObject({
cue: 'MEET1',
timeWarning: 15 * MILLIS_PER_MINUTE,
});
// in the case where a string is passed, we need to check whether it is an ISO 8601 date
expect(rundown[2]).toMatchObject({
cue: 'MEET2',
duration: 60 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(rundown[3]).toMatchObject({
cue: 'lunch',
timeWarning: 13 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(rundown[4]).toMatchObject({
cue: 'MEET3',
duration: 90 * MILLIS_PER_MINUTE,
linkStart: false,
timeWarning: 11 * MILLIS_PER_MINUTE,
timeDanger: 5 * MILLIS_PER_MINUTE,
});
expect(rundown[5]).toMatchObject({
cue: 'MEET4',
duration: 30 * MILLIS_PER_MINUTE,
timeWarning: 11 * MILLIS_PER_MINUTE,
// if we get a boolean, we should just use that
linkStart: true,
});
});
});