/* 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 = { 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(validated.timeStart); assertType(validated.timeEnd); assertType(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 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 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, }); }); });