From e5fdf27f6cd3263dbb264588e881f876b257aa0a Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Wed, 23 Aug 2023 22:31:15 +0200 Subject: [PATCH] fix: apply delay (#494) * fix: bug with applying delays * fix: show delay data only in production screens * chore: cover delay with feature test * refactor: simplify type assertion --- DEVELOPMENT.md | 37 +- .../components/input/time-input/TimeInput.tsx | 14 +- .../common/components/schedule/Schedule.tsx | 11 +- .../client/src/common/hooks/useEventAction.ts | 4 +- .../client/src/features/cuesheet/Cuesheet.tsx | 17 +- .../src/features/event-editor/EventEditor.tsx | 6 +- .../features/viewers/backstage/Backstage.tsx | 2 +- .../src/services/__tests__/delayUtils.test.ts | 249 ++++++++++++++ apps/server/src/services/delayUtils.ts | 37 ++ .../rundown-service/RundownService.ts | 64 +--- .../__tests__/RundownService.test.ts | 319 ------------------ .../rundown-service/delayedRundown.utils.ts | 60 ++-- e2e/tests/features/203-delay-block.spec.ts | 94 ++++++ packages/types/src/index.ts | 3 +- packages/types/src/utils/guards.ts | 14 + .../utils/src/rundown-utils/rundownUtils.ts | 17 +- 16 files changed, 517 insertions(+), 431 deletions(-) create mode 100644 apps/server/src/services/__tests__/delayUtils.test.ts create mode 100644 apps/server/src/services/delayUtils.ts delete mode 100644 apps/server/src/services/rundown-service/__tests__/RundownService.test.ts create mode 100644 e2e/tests/features/203-delay-block.spec.ts create mode 100644 packages/types/src/utils/guards.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6cf8f2165..d4c12d822 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,38 +1,48 @@ # GETTING STARTED Ontime consists of 3 distinct parts -- __client__: A React app for Ontime's UI and web clients + +- __client__: A React app for Ontime's UI and web clients - __electron__: An electron app which facilitates the cross-platform distribution of Ontime - __server__: A node application which handles the domains services and integrations -The steps below will assume you have locally installed the necessary dependencies. +The steps below will assume you have locally installed the necessary dependencies. Other dependencies will be installed as part of the setup + - __node__ (>=16.16) - __pnpm__ (>=7) - __docker__ (only necessary to run and build docker images) ## LOCAL DEVELOPMENT -The electron app is only necessary to distribute an installable version of the app and is not required for local development. +The electron app is only necessary to distribute an installable version of the app and is not required for local +development. Locally, we would need to run both the React client and the node.js server in development mode From the project root, run the following commands + - __Install the project dependencies__ by running `pnpm i` - __Run dev mode__ by running `turbo dev` ### Debugging backend + To debug backend code in Node.js: + - Open two separate terminals and navigate to the `apps/client` and `apps/server` directories. -- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications. -- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`. +- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server + applications. +- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by + running `pnpm dev:inspect`. ## TESTING -Generally we have 2 types of tests. +Generally we have 2 types of tests. + - Unit tests for functions that contain business logic - End-to-end tests for core features ### Unit tests + Unit tests are contained in mostly all the apps and packages (client, server and utils) You can run unit tests by running turbo `turbo test:pipeline` from the project root. @@ -41,12 +51,20 @@ This will run all tests and close test runner. Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode ### E2E tests -E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against + +E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the +webserver to test against These tests also run against a separate version of the DB (test-db) You can run playwright tests from project root with `pnpm e2e` -When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server` +When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually +start the webserver with `pnpm dev:server` + +Some other useful commands + +- `pnpm e2e --ui` open playwright UI +- `pnpm e2e --headed` run tests with a visible browser window ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) @@ -54,6 +72,7 @@ Ontime uses Electron to distribute the application. You can generate a distribution for your OS by running the following steps. From the project root, run the following commands + - __Install the project dependencies__ by running `pnpm i` - __Build the UI and server__ by running `turbo build:local` - __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux` @@ -66,10 +85,12 @@ Ontime provides a docker-compose file to aid with building and running docker im While it should allow for a generic setup, it might need to be modified to fit your infrastructure. From the project root, run the following commands + - __Install the project dependencies__ by running `pnpm i` - __Build docker image from__ by running `docker build -t getontime/ontime` - __Run docker image from compose__ by running `docker-compose up -d` Other useful commands + - __List running processes__ by running `docker ps` - __Kill running process__ by running `docker kill ` diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index 4f50659d7..04fdf5b31 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) { } export default function TimeInput(props: TimeInputProps) { - const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props; + const { + id, + name, + submitHandler, + time = 0, + delay = 0, + placeholder, + validationHandler, + previousEnd = 0, + warning, + } = props; const { emitError } = useEmitLog(); const inputRef = useRef(null); const [value, setValue] = useState(''); @@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) { { const newEvent: Partial = { ...event }; // ************* CHECK OPTIONS specific to events - if (newEvent.type === SupportedEvent.Event) { + if (isOntimeEvent(newEvent)) { const applicationOptions = { defaultPublic: options?.defaultPublic ?? defaultPublic, startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd, diff --git a/apps/client/src/features/cuesheet/Cuesheet.tsx b/apps/client/src/features/cuesheet/Cuesheet.tsx index bd863aedc..f8806e65f 100644 --- a/apps/client/src/features/cuesheet/Cuesheet.tsx +++ b/apps/client/src/features/cuesheet/Cuesheet.tsx @@ -12,7 +12,7 @@ import { } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { useLocalStorage } from '../../common/hooks/useLocalStorage'; import { millisToDelayString } from '../../common/utils/dateConfig'; @@ -196,15 +196,14 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu {table.getRowModel().rows.map((row) => { - const entryType = row.original.type as SupportedEvent; const key = row.original.id; const isSelected = selectedId === key; if (isSelected) { isPast = false; } - if (entryType === SupportedEvent.Block) { - const title = (row.original as OntimeBlock).title; + if (isOntimeBlock(row.original)) { + const title = row.original.title; return ( @@ -212,8 +211,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu ); } - if (entryType === SupportedEvent.Delay) { - const delayVal = (row.original as OntimeDelay).duration; + if (isOntimeDelay(row.original)) { + const delayVal = row.original.duration; if (!showDelayBlock || delayVal === 0) { return null; @@ -226,7 +225,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu ); } - if (entryType === SupportedEvent.Event) { + if (isOntimeEvent(row.original)) { eventIndex++; const isSelected = key === selectedId; if (isSelected) { @@ -238,9 +237,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu } const bgFallback = 'transparent'; - const bgColour = (row.original as OntimeEvent).colour || bgFallback; + const bgColour = row.original.colour || bgFallback; const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour); - const isSkipped = (row.original as OntimeEvent).skip; + const isSkipped = row.original.skip; let rowBgColour: string | undefined; if (row.original.id === selectedId) { diff --git a/apps/client/src/features/event-editor/EventEditor.tsx b/apps/client/src/features/event-editor/EventEditor.tsx index 11e49edc1..1163b9a01 100644 --- a/apps/client/src/features/event-editor/EventEditor.tsx +++ b/apps/client/src/features/event-editor/EventEditor.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { OntimeEvent, SupportedEvent } from 'ontime-types'; +import { isOntimeEvent, OntimeEvent } from 'ontime-types'; import CopyTag from '../../common/components/copy-tag/CopyTag'; import { useEventAction } from '../../common/hooks/useEventAction'; @@ -29,8 +29,8 @@ export default function EventEditor() { } const event = data.find((event) => event.id === openId); - if (event && event.type === SupportedEvent.Event) { - setEvent(event as OntimeEvent); + if (event && isOntimeEvent(event)) { + setEvent(event); } }, [data, openId]); diff --git a/apps/client/src/features/viewers/backstage/Backstage.tsx b/apps/client/src/features/viewers/backstage/Backstage.tsx index e3cf00ca8..54ca836ae 100644 --- a/apps/client/src/features/viewers/backstage/Backstage.tsx +++ b/apps/client/src/features/viewers/backstage/Backstage.tsx @@ -166,7 +166,7 @@ export default function Backstage(props: BackstageProps) { - +
diff --git a/apps/server/src/services/__tests__/delayUtils.test.ts b/apps/server/src/services/__tests__/delayUtils.test.ts new file mode 100644 index 000000000..ee184724e --- /dev/null +++ b/apps/server/src/services/__tests__/delayUtils.test.ts @@ -0,0 +1,249 @@ +import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; +import { _applyDelay } from '../delayUtils.js'; + +describe('_applyDelay() ', () => { + describe('in a rundown without the delay field, persisted rundown', () => { + it('applies delays', () => { + const delayId = '1'; + const testRundown: OntimeRundown = [ + { id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, + { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + { id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + ]; + + const expected = [ + { id: '2', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, + { id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 20, duration: 10, revision: 2 } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + ]; + + const updatedRundown = _applyDelay(delayId, testRundown); + expect(updatedRundown).toStrictEqual(expected); + }); + it('applies negative delays', () => { + const delayId = '1'; + const testRundown: OntimeRundown = [ + { id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, + { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + { id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + ]; + + const expected = [ + { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, + { id: '3', type: SupportedEvent.Event, timeStart: 10, timeEnd: 30, duration: 20, revision: 2 } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + ]; + + const updatedRundown = _applyDelay(delayId, testRundown); + expect(updatedRundown).toStrictEqual(expected); + }); + it('maintains constant duration', () => { + const delayId = '1'; + const testRundown: OntimeRundown = [ + { id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, + { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, + { id: '3', type: SupportedEvent.Event, timeStart: 20, timeEnd: 40, duration: 20, revision: 1 } as OntimeEvent, + ]; + + const expected = [ + { id: '2', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 2 } as OntimeEvent, + { id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent, + ]; + + const updatedRundown = _applyDelay(delayId, testRundown); + expect(updatedRundown).toStrictEqual(expected); + }); + }); + describe('in a rundown with the delay field, cached rundown', () => { + it('applies delays', () => { + const delayId = '1'; + const testRundown: OntimeRundown = [ + { id: delayId, type: SupportedEvent.Delay, duration: 10 } as OntimeDelay, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: 10, + } as OntimeEvent, + { + id: '3', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: 10, + } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { + id: '5', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: 0, + } as OntimeEvent, + ]; + + const expected = [ + { + id: '2', + type: SupportedEvent.Event, + timeStart: 10, + timeEnd: 20, + duration: 10, + revision: 2, + delay: 0, + } as OntimeEvent, + { + id: '3', + type: SupportedEvent.Event, + timeStart: 10, + timeEnd: 20, + duration: 10, + revision: 2, + delay: 0, + } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { + id: '5', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: 0, + } as OntimeEvent, + ]; + + const updatedRundown = _applyDelay(delayId, testRundown); + expect(updatedRundown).toStrictEqual(expected); + }); + it('applies negative delays', () => { + const delayId = '1'; + const testRundown: OntimeRundown = [ + { id: delayId, type: SupportedEvent.Delay, duration: -10 } as OntimeDelay, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: -10, + } as OntimeEvent, + { + id: '3', + type: SupportedEvent.Event, + timeStart: 20, + timeEnd: 40, + duration: 20, + revision: 1, + delay: -10, + } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { + id: '5', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: 0, + } as OntimeEvent, + ]; + + const expected = [ + { + id: '2', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 2, + delay: 0, + } as OntimeEvent, + { + id: '3', + type: SupportedEvent.Event, + timeStart: 10, + timeEnd: 30, + duration: 20, + revision: 2, + delay: 0, + } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, + { + id: '5', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: 0, + } as OntimeEvent, + ]; + + const updatedRundown = _applyDelay(delayId, testRundown); + expect(updatedRundown).toStrictEqual(expected); + }); + it('maintains constant duration', () => { + const delayId = '1'; + const testRundown: OntimeRundown = [ + { id: delayId, type: SupportedEvent.Delay, duration: -30 } as OntimeDelay, + { + id: '2', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 1, + delay: -30, + } as OntimeEvent, + { + id: '3', + type: SupportedEvent.Event, + timeStart: 20, + timeEnd: 40, + duration: 20, + revision: 1, + delay: -30, + } as OntimeEvent, + ]; + + const expected = [ + { + id: '2', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 10, + duration: 10, + revision: 2, + delay: 0, + } as OntimeEvent, + { + id: '3', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 20, + duration: 20, + revision: 2, + delay: 0, + } as OntimeEvent, + ]; + + const updatedRundown = _applyDelay(delayId, testRundown); + expect(updatedRundown).toStrictEqual(expected); + }); + }); +}); diff --git a/apps/server/src/services/delayUtils.ts b/apps/server/src/services/delayUtils.ts new file mode 100644 index 000000000..59a62c736 --- /dev/null +++ b/apps/server/src/services/delayUtils.ts @@ -0,0 +1,37 @@ +import { isOntimeBlock, isOntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; + +import { deleteAtIndex } from '../utils/arrayUtils.js'; + +export function _applyDelay(eventId: string, rundown: OntimeRundown): OntimeRundown { + const delayIndex = rundown.findIndex((event) => event.id === eventId); + const delayEvent = rundown.at(delayIndex); + + if (delayEvent.type !== SupportedEvent.Delay) { + throw new Error('Given event ID is not a delay'); + } + + const updatedRundown = [...rundown]; + const delayValue = delayEvent.duration; + + if (delayValue === 0 || delayIndex === rundown.length - 1) { + // nothing to apply + return updatedRundown; + } + + for (let i = delayIndex + 1; i < rundown.length; i++) { + const currentEvent = updatedRundown[i]; + + if (isOntimeBlock(currentEvent)) { + break; + } else if (isOntimeEvent(currentEvent)) { + currentEvent.timeStart = Math.max(0, currentEvent.timeStart + delayValue); + currentEvent.timeEnd = Math.max(currentEvent.duration, currentEvent.timeEnd + delayValue); + if (currentEvent.delay) { + currentEvent.delay = currentEvent.delay - delayValue; + } + currentEvent.revision += 1; + } + } + + return deleteAtIndex(delayIndex, updatedRundown); +} diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index f00838033..09e9f39cc 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -4,7 +4,6 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, - OntimeRundown, Playback, SupportedEvent, } from 'ontime-types'; @@ -18,6 +17,7 @@ import { sendRefetch } from '../../adapters/websocketAux.js'; import { runtimeCacheStore } from '../../stores/cachingStore.js'; import { cachedAdd, + cachedApplyDelay, cachedClear, cachedDelete, cachedEdit, @@ -166,7 +166,7 @@ export async function addEvent(eventData: Partial | Partial} - */ -export async function applyDelay(eventId: string) { - const rundown: OntimeRundown = DataProvider.getRundown(); - const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown); - if (delayIndex === null) { - throw new Error(`Delay event with ID ${eventId} not found`); - } - - await DataProvider.setRundown(updatedRundown); - await deleteEvent(eventId); -} - /** * Forces update in the store * Called when we make changes to the rundown object diff --git a/apps/server/src/services/rundown-service/__tests__/RundownService.test.ts b/apps/server/src/services/rundown-service/__tests__/RundownService.test.ts deleted file mode 100644 index a4b9e928f..000000000 --- a/apps/server/src/services/rundown-service/__tests__/RundownService.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types'; -import { _applyDelay } from '../RundownService.js'; - -describe('applyDelay()', () => { - it('applies its duration to following events', () => { - const rundown: OntimeRundown = [ - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 4, - id: '659e1', - }, - { - duration: 600000, - type: SupportedEvent.Delay, - revision: 0, - id: '07986', - }, - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 4, - id: 'd48c2', - }, - ]; - - const eventId = rundown[1].id; - const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown); - - expect(delayIndex).toBe(1); - // we do not delay delays anymore - expect(updatedRundown.length).toBe(3); - expect(rundown.length).toBe(3); - expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart); - expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart); - expect(updatedRundown[2].timeEnd).toBe(rundown[1].duration + rundown[2].timeEnd); - }); - it('stops propagating on blocks', () => { - const rundown: OntimeRundown = [ - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 0, - id: '659e1', - }, - { - duration: 600000, - type: SupportedEvent.Delay, - revision: 0, - id: '07986', - }, - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 0, - id: 'd48c2', - }, - { - title: '', - type: SupportedEvent.Block, - id: '9870d', - }, - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 1200000, - timeEnd: 1800000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 2, - id: '2f185', - }, - ]; - - const eventId = rundown[1].id; - const { updatedRundown } = _applyDelay(eventId, rundown); - - expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart); - expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart); - expect(updatedRundown[4].timeStart).toBe(rundown[4].timeStart); - }); - it('only applies given delay', () => { - const rundown: OntimeRundown = [ - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 0, - id: '659e1', - }, - { - duration: 600000, - type: SupportedEvent.Delay, - revision: 0, - id: '07986', - }, - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 1200000, - timeEnd: 1200000, - duration: 0, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 0, - id: '1c48f', - }, - { - duration: 1200000, - type: SupportedEvent.Delay, - revision: 0, - id: '7db42', - }, - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 0, - id: 'd48c2', - }, - { - title: '', - type: SupportedEvent.Block, - id: '9870d', - }, - { - title: '', - subtitle: '', - presenter: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - timeStart: 1200000, - timeEnd: 1800000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: SupportedEvent.Event, - revision: 0, - id: '2f185', - }, - ]; - - const eventId = rundown[1].id; - const { updatedRundown } = _applyDelay(eventId, rundown); - - expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart); - expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart); - expect(updatedRundown[4].timeStart).toBe(rundown[1].duration + rundown[4].timeStart); - }); -}); diff --git a/apps/server/src/services/rundown-service/delayedRundown.utils.ts b/apps/server/src/services/rundown-service/delayedRundown.utils.ts index e2c5a6914..51c7d727d 100644 --- a/apps/server/src/services/rundown-service/delayedRundown.utils.ts +++ b/apps/server/src/services/rundown-service/delayedRundown.utils.ts @@ -1,10 +1,20 @@ -import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; +import { + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + OntimeBlock, + OntimeDelay, + OntimeEvent, + OntimeRundown, + OntimeRundownEntry, +} from 'ontime-types'; +import { swapOntimeEvents } from 'ontime-utils'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js'; import { isProduction } from '../../setup.js'; import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js'; -import { swapOntimeEvents } from 'ontime-utils'; +import { _applyDelay } from '../delayUtils.js'; /** * Key of rundown in cache @@ -51,7 +61,7 @@ export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeD let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown); // update delay cache - if (event.type === SupportedEvent.Event) { + if (isOntimeEvent(event)) { // if it is an event, we need its delay (newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown); } else { @@ -79,22 +89,20 @@ export async function cachedEdit( } const updatedRundown = DataProvider.getRundown(); - const newEvent = { ...updatedRundown[indexInMemory], ...patchObject }; - if (newEvent.type === SupportedEvent.Event) { + const newEvent = { ...updatedRundown[indexInMemory], ...patchObject } as OntimeRundownEntry; + if (isOntimeEvent(newEvent)) { newEvent.revision++; } - // @ts-expect-error -- this merge is safe updatedRundown[indexInMemory] = newEvent; let newDelayedRundown = getDelayedRundown(); if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) { invalidateFromError(); } else { - // @ts-expect-error -- this merge is safe newDelayedRundown[indexInMemory] = newEvent; - if (newEvent.type === SupportedEvent.Event) { + if (isOntimeEvent(newEvent)) { (newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown); - } else if (newEvent.type === SupportedEvent.Delay) { + } else if (isOntimeDelay(newEvent)) { // blocks have no reason to change the rundown, from delays we need to recalculate newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown); } @@ -124,13 +132,12 @@ export async function cachedDelete(eventId: string) { } let updatedRundown = DataProvider.getRundown(); - const eventType = updatedRundown[eventIndex].type; updatedRundown = deleteAtIndex(eventIndex, updatedRundown); if (eventId !== delayedRundown[eventIndex].id) { invalidateFromError(); } else { delayedRundown = deleteAtIndex(eventIndex, delayedRundown); - if (eventType === SupportedEvent.Delay || eventType === SupportedEvent.Block) { + if (isOntimeDelay(updatedRundown[eventIndex]) || isOntimeBlock(updatedRundown[eventIndex])) { // for events, we do not have to worry // the following event, would have taken the place of the deleted event by now delayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, delayedRundown); @@ -205,6 +212,19 @@ export async function cachedSwap(fromEventId: string, toEventId: string) { await DataProvider.setRundown(rundownToUpdate); } +export async function cachedApplyDelay(eventId: string) { + // update persisted rundown + const rundown: OntimeRundown = DataProvider.getRundown(); + const persistedRundown = _applyDelay(eventId, rundown); + + const delayedRundown = getDelayedRundown(); + const cachedRundown = _applyDelay(eventId, delayedRundown); + + // update + runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown); + await DataProvider.setRundown(persistedRundown); +} + /** * Calculates all delays in a given rundown * @param rundown @@ -214,11 +234,11 @@ export function calculateRuntimeDelays(rundown: OntimeRundown) { const updatedRundown = [...rundown]; for (const [index, event] of updatedRundown.entries()) { - if (event.type === SupportedEvent.Delay) { + if (isOntimeDelay(event)) { accumulatedDelay += event.duration; - } else if (event.type === SupportedEvent.Block) { + } else if (isOntimeBlock(event)) { accumulatedDelay = 0; - } else if (event.type === SupportedEvent.Event) { + } else if (isOntimeEvent(event)) { updatedRundown[index] = { ...event, delay: accumulatedDelay, @@ -243,15 +263,15 @@ export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: Ont for (let i = eventIndex; i < rundown.length; i++) { const event = rundown[i]; - if (event.type === SupportedEvent.Delay) { + if (isOntimeDelay(event)) { accumulatedDelay += event.duration; - } else if (event.type === SupportedEvent.Block) { + } else if (isOntimeBlock(event)) { if (i === eventIndex) { accumulatedDelay = 0; } else { break; } - } else if (event.type === SupportedEvent.Event) { + } else if (isOntimeEvent(event)) { updatedRundown[i] = { ...event, delay: accumulatedDelay, @@ -284,11 +304,11 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number { // we need to check the event before const event = rundown[eventIndex - 1]; - if (event.type === SupportedEvent.Delay) { + if (isOntimeDelay(event)) { return event.duration + getDelayAt(eventIndex - 1, rundown); - } else if (event.type === SupportedEvent.Block) { + } else if (isOntimeBlock(event)) { return 0; - } else if (event.type === SupportedEvent.Event) { + } else if (isOntimeEvent(event)) { return event.delay ?? 0; } return 0; diff --git a/e2e/tests/features/203-delay-block.spec.ts b/e2e/tests/features/203-delay-block.spec.ts new file mode 100644 index 000000000..ce385aa1f --- /dev/null +++ b/e2e/tests/features/203-delay-block.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; + +test('delay blocks add time to events', async ({ page }) => { + await page.goto('http://localhost:4001/editor'); + + // delete all events and add a new one + await page.getByRole('button', { name: 'Event...' }).click(); + await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Event...' }).click(); + await page.getByRole('menuitem', { name: 'Add event at start' }).click(); + + // add data to new event + await page.getByTestId('panel-rundown').getByPlaceholder('Start').click(); + await page.getByTestId('panel-rundown').getByPlaceholder('Start').fill('10m'); + await page.getByTestId('panel-rundown').getByPlaceholder('Start').press('Enter'); + await page.getByTestId('panel-rundown').getByPlaceholder('End').click(); + await page.getByTestId('panel-rundown').getByPlaceholder('End').fill('20m'); + await page.getByTestId('panel-rundown').getByPlaceholder('End').press('Enter'); + + // add delay block + await page.getByRole('button', { name: 'Event...' }).click(); + await page.getByRole('menuitem', { name: 'Add delay at start' }).click(); + + // fill positive delay + await page.getByTestId('delay-input').click(); + await page.getByTestId('delay-input').fill('2m'); + await page.getByTestId('delay-input').press('Enter'); + await page.getByText('+2 minNew start: 00:12:00').click(); + + // make negative delay + await page.getByText('Subtract time').click(); + await page.getByText('-2 minNew start: 00:08:00').click(); + + // apply delay + await page.getByRole('button', { name: 'Apply' }).click(); + await expect(page.getByTestId('panel-rundown').getByTestId('time-input-timeStart')).toHaveValue('00:08:00'); + + // add new delay + await page.getByTestId('panel-rundown').getByPlaceholder('Start').click(); + await page.getByRole('button', { name: 'Event...' }).click(); + await page.getByRole('menuitem', { name: 'Add delay at start' }).click(); + await page.getByTestId('delay-input').click(); + await page.getByTestId('delay-input').fill('10m'); + await page.getByTestId('delay-input').press('Enter'); + await page.getByText('+10 minNew start: 00:18:00').click(); + + // cancel delay + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByTestId('panel-rundown').getByTestId('time-input-timeStart')).toHaveValue('00:08:00'); +}); + +test('delays are show correctly', async ({ page }) => { + await page.goto('http://localhost:4001/editor'); + + // add a test event + await page.getByRole('button', { name: 'Event...' }).click(); + await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('time-input-timeStart').click(); + await page.getByTestId('panel-rundown').getByTestId('time-input-timeStart').click(); + await page.getByTestId('panel-rundown').getByTestId('time-input-timeStart').fill('10'); + await page.getByTestId('panel-rundown').getByTestId('time-input-timeStart').press('Enter'); + await page.getByTestId('panel-rundown').getByTestId('time-input-timeEnd').click(); + await page.getByTestId('panel-rundown').getByTestId('time-input-timeEnd').fill('20'); + await page.getByTestId('panel-rundown').getByTestId('time-input-timeEnd').press('Enter'); + await page.getByText('Event title').click(); + await page.getByPlaceholder('Event title').fill('test'); + await page.getByPlaceholder('Event title').press('Enter'); + + await page.getByText('SED').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Toggle public' }).click(); + + // add a delay + await page.getByRole('button', { name: 'Event...' }).click(); + await page.getByRole('menuitem', { name: 'Add delay at start' }).click(); + await page.getByTestId('delay-input').click(); + await page.getByTestId('delay-input').fill('1'); + await page.getByTestId('delay-input').press('Enter'); + + // delay is shown in the editor + await page.getByText('+1 minNew start: 00:11:00').click(); + + // delay is shown in the cuesheet + await page.goto('http://localhost:4001/cuesheet'); + await page.getByRole('cell', { name: '+1 min' }).click(); + + // delay is NOT shown in the public view + await page.goto('http://localhost:4001/public'); + await page.getByText('00:10 → 00:20').click(); + + // delay is shown in the backstage view + await page.goto('http://localhost:4001/backstage'); + await page.getByText('00:11 → 00:21').click(); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9ac8eaf5e..65efda3ea 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -46,5 +46,6 @@ export type { TitleBlock } from './definitions/runtime/TitleBlock.type.js'; // CLIENT -// UTILITY TYPES +// TYPE UTILITIES +export { isOntimeBlock, isOntimeDelay, isOntimeEvent } from './utils/guards.js'; export type { MaybeNumber } from './utils/utils.type.js'; diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts new file mode 100644 index 000000000..3bdfd19d4 --- /dev/null +++ b/packages/types/src/utils/guards.ts @@ -0,0 +1,14 @@ +import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; +import { OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; + +export function isOntimeEvent(event: OntimeRundownEntry | Partial): event is OntimeEvent { + return event.type === SupportedEvent.Event; +} + +export function isOntimeDelay(event: OntimeRundownEntry | Partial): event is OntimeDelay { + return event.type === SupportedEvent.Delay; +} + +export function isOntimeBlock(event: OntimeRundownEntry | Partial): event is OntimeBlock { + return event.type === SupportedEvent.Block; +} diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index c5cdad8db..c1efeb095 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -1,4 +1,4 @@ -import { OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { dayInMs } from '../timeConstants.js'; @@ -18,8 +18,9 @@ export function getFirst(rundown: OntimeRundownEntry[]) { */ export function getFirstEvent(rundown: OntimeRundownEntry[]) { for (let i = 0; i < rundown.length; i++) { - if (rundown[i].type === SupportedEvent.Event) { - return rundown[i] as OntimeEvent; + const event = rundown[i]; + if (isOntimeEvent(event)) { + return event; } } return null; @@ -53,8 +54,9 @@ export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string): } for (let i = index + 1; i < rundown.length; i++) { - if (rundown[i].type === SupportedEvent.Event) { - return rundown[i] as OntimeEvent; + const event = rundown[i]; + if (isOntimeEvent(event)) { + return event; } } return null; @@ -88,8 +90,9 @@ export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: strin } for (let i = index - 1; i >= 0; i--) { - if (rundown[i].type === SupportedEvent.Event) { - return rundown[i] as OntimeEvent; + const event = rundown[i]; + if (isOntimeEvent(event)) { + return event; } } return null;