mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-13 18:19:40 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a6feed49b | |||
| c6cf9f2bf7 | |||
| 2f73303554 | |||
| 96943ca528 | |||
| 8cfffd32c7 | |||
| 24a606a246 | |||
| c322028821 | |||
| 9f40d962ea | |||
| 1e851ae40d | |||
| 2bcda2dfa4 | |||
| e2a2ea1d24 | |||
| 5be596b83e | |||
| ab15a65585 | |||
| 65ef442a9d | |||
| 71a7f26493 | |||
| b850a51204 | |||
| 0094c15da2 | |||
| e7d62f44e7 | |||
| 3c8d7e51f5 | |||
| 3da3950fce | |||
| ac3694ce4d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useLocation } from 'react-router';
|
|||||||
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||||
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
|
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
|
||||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import { useClientStore } from '../../stores/clientStore';
|
import { useClientStore } from '../../stores/clientStore';
|
||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
@@ -106,8 +105,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
{route.label}
|
{route.label}
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLocalhost && (
|
{isLocalhost && (
|
||||||
@@ -120,27 +117,3 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
|
||||||
const location = useLocation();
|
|
||||||
const { data: urlPresets } = useUrlPresets();
|
|
||||||
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
|
|
||||||
|
|
||||||
if (navPresets.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<hr className={style.separator} />
|
|
||||||
{navPresets.map((preset) => (
|
|
||||||
<ClientLink
|
|
||||||
key={preset.alias}
|
|
||||||
to={`preset/${preset.alias}`}
|
|
||||||
current={location.pathname === `/preset/${preset.alias}`}
|
|
||||||
postAction={isSmallScreen ? onClose : undefined}
|
|
||||||
>
|
|
||||||
{preset.alias}
|
|
||||||
</ClientLink>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
alias: 'demopage',
|
alias: 'demopage',
|
||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search: 'user=guest',
|
search: 'user=guest',
|
||||||
displayInNav: false,
|
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -120,7 +119,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
alias: 'cuesheet-4685d6',
|
alias: 'cuesheet-4685d6',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
options: {
|
options: {
|
||||||
read: 'full',
|
read: 'full',
|
||||||
write: '-',
|
write: '-',
|
||||||
@@ -133,7 +131,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
alias: 'cuesheet-basic',
|
alias: 'cuesheet-basic',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const cuesheetPresetWithNavLock: URLPreset[] = [
|
const cuesheetPresetWithNavLock: URLPreset[] = [
|
||||||
@@ -142,7 +139,6 @@ describe('getRouteFromPreset()', () => {
|
|||||||
alias: 'cuesheet-locked',
|
alias: 'cuesheet-locked',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: 'n=1',
|
search: 'n=1',
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -224,7 +220,6 @@ describe('generateUrlPresetOptions', () => {
|
|||||||
target: 'timer',
|
target: 'timer',
|
||||||
search: 'param1=value1¶m2=value2',
|
search: 'param1=value1¶m2=value2',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -236,7 +231,6 @@ describe('generateUrlPresetOptions', () => {
|
|||||||
target: 'timer',
|
target: 'timer',
|
||||||
search: 'param1=value1¶m2=value2',
|
search: 'param1=value1¶m2=value2',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -248,7 +242,6 @@ describe('generateUrlPresetOptions', () => {
|
|||||||
target: 'timer',
|
target: 'timer',
|
||||||
search: 'param1=value1¶m2=value2',
|
search: 'param1=value1¶m2=value2',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -260,7 +253,6 @@ describe('generateUrlPresetOptions', () => {
|
|||||||
target: 'timer',
|
target: 'timer',
|
||||||
search: 'param1=value1¶m2=value2',
|
search: 'param1=value1¶m2=value2',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
|
|||||||
target: path,
|
target: path,
|
||||||
search: url.searchParams.toString(),
|
search: url.searchParams.toString(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
displayInNav: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { OntimeView, URLPreset } from 'ontime-types';
|
import { URLPreset } from 'ontime-types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
@@ -23,23 +22,13 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
|||||||
|
|
||||||
export default function URLPresets() {
|
export default function URLPresets() {
|
||||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
|
||||||
const { data, status } = useUrlPresets();
|
const { data, status } = useUrlPresets();
|
||||||
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
|
const { deletePreset, isMutating } = useUpdateUrlPreset();
|
||||||
|
|
||||||
const openNewForm = () => setFormState({ isOpen: true });
|
const openNewForm = () => setFormState({ isOpen: true });
|
||||||
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
||||||
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
||||||
|
|
||||||
const persistPreset = async (preset: URLPreset) => {
|
|
||||||
setActionError(null);
|
|
||||||
try {
|
|
||||||
await updatePreset(preset.alias, preset);
|
|
||||||
} catch (error) {
|
|
||||||
setActionError(maybeAxiosError(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
@@ -64,12 +53,10 @@ export default function URLPresets() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Loader isLoading={status === 'pending'} />
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Enabled</th>
|
<th>Enabled</th>
|
||||||
<th>Show in nav</th>
|
|
||||||
<th>Target view</th>
|
<th>Target view</th>
|
||||||
<th>Alias</th>
|
<th>Alias</th>
|
||||||
<th />
|
<th />
|
||||||
@@ -78,23 +65,10 @@ export default function URLPresets() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||||
{data.map((preset, index) => {
|
{data.map((preset, index) => {
|
||||||
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
|
||||||
return (
|
return (
|
||||||
<tr key={preset.alias}>
|
<tr key={preset.alias}>
|
||||||
<td>
|
<td>
|
||||||
<Switch
|
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||||
checked={preset.enabled}
|
|
||||||
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
|
|
||||||
disabled={isMutating}
|
|
||||||
aria-label='Toggle preset enabled'
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Switch
|
|
||||||
checked={preset.displayInNav}
|
|
||||||
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
|
|
||||||
disabled={isMutating || isCuesheet}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Tag>{preset.target}</Tag>
|
<Tag>{preset.target}</Tag>
|
||||||
|
|||||||
+33
-88
@@ -1,5 +1,5 @@
|
|||||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||||
@@ -12,7 +12,6 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
|
|||||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './URLPresetForm.module.scss';
|
import style from './URLPresetForm.module.scss';
|
||||||
@@ -33,7 +32,6 @@ const defaultValues: URLPreset = {
|
|||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search: '',
|
search: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
displayInNav: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface URLPresetFormProps {
|
interface URLPresetFormProps {
|
||||||
@@ -63,42 +61,12 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
});
|
});
|
||||||
const urlRef = useRef<HTMLInputElement>(null);
|
const urlRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Cuesheet read/write permissions live outside react-hook-form
|
|
||||||
const initialPermissions = useRef<CuesheetPermissionValues>({
|
|
||||||
read: urlPreset?.options?.read ?? 'full',
|
|
||||||
write: urlPreset?.options?.write ?? 'full',
|
|
||||||
});
|
|
||||||
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
|
|
||||||
|
|
||||||
// update initial permissions on mount
|
|
||||||
useEffect(() => {
|
|
||||||
initialPermissions.current = {
|
|
||||||
read: urlPreset?.options?.read ?? 'full',
|
|
||||||
write: urlPreset?.options?.write ?? 'full',
|
|
||||||
};
|
|
||||||
setCuesheetPermissions(initialPermissions.current);
|
|
||||||
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
|
|
||||||
const isCuesheet = watch('target') === OntimeView.Cuesheet;
|
|
||||||
const permissionsDirty =
|
|
||||||
isCuesheet &&
|
|
||||||
(cuesheetPermissions.read !== initialPermissions.current.read ||
|
|
||||||
cuesheetPermissions.write !== initialPermissions.current.write);
|
|
||||||
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
|
|
||||||
|
|
||||||
const setupSubmit = async (data: URLPreset) => {
|
const setupSubmit = async (data: URLPreset) => {
|
||||||
try {
|
try {
|
||||||
// Preserve / apply cuesheet permissions, which are not part of the form fields
|
|
||||||
const payload: URLPreset =
|
|
||||||
data.target === OntimeView.Cuesheet
|
|
||||||
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
|
|
||||||
: data;
|
|
||||||
if (urlPreset) {
|
if (urlPreset) {
|
||||||
await updatePreset(urlPreset.alias, payload);
|
await updatePreset(urlPreset.alias, data);
|
||||||
} else {
|
} else {
|
||||||
await addPreset(payload);
|
await addPreset(data);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -106,7 +74,6 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// focus on alias when the form opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocus('alias');
|
setFocus('alias');
|
||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
@@ -159,66 +126,44 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
<div className={style.expand}>
|
<div className={style.expand}>
|
||||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
<Input placeholder='Paste URL' fluid ref={urlRef} />
|
||||||
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
<Button onClick={generateOptions}>Generate</Button>
|
||||||
Generate
|
|
||||||
</Button>
|
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||||
{!isEditingCuesheet && (
|
<div>
|
||||||
<>
|
{enDash} or {enDash}
|
||||||
<div>
|
</div>
|
||||||
{enDash} or {enDash}
|
<div>2. Choose a view and its parameters</div>
|
||||||
</div>
|
<div>
|
||||||
<div>2. Choose a view and its parameters</div>
|
<Panel.Description>Target</Panel.Description>
|
||||||
<div>
|
<Select
|
||||||
<Panel.Description>Target</Panel.Description>
|
options={targetOptions}
|
||||||
<Select
|
{...register('target', { required: 'Target is required' })}
|
||||||
options={targetOptions}
|
value={watch('target')}
|
||||||
{...register('target', { required: 'Target is required' })}
|
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||||
value={watch('target')}
|
if (value === null) return;
|
||||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
setValue('target', value, { shouldDirty: true });
|
||||||
if (value === null) return;
|
}}
|
||||||
setValue('target', value, { shouldDirty: true });
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<Panel.Description>Parameters</Panel.Description>
|
||||||
<div>
|
<Textarea
|
||||||
<Panel.Description>Parameters</Panel.Description>
|
fluid
|
||||||
<Textarea
|
rows={3}
|
||||||
fluid
|
{...register('search', {
|
||||||
rows={3}
|
validate: validateParams,
|
||||||
{...register('search', {
|
})}
|
||||||
validate: validateParams,
|
/>
|
||||||
})}
|
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||||
/>
|
</div>
|
||||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCuesheet && (
|
|
||||||
<div>
|
|
||||||
<Panel.Description>Permissions</Panel.Description>
|
|
||||||
<CuesheetLinkOptions
|
|
||||||
initialRead={initialPermissions.current.read}
|
|
||||||
initialWrite={initialPermissions.current.write}
|
|
||||||
onChange={setCuesheetPermissions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||||
<Panel.InlineElements align='end'>
|
<Panel.InlineElements align='end'>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button
|
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
|
||||||
variant='primary'
|
|
||||||
type='submit'
|
|
||||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
|
||||||
loading={isSubmitting || isMutating}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
|
|||||||
@@ -10,19 +10,13 @@ export default function McpSection() {
|
|||||||
const { data: infoData } = useInfo();
|
const { data: infoData } = useInfo();
|
||||||
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
||||||
|
|
||||||
// generate url
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const baseUrl = (() => {
|
const baseUrl = isOntimeCloud
|
||||||
if (isOntimeCloud) return serverURL;
|
? serverURL
|
||||||
|
: infoData.networkInterfaces.length > 0
|
||||||
|
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
|
||||||
|
: serverURL;
|
||||||
|
|
||||||
// for local setups we prefer the localhost IP to avoid remote access
|
|
||||||
if (infoData.networkInterfaces.length > 0) {
|
|
||||||
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
|
|
||||||
}
|
|
||||||
return serverURL;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// we are reusing the endpoint, so locking config and nav have no effect
|
|
||||||
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||||
.then(setMcpEndpointUrl)
|
.then(setMcpEndpointUrl)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
.footer {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
+14
-28
@@ -1,5 +1,5 @@
|
|||||||
import { TranslationObject, langEn } from 'ontime-types';
|
import { TranslationObject, langEn } from 'ontime-types';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
@@ -10,8 +10,6 @@ import Modal from '../../../../../common/components/modal/Modal';
|
|||||||
import { useTranslation } from '../../../../../translation/TranslationProvider';
|
import { useTranslation } from '../../../../../translation/TranslationProvider';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './CustomTranslationModal.module.scss';
|
|
||||||
|
|
||||||
interface CustomTranslationModalProps {
|
interface CustomTranslationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -19,7 +17,14 @@ interface CustomTranslationModalProps {
|
|||||||
|
|
||||||
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
|
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
|
||||||
const { userTranslation, postUserTranslation } = useTranslation();
|
const { userTranslation, postUserTranslation } = useTranslation();
|
||||||
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
|
|
||||||
|
const defaultValues = useMemo(() => {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
Object.keys(langEn).forEach((key) => {
|
||||||
|
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}, [userTranslation]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -28,17 +33,13 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
|||||||
formState: { isSubmitting, isDirty, errors, isValid },
|
formState: { isSubmitting, isDirty, errors, isValid },
|
||||||
setError,
|
setError,
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: customTranslationFormValues,
|
defaultValues,
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
});
|
});
|
||||||
|
|
||||||
// keep data up to date
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
reset(customTranslationFormValues);
|
|
||||||
}
|
|
||||||
}, [customTranslationFormValues, isOpen, reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: Record<string, string>) => {
|
const onSubmit = async (formData: Record<string, string>) => {
|
||||||
try {
|
try {
|
||||||
const translationData: Record<string, string> = {};
|
const translationData: Record<string, string> = {};
|
||||||
@@ -53,10 +54,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetToEnglish = () => {
|
|
||||||
reset(toFormValues(langEn), { keepDefaultValues: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title='Edit custom translations'
|
title='Edit custom translations'
|
||||||
@@ -87,12 +84,9 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
|||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
<div className={style.footer}>
|
<div>
|
||||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<Panel.InlineElements align='apart'>
|
<Panel.InlineElements align='apart'>
|
||||||
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
|
|
||||||
Reset to English
|
|
||||||
</Button>
|
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Button size='large' onClick={onClose}>
|
<Button size='large' onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -115,14 +109,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFormValues(translation: Partial<TranslationObject>) {
|
|
||||||
const values: Record<string, string> = {};
|
|
||||||
Object.keys(langEn).forEach((key) => {
|
|
||||||
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFormKey(key: string) {
|
function toFormKey(key: string) {
|
||||||
return key.replace('.', '_');
|
return key.replace('.', '_');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OntimeView, URLPreset } from 'ontime-types';
|
import { OntimeView, URLPreset } from 'ontime-types';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { FieldErrors, useForm } from 'react-hook-form';
|
import { FieldErrors, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { generateUrl } from '../../common/api/session';
|
import { generateUrl } from '../../common/api/session';
|
||||||
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
|
|||||||
import { isUrlSafe } from '../../common/utils/regex';
|
import { isUrlSafe } from '../../common/utils/regex';
|
||||||
import { isOntimeCloud, serverURL } from '../../externals';
|
import { isOntimeCloud, serverURL } from '../../externals';
|
||||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions';
|
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
|
||||||
|
|
||||||
import style from './GenerateLinkForm.module.scss';
|
import style from './GenerateLinkForm.module.scss';
|
||||||
|
|
||||||
@@ -53,26 +53,12 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
|||||||
|
|
||||||
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
||||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState(serverURL);
|
||||||
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({
|
const cuesheetReadRef = useRef<HTMLInputElement>(null);
|
||||||
read: 'full',
|
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
|
||||||
write: 'full',
|
|
||||||
});
|
|
||||||
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
||||||
|
|
||||||
const { addPreset, updatePreset } = useUpdateUrlPreset();
|
const { addPreset } = useUpdateUrlPreset();
|
||||||
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
|
|
||||||
const createdAlias = useRef<string | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permissions live outside react-hook-form, so we reset a successful state manually
|
|
||||||
* whenever they change - this re-arms the "Create share link" button as the previous
|
|
||||||
* link no longer reflects the selected permissions.
|
|
||||||
*/
|
|
||||||
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
|
|
||||||
setCuesheetPermissions(permissions);
|
|
||||||
setFormState((current) => (current === 'success' ? 'pending' : current));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -104,20 +90,16 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
if (options.read === '-') {
|
if (options.read === '-') {
|
||||||
throw new Error('Cannot create a share with no read permissions');
|
throw new Error('Cannot create a share with no read permissions');
|
||||||
}
|
}
|
||||||
const payload = {
|
const presets = await addPreset({
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias,
|
alias,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
options: {
|
options: {
|
||||||
read: options.read,
|
read: options.read,
|
||||||
write: options.write,
|
write: options.write,
|
||||||
},
|
},
|
||||||
} as const;
|
});
|
||||||
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
|
|
||||||
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
|
|
||||||
createdAlias.current = alias;
|
|
||||||
return presets.find((preset) => preset.alias === alias);
|
return presets.find((preset) => preset.alias === alias);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,8 +108,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
setFormState('loading');
|
setFormState('loading');
|
||||||
if (options.path === OntimeView.Cuesheet) {
|
if (options.path === OntimeView.Cuesheet) {
|
||||||
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
||||||
read: cuesheetPermissions.read,
|
read: cuesheetReadRef.current?.value ?? 'full',
|
||||||
write: cuesheetPermissions.write,
|
write: cuesheetWriteRef.current?.value ?? 'full',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!urlPreset) {
|
if (!urlPreset) {
|
||||||
@@ -175,7 +157,6 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
|
|
||||||
const canSubmit = isDirty || formState !== 'success';
|
const canSubmit = isDirty || formState !== 'success';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -239,7 +220,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<CuesheetLinkOptions onChange={handlePermissionsChange} />
|
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -287,29 +268,18 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||||
<Panel.InlineElements align='end' className={style.end}>
|
<Panel.InlineElements align='end' className={style.end}>
|
||||||
<Button
|
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
|
||||||
type='submit'
|
|
||||||
variant={canSubmit ? 'primary' : 'subtle'}
|
|
||||||
loading={formState === 'loading'}
|
|
||||||
disabled={noReadAccess}
|
|
||||||
>
|
|
||||||
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
<Panel.Section className={style.column}>
|
<Panel.Section className={style.column}>
|
||||||
<Panel.Description>Share this link</Panel.Description>
|
<Panel.Description>Share this link</Panel.Description>
|
||||||
{url ? (
|
<QRCode size={172} value={url} />
|
||||||
<>
|
<div className={style.copiableLink} data-testid='copy-link'>
|
||||||
<QRCode size={172} value={url} />
|
{url}
|
||||||
<div className={style.copiableLink} data-testid='copy-link'>
|
</div>
|
||||||
{url}
|
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||||
</div>
|
|
||||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
|
|
||||||
)}
|
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
import { Fragment, RefObject, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
|
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
|
||||||
import Switch from '../../../common/components/switch/Switch';
|
import Switch from '../../../common/components/switch/Switch';
|
||||||
@@ -10,141 +10,118 @@ import style from './CuesheetLinkOptions.module.scss';
|
|||||||
|
|
||||||
type AccessMode = 'full' | 'custom';
|
type AccessMode = 'full' | 'custom';
|
||||||
|
|
||||||
export interface CuesheetPermissionValues {
|
|
||||||
read: string;
|
|
||||||
write: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CuesheetLinkOptionsProps {
|
interface CuesheetLinkOptionsProps {
|
||||||
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */
|
readRef?: RefObject<HTMLInputElement | null>;
|
||||||
initialRead?: string;
|
writeRef?: RefObject<HTMLInputElement | null>;
|
||||||
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
|
|
||||||
initialWrite?: string;
|
|
||||||
/** Notifies the parent whenever the resolved read/write permissions change */
|
|
||||||
onChange: (permissions: CuesheetPermissionValues) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A null result means "full or unset" - there is no explicit per-column selection to seed */
|
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
|
||||||
function parseKeys(permission: string | undefined): Set<string> | null {
|
|
||||||
if (permission == null || permission === 'full') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (permission === '-') {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
return new Set(permission.split(','));
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeFromPermission(permission: string | undefined): AccessMode {
|
|
||||||
return permission == null || permission === 'full' ? 'full' : 'custom';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
|
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
||||||
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
|
|
||||||
|
|
||||||
// Parsed seed values - stable for the lifetime of a given preset
|
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
|
||||||
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
|
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
|
||||||
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
|
|
||||||
|
|
||||||
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead));
|
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
|
||||||
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite));
|
const initialState: Record<string, boolean> = {};
|
||||||
|
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||||
// Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access
|
initialState[column.value] = true;
|
||||||
const defaultRead = useCallback(
|
|
||||||
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
|
|
||||||
[initialReadKeys],
|
|
||||||
);
|
|
||||||
const defaultWrite = useCallback(
|
|
||||||
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
|
|
||||||
[initialWriteKeys],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
|
|
||||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
|
|
||||||
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
|
|
||||||
useEffect(() => {
|
|
||||||
setReadSwitches((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const column of allColumns) {
|
|
||||||
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
setWriteSwitches((prev) => {
|
return initialState;
|
||||||
const next = { ...prev };
|
});
|
||||||
for (const column of allColumns) {
|
|
||||||
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [allColumns, defaultRead, defaultWrite]);
|
|
||||||
|
|
||||||
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key);
|
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
|
||||||
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key);
|
const initialState: Record<string, boolean> = {};
|
||||||
|
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||||
|
initialState[column.value] = true;
|
||||||
|
});
|
||||||
|
return initialState;
|
||||||
|
});
|
||||||
|
|
||||||
const handleReadModeChange = (value: AccessMode) => {
|
const handleReadModeChange = (value: AccessMode) => {
|
||||||
setReadPermissions(value);
|
setReadPermissions(value);
|
||||||
|
|
||||||
|
setReadSwitches((prevReadSwitches) => {
|
||||||
|
const updatedReadSwitches = { ...prevReadSwitches };
|
||||||
|
Object.keys(updatedReadSwitches).forEach((key) => {
|
||||||
|
updatedReadSwitches[key] = true;
|
||||||
|
});
|
||||||
|
return updatedReadSwitches;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWriteModeChange = (value: AccessMode) => {
|
const handleWriteModeChange = (value: AccessMode) => {
|
||||||
setWritePermissions(value);
|
|
||||||
// Full write implies full read
|
|
||||||
if (value === 'full') {
|
if (value === 'full') {
|
||||||
setReadPermissions('full');
|
setReadPermissions('full');
|
||||||
}
|
}
|
||||||
|
setWritePermissions(value);
|
||||||
|
|
||||||
|
setReadSwitches((prevReadSwitches) => {
|
||||||
|
const updatedReadSwitches = { ...prevReadSwitches };
|
||||||
|
setWriteSwitches((prevWriteSwitches) => {
|
||||||
|
const updatedWriteSwitches = { ...prevWriteSwitches };
|
||||||
|
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||||
|
updatedReadSwitches[column.value] = true;
|
||||||
|
updatedWriteSwitches[column.value] = true;
|
||||||
|
});
|
||||||
|
return updatedWriteSwitches;
|
||||||
|
});
|
||||||
|
return updatedReadSwitches;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReadSwitch = (key: string, value: boolean) => {
|
const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
|
||||||
setReadSwitches((prev) => ({ ...prev, [key]: value }));
|
if (type === 'read') {
|
||||||
// A column the recipient cannot read cannot be written either
|
setReadSwitches((prevReadSwitches) => {
|
||||||
if (!value) {
|
const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
|
||||||
setWriteSwitches((prev) => ({ ...prev, [key]: false }));
|
return updatedReadSwitches;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setWriteSwitches((prevWriteSwitches) => {
|
||||||
|
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
|
||||||
|
return updatedWriteSwitches;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWriteSwitch = (key: string, value: boolean) => {
|
const getReadPermissions = () => {
|
||||||
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
|
|
||||||
// Granting write access requires read access
|
|
||||||
if (value) {
|
|
||||||
setReadSwitches((prev) => ({ ...prev, [key]: true }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvedRead = useMemo(() => {
|
|
||||||
if (readPermissions === 'full' || writePermissions === 'full') {
|
if (readPermissions === 'full' || writePermissions === 'full') {
|
||||||
return 'full';
|
return 'full';
|
||||||
}
|
}
|
||||||
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
|
|
||||||
return keys.length ? keys.join(',') : '-';
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [readPermissions, writePermissions, readSwitches, allColumns]);
|
|
||||||
|
|
||||||
const resolvedWrite = useMemo(() => {
|
return Object.entries(readSwitches)
|
||||||
|
.filter(([_, value]) => value)
|
||||||
|
.map(([key]) => key)
|
||||||
|
.join(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWritePermissions = () => {
|
||||||
if (writePermissions === 'full') {
|
if (writePermissions === 'full') {
|
||||||
return 'full';
|
return 'full';
|
||||||
}
|
}
|
||||||
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
|
|
||||||
return keys.length ? keys.join(',') : '-';
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [writePermissions, writeSwitches, allColumns]);
|
|
||||||
|
|
||||||
// Notify the parent of the resolved permissions. onChange is expected to be stable.
|
return Object.entries(writeSwitches)
|
||||||
useEffect(() => {
|
.filter(([_, value]) => value)
|
||||||
onChange({ read: resolvedRead, write: resolvedWrite });
|
.map(([key]) => key)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
.join(',');
|
||||||
}, [resolvedRead, resolvedWrite]);
|
};
|
||||||
|
|
||||||
const noReadAccess = resolvedRead === '-';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent>
|
<Panel.Indent>
|
||||||
|
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
|
||||||
|
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
|
||||||
<div>
|
<div>
|
||||||
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' />
|
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
|
||||||
<div>
|
<div>
|
||||||
|
<RadioGroup
|
||||||
|
value={writePermissions}
|
||||||
|
onValueChange={handleWriteModeChange}
|
||||||
|
orientation='horizontal'
|
||||||
|
items={[
|
||||||
|
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||||
|
{ value: 'custom', label: 'Custom write' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={readPermissions}
|
value={readPermissions}
|
||||||
onValueChange={handleReadModeChange}
|
onValueChange={handleReadModeChange}
|
||||||
@@ -155,18 +132,8 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
|
|||||||
{ value: 'custom', label: 'Custom read' },
|
{ value: 'custom', label: 'Custom read' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<RadioGroup
|
|
||||||
value={writePermissions}
|
|
||||||
onValueChange={handleWriteModeChange}
|
|
||||||
orientation='horizontal'
|
|
||||||
items={[
|
|
||||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
|
||||||
{ value: 'custom', label: 'Custom write' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
|
|
||||||
<div className={style.twoCols}>
|
<div className={style.twoCols}>
|
||||||
<div className={style.grid}>
|
<div className={style.grid}>
|
||||||
<Panel.Description>Ontime columns</Panel.Description>
|
<Panel.Description>Ontime columns</Panel.Description>
|
||||||
@@ -176,14 +143,14 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
|
|||||||
<Fragment key={column.value}>
|
<Fragment key={column.value}>
|
||||||
<div>{column.label}</div>
|
<div>{column.label}</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isReadOn(column.value)}
|
checked={Boolean(readSwitches[column.value])}
|
||||||
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||||
data-testid={`read-${column.value}`}
|
data-testid={`read-${column.value}`}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isWriteOn(column.value)}
|
checked={Boolean(writeSwitches[column.value])}
|
||||||
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||||
disabled={writePermissions === 'full'}
|
disabled={writePermissions === 'full'}
|
||||||
data-testid={`write-${column.value}`}
|
data-testid={`write-${column.value}`}
|
||||||
/>
|
/>
|
||||||
@@ -197,16 +164,16 @@ export default function CuesheetLinkOptions({ initialRead, initialWrite, onChang
|
|||||||
<Panel.Description>Write</Panel.Description>
|
<Panel.Description>Write</Panel.Description>
|
||||||
{customFieldColumns.map((column) => (
|
{customFieldColumns.map((column) => (
|
||||||
<Fragment key={column.value}>
|
<Fragment key={column.value}>
|
||||||
<div>{column.label}</div>
|
{column.label}
|
||||||
<Switch
|
<Switch
|
||||||
checked={isReadOn(column.value)}
|
checked={Boolean(readSwitches[column.value])}
|
||||||
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||||
data-testid={`read-${column.value}`}
|
data-testid={`read-${column.value}`}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isWriteOn(column.value)}
|
checked={Boolean(writeSwitches[column.value])}
|
||||||
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||||
disabled={writePermissions === 'full'}
|
disabled={writePermissions === 'full'}
|
||||||
data-testid={`write-${column.value}`}
|
data-testid={`write-${column.value}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const langDe: TranslationObject = {
|
|||||||
'timeline.done': 'Beendet',
|
'timeline.done': 'Beendet',
|
||||||
'timeline.due': 'fällig',
|
'timeline.due': 'fällig',
|
||||||
'timeline.followedby': 'Gefolgt von',
|
'timeline.followedby': 'Gefolgt von',
|
||||||
'timeline.standby': 'Bereitschaft',
|
|
||||||
'project.title': 'Titel',
|
'project.title': 'Titel',
|
||||||
'project.description': 'Beschreibung',
|
'project.description': 'Beschreibung',
|
||||||
'project.info': 'Projektinfo',
|
'project.info': 'Projektinfo',
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const langEs: TranslationObject = {
|
|||||||
'timeline.done': 'Terminado',
|
'timeline.done': 'Terminado',
|
||||||
'timeline.due': 'pendiente',
|
'timeline.due': 'pendiente',
|
||||||
'timeline.followedby': 'Seguido por',
|
'timeline.followedby': 'Seguido por',
|
||||||
'timeline.standby': 'En espera',
|
|
||||||
'project.title': 'Título',
|
'project.title': 'Título',
|
||||||
'project.description': 'Descripción',
|
'project.description': 'Descripción',
|
||||||
'project.info': 'Información del proyecto',
|
'project.info': 'Información del proyecto',
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const langFr: TranslationObject = {
|
|||||||
'timeline.done': 'Terminé',
|
'timeline.done': 'Terminé',
|
||||||
'timeline.due': 'dû',
|
'timeline.due': 'dû',
|
||||||
'timeline.followedby': 'Suivi de',
|
'timeline.followedby': 'Suivi de',
|
||||||
'timeline.standby': 'En attente',
|
|
||||||
'project.title': 'Titre',
|
'project.title': 'Titre',
|
||||||
'project.description': 'Description',
|
'project.description': 'Description',
|
||||||
'project.info': 'Informations du projet',
|
'project.info': 'Informations du projet',
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const langIt: TranslationObject = {
|
|||||||
'timeline.done': 'Terminato',
|
'timeline.done': 'Terminato',
|
||||||
'timeline.due': 'previsto',
|
'timeline.due': 'previsto',
|
||||||
'timeline.followedby': 'Seguito da',
|
'timeline.followedby': 'Seguito da',
|
||||||
'timeline.standby': 'In attesa',
|
|
||||||
'project.title': 'Titolo',
|
'project.title': 'Titolo',
|
||||||
'project.description': 'Descrizione',
|
'project.description': 'Descrizione',
|
||||||
'project.info': 'Informazioni sul progetto',
|
'project.info': 'Informazioni sul progetto',
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const langPt: TranslationObject = {
|
|||||||
'timeline.done': 'Concluído',
|
'timeline.done': 'Concluído',
|
||||||
'timeline.due': 'Pendente',
|
'timeline.due': 'Pendente',
|
||||||
'timeline.followedby': 'Seguido por',
|
'timeline.followedby': 'Seguido por',
|
||||||
'timeline.standby': 'Em espera',
|
|
||||||
'project.title': 'Título',
|
'project.title': 'Título',
|
||||||
'project.description': 'Descrição',
|
'project.description': 'Descrição',
|
||||||
'project.info': 'Informações do projeto',
|
'project.info': 'Informações do projeto',
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
|||||||
alias: 'cuesheet-read-only',
|
alias: 'cuesheet-read-only',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
options: {
|
options: {
|
||||||
read: 'full',
|
read: 'full',
|
||||||
write: '-',
|
write: '-',
|
||||||
@@ -42,7 +41,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
|||||||
alias: 'cuesheet-flag',
|
alias: 'cuesheet-flag',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
options: {
|
options: {
|
||||||
read: 'full',
|
read: 'full',
|
||||||
write: 'flag',
|
write: 'flag',
|
||||||
@@ -64,7 +62,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
|||||||
alias: 'cuesheet-default',
|
alias: 'cuesheet-default',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||||
@@ -79,7 +76,6 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
|||||||
alias: 'cuesheet-granular',
|
alias: 'cuesheet-granular',
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
search: '',
|
search: '',
|
||||||
displayInNav: false,
|
|
||||||
options: {
|
options: {
|
||||||
read: 'cue,title',
|
read: 'cue,title',
|
||||||
write: 'title',
|
write: 'title',
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
$timeline-height: 1rem;
|
$timeline-height: 1rem;
|
||||||
$group-band-height: 0.5rem;
|
|
||||||
$group-band-gap: 0.25rem;
|
|
||||||
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
|
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
|
||||||
|
|
||||||
.timelineContainer {
|
.timelineContainer {
|
||||||
@@ -19,21 +17,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
|||||||
position: relative;
|
position: relative;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
// reserve the top region (progress bar + gap + group band) with the card background
|
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0
|
|
||||||
var(--card-background-color-override, $viewer-card-bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupBand {
|
|
||||||
height: $group-band-height;
|
|
||||||
margin-top: $group-band-gap;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--group-colour, transparent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
@@ -108,6 +92,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
|||||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||||
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
||||||
|
|
||||||
|
text-transform: capitalize;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|
||||||
&[data-status='done'] {
|
&[data-status='done'] {
|
||||||
@@ -134,7 +119,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
|||||||
|
|
||||||
.status {
|
.status {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.due {
|
.status.due {
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
|||||||
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
|
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
|
||||||
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
|
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
|
||||||
|
|
||||||
|
if (totalDuration === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-calculate event statuses
|
// Pre-calculate event statuses
|
||||||
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||||
const statusMap: Record<string, ProgressStatus> = {};
|
const statusMap: Record<string, ProgressStatus> = {};
|
||||||
@@ -101,7 +105,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
|||||||
title={displayTitle}
|
title={displayTitle}
|
||||||
cue={event.cue}
|
cue={event.cue}
|
||||||
width={position.width}
|
width={position.width}
|
||||||
groupColour={event.groupColour}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ interface TimelineEntryProps {
|
|||||||
title: string;
|
title: string;
|
||||||
width: number;
|
width: number;
|
||||||
cue: string;
|
cue: string;
|
||||||
groupColour?: string;
|
|
||||||
ref?: RefObject<HTMLDivElement | null>;
|
ref?: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function TimelineEntry({
|
|||||||
title,
|
title,
|
||||||
width,
|
width,
|
||||||
cue,
|
cue,
|
||||||
groupColour,
|
|
||||||
ref,
|
ref,
|
||||||
}: TimelineEntryProps) {
|
}: TimelineEntryProps) {
|
||||||
const formattedStartTime = formatTime(start, formatOptions);
|
const formattedStartTime = formatTime(start, formatOptions);
|
||||||
@@ -69,7 +67,6 @@ export function TimelineEntry({
|
|||||||
{
|
{
|
||||||
'--color': colour,
|
'--color': colour,
|
||||||
'--lighter': lighterColour ?? '',
|
'--lighter': lighterColour ?? '',
|
||||||
'--group-colour': groupColour ?? 'transparent',
|
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
@@ -77,7 +74,6 @@ export function TimelineEntry({
|
|||||||
data-testid={cue}
|
data-testid={cue}
|
||||||
>
|
>
|
||||||
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
|
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
|
||||||
<div className={style.groupBand} />
|
|
||||||
<div
|
<div
|
||||||
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
|
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
|
||||||
data-status={status}
|
data-status={status}
|
||||||
@@ -157,8 +153,6 @@ function TimelineEntryStatus({
|
|||||||
statusText = getLocalizedString('timeline.live');
|
statusText = getLocalizedString('timeline.live');
|
||||||
} else if (statusText === 'pending') {
|
} else if (statusText === 'pending') {
|
||||||
statusText = getLocalizedString('timeline.due');
|
statusText = getLocalizedString('timeline.due');
|
||||||
} else if (statusText === 'done') {
|
|
||||||
statusText = getLocalizedString('timeline.done');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDue = status === 'future' && timeToStart <= 0;
|
const isDue = status === 'future' && timeToStart <= 0;
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ export default function TimelinePageLoader() {
|
|||||||
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
|
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const { mainSource, timeformat } = useTimelineOptions();
|
const { mainSource, timeformat } = useTimelineOptions();
|
||||||
const { getLocalizedString } = useTranslation();
|
|
||||||
|
|
||||||
// holds copy of the rundown with only relevant events
|
// holds copy of the rundown with only relevant events
|
||||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
|
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
|
||||||
|
|
||||||
@@ -52,8 +50,6 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
|||||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
|
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
|
||||||
|
|
||||||
const hasContent = totalDuration > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='timeline' data-testid='timeline-view'>
|
<div className='timeline' data-testid='timeline-view'>
|
||||||
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
|
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
|
||||||
@@ -65,16 +61,12 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
|||||||
|
|
||||||
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
|
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
|
||||||
|
|
||||||
{hasContent ? (
|
<Timeline
|
||||||
<Timeline
|
firstStart={firstStart}
|
||||||
firstStart={firstStart}
|
rundown={scopedRundown}
|
||||||
rundown={scopedRundown}
|
selectedEventId={selectedEventId}
|
||||||
selectedEventId={selectedEventId}
|
totalDuration={totalDuration}
|
||||||
totalDuration={totalDuration}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyPage text={getLocalizedString('common.no_data')} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }:
|
|||||||
const state = useExpectedStartData();
|
const state = useExpectedStartData();
|
||||||
|
|
||||||
// gather card data
|
// gather card data
|
||||||
const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby');
|
const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
|
||||||
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
||||||
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
|
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
|
||||||
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
|
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
|
||||||
|
|||||||
@@ -96,77 +96,63 @@ export function useScopedRundown(
|
|||||||
): ScopedRundownData {
|
): ScopedRundownData {
|
||||||
const { hidePast } = useTimelineOptions();
|
const { hidePast } = useTimelineOptions();
|
||||||
|
|
||||||
const data = useMemo(
|
const data = useMemo(() => {
|
||||||
() => computeScopedRundown(rundown, selectedEventId, hidePast),
|
if (rundown.length === 0) {
|
||||||
[hidePast, rundown, selectedEventId],
|
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
|
||||||
|
let selectedIndex = selectedEventId ? Infinity : -1;
|
||||||
|
let firstStart = null;
|
||||||
|
let totalDuration = 0;
|
||||||
|
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < rundown.length; i++) {
|
||||||
|
const currentEntry = rundown[i];
|
||||||
|
// we only deal with playableEvents
|
||||||
|
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
|
||||||
|
if (currentEntry.id === selectedEventId) {
|
||||||
|
selectedIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybe filter past
|
||||||
|
if (hidePast && i < selectedIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add to scopedRundown
|
||||||
|
scopedRundown.push(currentEntry);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive timers
|
||||||
|
* This logic is partially from rundownCache.generate
|
||||||
|
* With the addition of deriving the current day offset
|
||||||
|
*/
|
||||||
|
if (firstStart === null) {
|
||||||
|
firstStart = currentEntry.timeStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
|
||||||
|
|
||||||
|
if (timeFromPrevious === 0) {
|
||||||
|
totalDuration += currentEntry.duration;
|
||||||
|
} else if (timeFromPrevious > 0) {
|
||||||
|
totalDuration += timeFromPrevious + currentEntry.duration;
|
||||||
|
} else if (timeFromPrevious < 0) {
|
||||||
|
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
||||||
|
}
|
||||||
|
if (isNewLatest(currentEntry, lastEntry)) {
|
||||||
|
lastEntry = currentEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
|
||||||
|
}, [hidePast, rundown, selectedEventId]);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure computation behind useScopedRundown: filters to playable events and derives timers
|
|
||||||
*/
|
|
||||||
export function computeScopedRundown(
|
|
||||||
rundown: ExtendedEntry<OntimeEntry>[],
|
|
||||||
selectedEventId: MaybeString,
|
|
||||||
hidePast: boolean,
|
|
||||||
): ScopedRundownData {
|
|
||||||
if (rundown.length === 0) {
|
|
||||||
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
|
|
||||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
|
||||||
let firstStart: number | null = null;
|
|
||||||
let totalDuration = 0;
|
|
||||||
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < rundown.length; i++) {
|
|
||||||
// we only deal with playableEvents
|
|
||||||
const currentEntry = rundown[i];
|
|
||||||
if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentEntry.id === selectedEventId) {
|
|
||||||
selectedIndex = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybe filter past
|
|
||||||
if (hidePast && i < selectedIndex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// add to scopedRundown
|
|
||||||
scopedRundown.push(currentEntry);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive timers
|
|
||||||
* This logic is partially from rundownCache.generate
|
|
||||||
* With the addition of deriving the current day offset
|
|
||||||
*/
|
|
||||||
if (firstStart === null) {
|
|
||||||
firstStart = currentEntry.timeStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
|
|
||||||
|
|
||||||
if (timeFromPrevious === 0) {
|
|
||||||
totalDuration += currentEntry.duration;
|
|
||||||
} else if (timeFromPrevious > 0) {
|
|
||||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
|
||||||
} else if (timeFromPrevious < 0) {
|
|
||||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
|
||||||
}
|
|
||||||
if (isNewLatest(currentEntry, lastEntry)) {
|
|
||||||
lastEntry = currentEntry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpcomingEvents = {
|
type UpcomingEvents = {
|
||||||
now: ExtendedEntry<OntimeEvent> | null;
|
now: ExtendedEntry<OntimeEvent> | null;
|
||||||
next: ExtendedEntry<OntimeEvent> | null;
|
next: ExtendedEntry<OntimeEvent> | null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -110,13 +110,14 @@ type old_URLPreset = {
|
|||||||
/**
|
/**
|
||||||
* migrates a url presets from v3 to v4
|
* migrates a url presets from v3 to v4
|
||||||
* - pathAndParams split into a target and search
|
* - pathAndParams split into a target and search
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
|
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
|
||||||
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
|
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
|
||||||
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
|
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
|
||||||
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
|
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
|
||||||
const [target, search] = pathAndParams.split('?');
|
const [target, search] = pathAndParams.split('?');
|
||||||
return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset;
|
return { enabled, alias, target, search, options: {} } as URLPreset;
|
||||||
});
|
});
|
||||||
return newURLPreset;
|
return newURLPreset;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,6 @@ describe('v3 to v4', () => {
|
|||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search:
|
search:
|
||||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
displayInNav: false,
|
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -217,7 +216,6 @@ describe('v3 to v4', () => {
|
|||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search:
|
search:
|
||||||
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
displayInNav: false,
|
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -704,14 +704,11 @@ export async function renameRundown(id: string, title: string) {
|
|||||||
await dataProvider.setRundown(id, { ...rundown, title });
|
await dataProvider.setRundown(id, { ...rundown, title });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If we are modifying the loaded rundown we re-init it
|
* If loaded we re-init the rundown
|
||||||
* This is likely over-kill but the simplest way to ensure state consistency
|
* This is likely over-kill but the simplest way to ensure state consistency
|
||||||
*/
|
*/
|
||||||
if (isCurrentRundown(id)) {
|
if (isCurrentRundown(id)) {
|
||||||
const rundown = dataProvider.getRundown(id);
|
await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
|
||||||
const customField = dataProvider.getCustomFields();
|
|
||||||
// init rundown does its own refetch
|
|
||||||
await initRundown(rundown, customField);
|
|
||||||
} else {
|
} else {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
sendRefetch(RefetchKey.ProjectRundowns);
|
sendRefetch(RefetchKey.ProjectRundowns);
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ describe('parseUrlPresets()', () => {
|
|||||||
|
|
||||||
it('parses data, skipping invalid results', () => {
|
it('parses data, skipping invalid results', () => {
|
||||||
const errorEmitter = vi.fn();
|
const errorEmitter = vi.fn();
|
||||||
const urlPresets = [
|
const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[];
|
||||||
{ enabled: true, alias: 'alias', target: 'timer', search: 'ss', displayInNav: false },
|
|
||||||
] as URLPreset[];
|
|
||||||
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
||||||
expect(result.length).toEqual(1);
|
expect(result.length).toEqual(1);
|
||||||
expect(result.at(0)).toMatchObject({
|
expect(result.at(0)).toMatchObject({
|
||||||
@@ -38,7 +36,6 @@ describe('parseUrlPresets()', () => {
|
|||||||
alias: 'testalias',
|
alias: 'testalias',
|
||||||
target: 'timer',
|
target: 'timer',
|
||||||
search: 'testpathAndParams',
|
search: 'testpathAndParams',
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DatabaseModel;
|
} as unknown as DatabaseModel;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
|
|||||||
alias: preset.alias,
|
alias: preset.alias,
|
||||||
target: preset.target,
|
target: preset.target,
|
||||||
search: preset.search ?? '',
|
search: preset.search ?? '',
|
||||||
displayInNav: preset.displayInNav ?? false,
|
|
||||||
options: preset?.options,
|
options: preset?.options,
|
||||||
};
|
};
|
||||||
newPresets.push(newPreset);
|
newPresets.push(newPreset);
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
|
|||||||
alias: req.body.alias,
|
alias: req.body.alias,
|
||||||
target: req.body.target,
|
target: req.body.target,
|
||||||
search: req.body.search,
|
search: req.body.search,
|
||||||
displayInNav: req.body.displayInNav,
|
|
||||||
options: req.body.options,
|
options: req.body.options,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +55,6 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
|
|||||||
alias: req.body.alias,
|
alias: req.body.alias,
|
||||||
target: req.body.target,
|
target: req.body.target,
|
||||||
search: req.body.search,
|
search: req.body.search,
|
||||||
displayInNav: req.body.displayInNav,
|
|
||||||
options: req.body.options ?? existingPreset.options,
|
options: req.body.options ?? existingPreset.options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const validateNewPreset = [
|
|||||||
body('alias').isString().trim().notEmpty(),
|
body('alias').isString().trim().notEmpty(),
|
||||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||||
body('search').isString().trim(),
|
body('search').isString().trim(),
|
||||||
body('displayInNav').isBoolean(),
|
|
||||||
|
|
||||||
// options are currently only provided for cuesheet presets
|
// options are currently only provided for cuesheet presets
|
||||||
body('options').optional().isObject(),
|
body('options').optional().isObject(),
|
||||||
@@ -28,7 +27,6 @@ export const validateUpdatePreset = [
|
|||||||
body('alias').isString().trim().notEmpty(),
|
body('alias').isString().trim().notEmpty(),
|
||||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||||
body('search').isString().trim(),
|
body('search').isString().trim(),
|
||||||
body('displayInNav').isBoolean(),
|
|
||||||
|
|
||||||
// options are currently only provided for cuesheet presets
|
// options are currently only provided for cuesheet presets
|
||||||
body('options').optional().isObject(),
|
body('options').optional().isObject(),
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Ontime MCP Server
|
||||||
|
|
||||||
|
Ontime exposes an MCP server over Streamable HTTP.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
Default local URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:4001/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
If Ontime is running on another host or port, replace `localhost:4001` with that address.
|
||||||
|
If `ROUTER_PREFIX` is configured, include it before `/mcp`, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:4001/stage/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` and
|
||||||
|
`DELETE` are not used.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
If Ontime has no session password configured, no MCP authentication is required.
|
||||||
|
|
||||||
|
If a session password is configured, authenticate with the hashed Ontime token.
|
||||||
|
The settings UI generates an endpoint URL with the token in the query string:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:4001/mcp?token=<hashed-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
If your MCP client supports request headers, you can send the same token as a
|
||||||
|
bearer token instead:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <hashed-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same token used in authenticated Ontime share URLs as the `token` query
|
||||||
|
parameter. The raw session password is not accepted as the bearer token.
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
Use a Streamable HTTP MCP client and point it at the MCP endpoint:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ontime": {
|
||||||
|
"url": "http://localhost:4001/mcp?token=<hashed-token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit the token when Ontime is not password protected. If you prefer headers, use
|
||||||
|
`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the
|
||||||
|
same bearer token.
|
||||||
|
|
||||||
|
## Quick Check
|
||||||
|
|
||||||
|
The server should respond to MCP initialization requests at `/mcp`. A plain browser
|
||||||
|
`GET` request will return `405 Method not allowed`, which is expected.
|
||||||
@@ -33,17 +33,6 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'manage_custom_fields',
|
|
||||||
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
|
|
||||||
arguments: [
|
|
||||||
{
|
|
||||||
name: 'instruction',
|
|
||||||
description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function userPrompt(description: string, text: string): GetPromptResult {
|
function userPrompt(description: string, text: string): GetPromptResult {
|
||||||
@@ -90,7 +79,6 @@ Count to end (countToEnd):
|
|||||||
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
|
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
|
||||||
|
|
||||||
End action (endAction):
|
End action (endAction):
|
||||||
- Controls what happens when the event's timer reaches zero and can have surprising effect. Use only if the user explicitly asks for it.
|
|
||||||
- \`none\` (default): stops at end; operator must manually start the next event.
|
- \`none\` (default): stops at end; operator must manually start the next event.
|
||||||
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
|
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
|
||||||
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
|
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
|
||||||
@@ -215,36 +203,5 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'manage_custom_fields') {
|
|
||||||
return userPrompt(
|
|
||||||
'List, create, rename, recolour, or delete custom field definitions',
|
|
||||||
`Manage the project-level custom field definitions for this Ontime project: "${args.instruction}"
|
|
||||||
|
|
||||||
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Call ontime_get_custom_fields to see what fields already exist. This is mandatory before creating — to avoid duplicates such as "Cam", "camera", and "Cameras".
|
|
||||||
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling.
|
|
||||||
|
|
||||||
Creating a field:
|
|
||||||
- Call ontime_create_custom_field with { label, type, colour }.
|
|
||||||
- label: human-readable name (alphanumeric with spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle"). Confirm the derived key with the user before creating.
|
|
||||||
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
|
|
||||||
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. Ask the user what colour to use if not specified.
|
|
||||||
- Returns: { key, customFields } — use the returned key when setting values on entries.
|
|
||||||
|
|
||||||
Renaming or recolouring a field:
|
|
||||||
- Call ontime_update_custom_field with { key, label?, colour? }.
|
|
||||||
- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming.
|
|
||||||
- The field type cannot be changed.
|
|
||||||
|
|
||||||
Deleting a field:
|
|
||||||
- Call ontime_delete_custom_field with { key }.
|
|
||||||
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
|
|
||||||
|
|
||||||
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unknown prompt: ${name}`);
|
throw new Error(`Unknown prompt: ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,20 +17,9 @@ mcpRouter.post('/', async (req, res) => {
|
|||||||
sessionIdGenerator: undefined, // stateless: no session tracking
|
sessionIdGenerator: undefined, // stateless: no session tracking
|
||||||
enableJsonResponse: true,
|
enableJsonResponse: true,
|
||||||
});
|
});
|
||||||
const server = createMcpServer();
|
res.on('close', () => transport.close());
|
||||||
|
|
||||||
let cleanedUp = false;
|
|
||||||
const cleanup = async () => {
|
|
||||||
if (cleanedUp) return;
|
|
||||||
cleanedUp = true;
|
|
||||||
transport.close();
|
|
||||||
await server.close?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
res.on('close', () => void cleanup());
|
|
||||||
res.on('finish', () => void cleanup());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const server = createMcpServer();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
|
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -39,8 +28,6 @@ mcpRouter.post('/', async (req, res) => {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
|
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
await cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -177,15 +177,8 @@ Examples:
|
|||||||
## Custom fields
|
## Custom fields
|
||||||
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
|
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
|
||||||
Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
|
Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
|
||||||
|
Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation.
|
||||||
Managing field definitions:
|
When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||||
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
|
|
||||||
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Confirm the derived key before creating. Returns \`{ key, customFields }\`.
|
|
||||||
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
|
|
||||||
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
|
|
||||||
|
|
||||||
When setting \`custom\` values on entries, only use existing field keys. If a key does not exist, create it with \`ontime_create_custom_field\` first.
|
|
||||||
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
|
||||||
|
|
||||||
## Targeting rundowns
|
## Targeting rundowns
|
||||||
Entry read/write tools accept an optional \`rundownId\`.
|
Entry read/write tools accept an optional \`rundownId\`.
|
||||||
@@ -232,10 +225,8 @@ Main site: https://docs.getontime.no
|
|||||||
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
|
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
|
||||||
- HTTP Integration: https://docs.getontime.no/api/http/
|
- HTTP Integration: https://docs.getontime.no/api/http/
|
||||||
- OSC Integration: https://docs.getontime.no/api/osc/
|
- OSC Integration: https://docs.getontime.no/api/osc/
|
||||||
- Make custom views: https://docs.getontime.no/features/custom-views/
|
|
||||||
|
|
||||||
## Helpful references
|
## API reference
|
||||||
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
|
- REST API overview: https://docs.getontime.no/api/
|
||||||
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
|
- WebSocket events: https://docs.getontime.no/api/websocket/
|
||||||
- Import data from spreadsheets: https://docs.getontime.no/features/import-spreadsheet/
|
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '
|
|||||||
import {
|
import {
|
||||||
addEntry,
|
addEntry,
|
||||||
batchEditEntries,
|
batchEditEntries,
|
||||||
createCustomField,
|
|
||||||
deleteCustomField,
|
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
editCustomField,
|
|
||||||
editEntry,
|
editEntry,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
@@ -106,16 +103,8 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (unknownKeys.size > 0) {
|
if (unknownKeys.size > 0) {
|
||||||
const missing = [...unknownKeys].join(', ');
|
const keys = [...unknownKeys].join(', ');
|
||||||
const available = Object.keys(customFields);
|
throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`);
|
||||||
const hint =
|
|
||||||
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
|
|
||||||
throw new Error(
|
|
||||||
`Unknown custom field key(s): ${missing}. ${hint} ` +
|
|
||||||
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
|
|
||||||
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` +
|
|
||||||
`Call ontime_get_custom_fields to list existing keys.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,21 +320,3 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
|
|||||||
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
||||||
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
|
|
||||||
const updated = await createCustomField({ label: args.label, type: args.type, colour: args.colour });
|
|
||||||
const key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label;
|
|
||||||
return { key, customFields: updated };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
|
||||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
|
||||||
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
|
|
||||||
return { customFields: updated };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCustomFieldForMcp(args: { key: string }) {
|
|
||||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
|
||||||
const updated = await deleteCustomField(args.key, projectRundowns);
|
|
||||||
return { customFields: updated };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
|
|||||||
import {
|
import {
|
||||||
batchCreateEntriesForMcp,
|
batchCreateEntriesForMcp,
|
||||||
batchUpdateEntriesForMcp,
|
batchUpdateEntriesForMcp,
|
||||||
createCustomFieldForMcp,
|
|
||||||
createEntryForMcp,
|
createEntryForMcp,
|
||||||
deleteCustomFieldForMcp,
|
|
||||||
deleteEntriesForMcp,
|
deleteEntriesForMcp,
|
||||||
findEntry,
|
findEntry,
|
||||||
getRundownById,
|
getRundownById,
|
||||||
@@ -35,7 +33,6 @@ import {
|
|||||||
reorderEntryForMcp,
|
reorderEntryForMcp,
|
||||||
toRundownList,
|
toRundownList,
|
||||||
ungroupEntryForMcp,
|
ungroupEntryForMcp,
|
||||||
updateCustomFieldForMcp,
|
|
||||||
updateEntryForMcp,
|
updateEntryForMcp,
|
||||||
type BatchCreateEntryArgs,
|
type BatchCreateEntryArgs,
|
||||||
type CreateEntryArgs,
|
type CreateEntryArgs,
|
||||||
@@ -374,64 +371,6 @@ export const TOOL_DEFINITIONS = [
|
|||||||
inputSchema: { type: 'object', properties: {} },
|
inputSchema: { type: 'object', properties: {} },
|
||||||
annotations: READ,
|
annotations: READ,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'ontime_create_custom_field',
|
|
||||||
description:
|
|
||||||
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). After creation, use the key in entry.custom when creating or updating entries.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['label', 'type', 'colour'],
|
|
||||||
properties: {
|
|
||||||
label: {
|
|
||||||
type: 'string',
|
|
||||||
description:
|
|
||||||
'Human-readable label (alphanumeric with spaces, e.g. "Camera"). Determines the key — ask the user to confirm before creating to avoid duplicates like "Cam", "camera", "Cameras".',
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['text', 'image'],
|
|
||||||
description:
|
|
||||||
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
|
|
||||||
},
|
|
||||||
colour: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
annotations: WRITE,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ontime_update_custom_field',
|
|
||||||
description:
|
|
||||||
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['key'],
|
|
||||||
properties: {
|
|
||||||
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
|
|
||||||
label: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.',
|
|
||||||
},
|
|
||||||
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
annotations: WRITE_IDEM,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ontime_delete_custom_field',
|
|
||||||
description:
|
|
||||||
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['key'],
|
|
||||||
properties: {
|
|
||||||
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
annotations: WRITE_DESTRUCTIVE,
|
|
||||||
},
|
|
||||||
// --- Project file management ---
|
// --- Project file management ---
|
||||||
{
|
{
|
||||||
name: 'ontime_list_projects',
|
name: 'ontime_list_projects',
|
||||||
@@ -529,7 +468,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
|||||||
const serialised = text(data);
|
const serialised = text(data);
|
||||||
if (serialised.length > CHARACTER_LIMIT) {
|
if (serialised.length > CHARACTER_LIMIT) {
|
||||||
return ok({
|
return ok({
|
||||||
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry.`,
|
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry. Entry IDs in order: ${rundown.order.join(', ')}`,
|
||||||
truncated: true,
|
truncated: true,
|
||||||
rundownId: rundown.id,
|
rundownId: rundown.id,
|
||||||
order: rundown.order,
|
order: rundown.order,
|
||||||
@@ -632,18 +571,6 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
|||||||
|
|
||||||
ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
|
ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
|
||||||
|
|
||||||
ontime_create_custom_field: async (args) => {
|
|
||||||
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
|
|
||||||
},
|
|
||||||
|
|
||||||
ontime_update_custom_field: async (args) => {
|
|
||||||
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
|
|
||||||
},
|
|
||||||
|
|
||||||
ontime_delete_custom_field: async (args) => {
|
|
||||||
return ok(await deleteCustomFieldForMcp(args as { key: string }));
|
|
||||||
},
|
|
||||||
|
|
||||||
ontime_list_projects: async () => ok(await getProjectList()),
|
ontime_list_projects: async () => ok(await getProjectList()),
|
||||||
|
|
||||||
ontime_load_project: async (args) => {
|
ontime_load_project: async (args) => {
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ describe('safeMerge', () => {
|
|||||||
it('should merge the urlPresets key when present', () => {
|
it('should merge the urlPresets key when present', () => {
|
||||||
const newData = {
|
const newData = {
|
||||||
urlPresets: [
|
urlPresets: [
|
||||||
{ enabled: true, alias: 'alias1', target: 'timer', search: '', displayInNav: false },
|
{ enabled: true, alias: 'alias1', search: '' },
|
||||||
{ enabled: true, alias: 'alias2', target: 'timer', search: '', displayInNav: false },
|
{ enabled: true, alias: 'alias2', search: '' },
|
||||||
] as URLPreset[],
|
] as URLPreset[],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export const demoDb: DatabaseModel = {
|
|||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search:
|
search:
|
||||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -51,7 +50,6 @@ export const demoDb: DatabaseModel = {
|
|||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
search:
|
search:
|
||||||
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
|
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
|
||||||
displayInNav: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
customFields: {
|
customFields: {
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
import { MessageTag, OffsetMode, TimerLifeCycle } from 'ontime-types';
|
|
||||||
/**
|
|
||||||
* Characterisation tests for the broadcast contract of the RuntimeService
|
|
||||||
*
|
|
||||||
* These tests lock down WHICH top-level keys of the runtime store are
|
|
||||||
* emitted to clients for each action and tick. This is the wire contract
|
|
||||||
* consumed by the client (which shallow-merges top-level keys) and by
|
|
||||||
* user-facing automation templates.
|
|
||||||
*
|
|
||||||
* The tests drive the real production path: fake timers fire the
|
|
||||||
* EventTimer interval, which runs update() and the broadcastResult
|
|
||||||
* decorator diffing, down to the (spied) websocket fan-out.
|
|
||||||
*/
|
|
||||||
import type { MockInstance } from 'vitest';
|
|
||||||
|
|
||||||
import { makeFlatRundown } from '../../../stores/__tests__/harness/scenario.fixtures.js';
|
|
||||||
|
|
||||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
|
||||||
return {
|
|
||||||
getDataProvider: vi.fn().mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../../restore-service/restore.service.js', () => {
|
|
||||||
return {
|
|
||||||
restoreService: {
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
load: vi.fn(),
|
|
||||||
clear: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
shutdown: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../../../api-data/automation/automation.service.js', () => {
|
|
||||||
return {
|
|
||||||
triggerAutomations: vi.fn(),
|
|
||||||
testOutput: vi.fn(),
|
|
||||||
testConditions: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../../../api-data/report/report.service.js', () => {
|
|
||||||
return {
|
|
||||||
generate: vi.fn(),
|
|
||||||
clear: vi.fn(),
|
|
||||||
triggerReportEntry: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
type RuntimeServiceModule = typeof import('../runtime.service.js');
|
|
||||||
type AutomationModule = typeof import('../../../api-data/automation/automation.service.js');
|
|
||||||
type RestoreModule = typeof import('../../restore-service/restore.service.js');
|
|
||||||
|
|
||||||
let runtimeService: RuntimeServiceModule['runtimeService'];
|
|
||||||
let triggerAutomations: AutomationModule['triggerAutomations'];
|
|
||||||
let restoreService: RestoreModule['restoreService'];
|
|
||||||
let sendSpy: MockInstance;
|
|
||||||
|
|
||||||
/** RuntimeData patches sent over the websocket since the last spy reset */
|
|
||||||
function runtimePatches(): Record<string, unknown>[] {
|
|
||||||
return sendSpy.mock.calls
|
|
||||||
.filter(([tag]) => tag === MessageTag.RuntimeData)
|
|
||||||
.map(([, payload]) => payload as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** the sorted top-level keys of every emitted patch */
|
|
||||||
function emittedKeys(): string[][] {
|
|
||||||
return runtimePatches().map((patch) => Object.keys(patch).sort());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** drains the process.nextTick queue where automation triggers are scheduled */
|
|
||||||
async function flushNextTicks() {
|
|
||||||
await new Promise((resolve) => process.nextTick(resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// the runtime service starts its EventTimer interval at import time:
|
|
||||||
// reset modules and install fake timers BEFORE importing, so the
|
|
||||||
// interval is fake and controlled by the test
|
|
||||||
vi.resetModules();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime('jan 5 09:59');
|
|
||||||
|
|
||||||
const websocketModule = await import('../../../adapters/WebsocketAdapter.js');
|
|
||||||
sendSpy = vi.spyOn(websocketModule.socket, 'sendAsJson');
|
|
||||||
|
|
||||||
const automationModule = await import('../../../api-data/automation/automation.service.js');
|
|
||||||
triggerAutomations = automationModule.triggerAutomations;
|
|
||||||
|
|
||||||
const restoreModule = await import('../../restore-service/restore.service.js');
|
|
||||||
restoreService = restoreModule.restoreService;
|
|
||||||
|
|
||||||
const serviceModule = await import('../runtime.service.js');
|
|
||||||
runtimeService = serviceModule.runtimeService;
|
|
||||||
runtimeService.init(null);
|
|
||||||
|
|
||||||
const { initRundown } = await import('../../../api-data/rundown/rundown.service.js');
|
|
||||||
await initRundown(makeFlatRundown(), {});
|
|
||||||
// flush the scheduled rundown side effects
|
|
||||||
// (we cannot use runAllTimers here, the EventTimer interval never stops)
|
|
||||||
vi.runOnlyPendingTimers();
|
|
||||||
|
|
||||||
sendSpy.mockClear();
|
|
||||||
vi.mocked(restoreService.save).mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
runtimeService.shutdown();
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* !!! characterised bug: the decorator combines the entry diffing with
|
|
||||||
* `entryChanged ||= updateMaybeEntryIfChanged(key)`. The logical-or
|
|
||||||
* assignment short-circuits: once one entry has changed, the remaining
|
|
||||||
* entries are neither diffed nor emitted in that broadcast. Changed
|
|
||||||
* entries therefore drip out one per broadcast cycle
|
|
||||||
* (load emits eventNow; eventNext rides along on the next broadcast,
|
|
||||||
* eventFlag on the one after)
|
|
||||||
*/
|
|
||||||
describe('characterisation: broadcast contract', () => {
|
|
||||||
test('loading an event emits eventNow, timer, clock and rundown', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
|
|
||||||
// eventNext and eventFlag also changed, but are held back by the short-circuit
|
|
||||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'rundown', 'timer']]);
|
|
||||||
// an immediate change also saves a restore point
|
|
||||||
expect(restoreService.save).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starting emits timer, clock, offset and rundown', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
sendSpy.mockClear();
|
|
||||||
|
|
||||||
runtimeService.start();
|
|
||||||
|
|
||||||
// offset is included because the expected times are computed on start
|
|
||||||
// eventNext is the pending entry dripping out from the load
|
|
||||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventNext', 'offset', 'rundown', 'timer']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ticks within the same second only flush the pending entry', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
runtimeService.start();
|
|
||||||
sendSpy.mockClear();
|
|
||||||
|
|
||||||
// the EventTimer interval fires every 32ms: run 5 production ticks
|
|
||||||
vi.advanceTimersByTime(5 * 32);
|
|
||||||
|
|
||||||
// nothing tick-related is emitted within the same second: the only
|
|
||||||
// patch is the eventFlag entry still dripping out from the load
|
|
||||||
expect(emittedKeys()).toStrictEqual([['eventFlag']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a tick crossing the second boundary emits timer and clock together', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
runtimeService.start();
|
|
||||||
sendSpy.mockClear();
|
|
||||||
|
|
||||||
// 32 interval ticks: the seconds boundary is crossed at the 1024ms tick
|
|
||||||
vi.advanceTimersByTime(1024);
|
|
||||||
|
|
||||||
// first tick flushes the dripping eventFlag; the boundary tick emits
|
|
||||||
// timer and clock; offset is not included since it did not change
|
|
||||||
expect(emittedKeys()).toStrictEqual([['eventFlag'], ['clock', 'timer']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('addTime emits timer, clock and offset immediately', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
runtimeService.start();
|
|
||||||
sendSpy.mockClear();
|
|
||||||
vi.mocked(restoreService.save).mockClear();
|
|
||||||
|
|
||||||
runtimeService.addTime(60_000);
|
|
||||||
|
|
||||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'offset', 'timer']]);
|
|
||||||
expect(restoreService.save).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setOffsetMode emits the offset mid-second', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
runtimeService.start();
|
|
||||||
sendSpy.mockClear();
|
|
||||||
vi.mocked(restoreService.save).mockClear();
|
|
||||||
|
|
||||||
runtimeService.setOffsetMode(OffsetMode.Relative);
|
|
||||||
|
|
||||||
expect(emittedKeys()).toStrictEqual([['eventFlag', 'offset']]);
|
|
||||||
// the mode change counts as an immediate change and saves a restore point
|
|
||||||
expect(restoreService.save).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pause emits timer and clock; stop emits the clearing state', () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
runtimeService.start();
|
|
||||||
sendSpy.mockClear();
|
|
||||||
|
|
||||||
runtimeService.pause();
|
|
||||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'timer']]);
|
|
||||||
|
|
||||||
sendSpy.mockClear();
|
|
||||||
runtimeService.stop();
|
|
||||||
// eventNow clears to null; the other cleared entries are again held
|
|
||||||
// back by the entry short-circuit
|
|
||||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'offset', 'rundown', 'timer']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('integration automations are throttled to the seconds rollover', async () => {
|
|
||||||
runtimeService.loadById('flat1');
|
|
||||||
vi.setSystemTime('jan 5 10:00');
|
|
||||||
runtimeService.start();
|
|
||||||
await flushNextTicks();
|
|
||||||
vi.mocked(triggerAutomations).mockClear();
|
|
||||||
|
|
||||||
// the first tick initialises the integration trackers and fires both cycles
|
|
||||||
vi.advanceTimersByTime(32);
|
|
||||||
await flushNextTicks();
|
|
||||||
expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([
|
|
||||||
[TimerLifeCycle.onUpdate],
|
|
||||||
[TimerLifeCycle.onClock],
|
|
||||||
]);
|
|
||||||
vi.mocked(triggerAutomations).mockClear();
|
|
||||||
|
|
||||||
// ticks within the same second do not trigger integrations
|
|
||||||
vi.advanceTimersByTime(5 * 32);
|
|
||||||
await flushNextTicks();
|
|
||||||
expect(triggerAutomations).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// crossing into the next second triggers onUpdate and onClock once
|
|
||||||
vi.advanceTimersByTime(832);
|
|
||||||
await flushNextTicks();
|
|
||||||
expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([
|
|
||||||
[TimerLifeCycle.onUpdate],
|
|
||||||
[TimerLifeCycle.onClock],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
/**
|
|
||||||
* Characterisation tests for the change-detection predicates that gate
|
|
||||||
* what gets broadcast to clients on every tick
|
|
||||||
*/
|
|
||||||
import { Offset, OffsetMode, PlayableEvent, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
|
||||||
|
|
||||||
import { makeOntimeEvent } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
|
||||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
|
||||||
import type { RuntimeState } from '../../../stores/runtimeState.js';
|
|
||||||
import {
|
|
||||||
collectRuntimeStateChanges,
|
|
||||||
getShouldClockUpdate,
|
|
||||||
getShouldOffsetUpdate,
|
|
||||||
getShouldTimerUpdate,
|
|
||||||
isNewSecond,
|
|
||||||
} from '../runtime.utils.js';
|
|
||||||
|
|
||||||
const baseTimer: TimerState = {
|
|
||||||
addedTime: 0,
|
|
||||||
current: 10_000,
|
|
||||||
duration: 60_000,
|
|
||||||
elapsed: 50_000,
|
|
||||||
expectedFinish: 100_000,
|
|
||||||
phase: TimerPhase.Default,
|
|
||||||
playback: Playback.Play,
|
|
||||||
secondaryTimer: null,
|
|
||||||
startedAt: 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeTimer(patch: Partial<TimerState>): TimerState {
|
|
||||||
return { ...baseTimer, ...patch };
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseOffset: Offset = {
|
|
||||||
absolute: 0,
|
|
||||||
relative: 0,
|
|
||||||
mode: OffsetMode.Absolute,
|
|
||||||
expectedGroupEnd: null,
|
|
||||||
expectedRundownEnd: null,
|
|
||||||
expectedFlagStart: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('isNewSecond()', () => {
|
|
||||||
describe('count down (default): seconds are rounded up', () => {
|
|
||||||
test.each([
|
|
||||||
// [previous, current, expected]
|
|
||||||
[1500, 1200, false], // both round to 2
|
|
||||||
[1500, 1000, true], // 2 -> 1
|
|
||||||
[1000, 999, false], // both round to 1
|
|
||||||
[999, 1, false], // both round to 1
|
|
||||||
[1, 0, true], // 1 -> 0
|
|
||||||
[0, -999, false], // both round to 0
|
|
||||||
[-1, -1000, true], // 0 -> -1
|
|
||||||
])('%s -> %s is a new second: %s', (previous, current, expected) => {
|
|
||||||
expect(isNewSecond(previous, current)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('null and undefined values are coerced to second 0', () => {
|
|
||||||
expect(isNewSecond(undefined, 0)).toBe(false);
|
|
||||||
expect(isNewSecond(null, 0)).toBe(false);
|
|
||||||
expect(isNewSecond(undefined, 1000)).toBe(true);
|
|
||||||
expect(isNewSecond(0, null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('count up: seconds are rounded down', () => {
|
|
||||||
test.each([
|
|
||||||
[999, 1, false], // both round to 0
|
|
||||||
[999, 1000, true], // 0 -> 1
|
|
||||||
[1000, 1999, false], // both round to 1
|
|
||||||
])('%s -> %s is a new second: %s', (previous, current, expected) => {
|
|
||||||
expect(isNewSecond(previous, current, TimerType.CountUp)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShouldClockUpdate()', () => {
|
|
||||||
test('updates when the clock rolls into a new second', () => {
|
|
||||||
expect(getShouldClockUpdate(500, 999)).toBe(false);
|
|
||||||
expect(getShouldClockUpdate(999, 1000)).toBe(true);
|
|
||||||
expect(getShouldClockUpdate(1000, 1032)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updates when the clock wraps around midnight', () => {
|
|
||||||
expect(getShouldClockUpdate(86_399_999, 0)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShouldTimerUpdate()', () => {
|
|
||||||
test('always updates when there is no previous state', () => {
|
|
||||||
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not update when nothing changed', () => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, makeTimer({}))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('current triggers only on a new second', () => {
|
|
||||||
// 10_000 and 9_001 both round up to second 10
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9001 }))).toBe(false);
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9000 }))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('secondaryTimer triggers only on a new second', () => {
|
|
||||||
const previous = makeTimer({ secondaryTimer: 5000 });
|
|
||||||
expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4001 }))).toBe(false);
|
|
||||||
expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4000 }))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
['addedTime', { addedTime: 1000 }],
|
|
||||||
['duration', { duration: 61_000 }],
|
|
||||||
['phase', { phase: TimerPhase.Warning }],
|
|
||||||
['playback', { playback: Playback.Pause }],
|
|
||||||
['startedAt', { startedAt: 2000 }],
|
|
||||||
] as const)('%s triggers on any change', (_field, patch) => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
['elapsed', { elapsed: 50_500 }],
|
|
||||||
['expectedFinish', { expectedFinish: 100_500 }],
|
|
||||||
] as const)('%s is deliberately not checked', (_field, patch) => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShouldOffsetUpdate()', () => {
|
|
||||||
test('always updates when there is no previous state', () => {
|
|
||||||
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mode change triggers regardless of dependencies', () => {
|
|
||||||
const current = { ...baseOffset, mode: OffsetMode.Relative };
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('value changes are gated by the dependency update', () => {
|
|
||||||
const current = { ...baseOffset, absolute: 1000 };
|
|
||||||
// a changed offset without a timer/clock update is not emitted
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(false);
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not update when deep-equal, even with a dependency update', () => {
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('expected times participate in the deep comparison', () => {
|
|
||||||
const current = { ...baseOffset, expectedRundownEnd: 1000 };
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('collectRuntimeStateChanges()', () => {
|
|
||||||
test('the very first run emits everything and counts as an immediate change', () => {
|
|
||||||
const previousState = {} as RuntimeState;
|
|
||||||
const state = makeRuntimeStateData();
|
|
||||||
|
|
||||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
|
||||||
|
|
||||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'offset', 'rundown', 'timer']);
|
|
||||||
expect(hasImmediateChanges).toBe(true);
|
|
||||||
// the emitted keys are persisted into the previous state for the next diff
|
|
||||||
expect(previousState.timer).toStrictEqual(state.timer);
|
|
||||||
expect(previousState.offset).toStrictEqual(state.offset);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an unchanged state emits nothing', () => {
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const state = makeRuntimeStateData();
|
|
||||||
|
|
||||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
|
||||||
|
|
||||||
expect(patch).toStrictEqual({});
|
|
||||||
expect(hasImmediateChanges).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a playback change emits timer and clock together', () => {
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const state = makeRuntimeStateData({ timer: { playback: Playback.Armed } });
|
|
||||||
|
|
||||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
|
||||||
|
|
||||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
|
|
||||||
expect(hasImmediateChanges).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an offset change is gated on a timer/clock dependency', () => {
|
|
||||||
// nothing else changed: the offset is held back
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const state = makeRuntimeStateData({ offset: { absolute: 1000 } });
|
|
||||||
|
|
||||||
const gated = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(gated.patch).toStrictEqual({});
|
|
||||||
|
|
||||||
// with a clock rollover, the offset rides along
|
|
||||||
const stateWithClock = makeRuntimeStateData({ clock: 1000, offset: { absolute: 1000 } });
|
|
||||||
const emitted = collectRuntimeStateChanges(previousState, stateWithClock);
|
|
||||||
expect(Object.keys(emitted.patch).sort()).toStrictEqual(['clock', 'offset']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('!!! characterised bug: changed entries drip out one per call', () => {
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const eventNow = makeOntimeEvent({ id: 'now' }) as PlayableEvent;
|
|
||||||
const eventNext = makeOntimeEvent({ id: 'next' }) as PlayableEvent;
|
|
||||||
const state = makeRuntimeStateData({ eventNow, eventNext });
|
|
||||||
|
|
||||||
// both entries changed, but the ||= short-circuit only diffs the first
|
|
||||||
const first = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(Object.keys(first.patch).sort()).toStrictEqual(['eventNow']);
|
|
||||||
|
|
||||||
// the second call flushes the next pending entry
|
|
||||||
const second = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(Object.keys(second.patch).sort()).toStrictEqual(['eventNext']);
|
|
||||||
|
|
||||||
// from here on, nothing is pending
|
|
||||||
const third = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(third.patch).toStrictEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rundown data changes are emitted on deep difference', () => {
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const state = makeRuntimeStateData({ rundown: { actualStart: 1000 } });
|
|
||||||
|
|
||||||
const { patch } = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(Object.keys(patch).sort()).toStrictEqual(['rundown']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('addedTime changes count as immediate and emit the timer', () => {
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const state = makeRuntimeStateData({ timer: { addedTime: 60_000 } });
|
|
||||||
|
|
||||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
|
|
||||||
expect(hasImmediateChanges).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an offset mode change is immediate and bypasses the dependency gate', () => {
|
|
||||||
const previousState = makeRuntimeStateData();
|
|
||||||
const state = makeRuntimeStateData({ offset: { mode: OffsetMode.Relative } });
|
|
||||||
|
|
||||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
|
||||||
expect(Object.keys(patch).sort()).toStrictEqual(['offset']);
|
|
||||||
expect(hasImmediateChanges).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { deepEqual } from 'fast-equals';
|
||||||
import {
|
import {
|
||||||
EndAction,
|
EndAction,
|
||||||
EntryId,
|
EntryId,
|
||||||
@@ -29,12 +30,13 @@ import { restoreService } from '../restore-service/restore.service.js';
|
|||||||
import type { RestorePoint } from '../restore-service/restore.type.js';
|
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||||
import {
|
import {
|
||||||
collectRuntimeStateChanges,
|
|
||||||
findNextPlayableId,
|
findNextPlayableId,
|
||||||
findNextPlayableWithCue,
|
findNextPlayableWithCue,
|
||||||
findPreviousPlayableId,
|
findPreviousPlayableId,
|
||||||
getEventAtIndex,
|
getEventAtIndex,
|
||||||
getShouldClockUpdate,
|
getShouldClockUpdate,
|
||||||
|
getShouldOffsetUpdate,
|
||||||
|
getShouldTimerUpdate,
|
||||||
isNewSecond,
|
isNewSecond,
|
||||||
} from './runtime.utils.js';
|
} from './runtime.utils.js';
|
||||||
|
|
||||||
@@ -649,6 +651,8 @@ const eventTimer = new EventTimer({
|
|||||||
});
|
});
|
||||||
export const runtimeService = new RuntimeService(eventTimer);
|
export const runtimeService = new RuntimeService(eventTimer);
|
||||||
|
|
||||||
|
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorator manages side effects from updating the runtime
|
* Decorator manages side effects from updating the runtime
|
||||||
* This should only be applied to functions that are exposed for consumption
|
* This should only be applied to functions that are exposed for consumption
|
||||||
@@ -661,13 +665,90 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
// call the original method and get the state
|
// call the original method and get the state
|
||||||
const result = originalMethod.apply(this, args);
|
const result = originalMethod.apply(this, args);
|
||||||
const state = runtimeState.getState();
|
const state = runtimeState.getState();
|
||||||
|
|
||||||
// diff against the previously broadcast state to find what to send
|
|
||||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(RuntimeService.previousState, state);
|
|
||||||
|
|
||||||
const batch = eventStore.createBatch();
|
const batch = eventStore.createBatch();
|
||||||
for (const key of Object.keys(patch) as (keyof RuntimeStore)[]) {
|
|
||||||
batch.add(key, patch[key] as RuntimeStore[typeof key]);
|
// we do the comparison by explicitly for each property
|
||||||
|
// to apply custom logic for different datasets
|
||||||
|
|
||||||
|
// Update the entry if they have changed
|
||||||
|
let entryChanged = false;
|
||||||
|
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
||||||
|
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
||||||
|
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
||||||
|
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
||||||
|
|
||||||
|
// for the very fist run there will be nothing in the previousState so we force an update
|
||||||
|
const justStarted = !RuntimeService.previousState?.timer;
|
||||||
|
|
||||||
|
// offset mode has been changed
|
||||||
|
const offsetModeChanged = RuntimeService.previousState?.offset?.mode !== state.offset.mode;
|
||||||
|
|
||||||
|
// if playback changes most things should update
|
||||||
|
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||||
|
|
||||||
|
const addedTimeChanged = !justStarted && RuntimeService.previousState?.timer.addedTime !== state.timer.addedTime;
|
||||||
|
|
||||||
|
// combine all big changes
|
||||||
|
const hasImmediateChanges =
|
||||||
|
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if any values have changed.
|
||||||
|
* values that have the possibility to tick are updated when the seconds roll over
|
||||||
|
*/
|
||||||
|
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
||||||
|
if (updateTimer) {
|
||||||
|
batch.add('timer', state.timer);
|
||||||
|
RuntimeService.previousState.timer = { ...state.timer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clock has changed by a second or more.
|
||||||
|
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||||
|
*/
|
||||||
|
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||||
|
if (updateClock) {
|
||||||
|
batch.add('clock', state.clock);
|
||||||
|
RuntimeService.previousState.clock = state.clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if any values have changed.
|
||||||
|
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||||
|
*/
|
||||||
|
const updateRuntime = getShouldOffsetUpdate(
|
||||||
|
RuntimeService.previousState?.offset,
|
||||||
|
state.offset,
|
||||||
|
updateClock || hasImmediateChanges,
|
||||||
|
);
|
||||||
|
if (updateRuntime) {
|
||||||
|
batch.add('offset', state.offset);
|
||||||
|
RuntimeService.previousState.offset = structuredClone(state.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if any values have changed.
|
||||||
|
*/
|
||||||
|
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
||||||
|
if (updateRundownData) {
|
||||||
|
batch.add('rundown', state.rundown);
|
||||||
|
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||||
|
const previousEntry = RuntimeService.previousState[key];
|
||||||
|
const currentEntry = state[key];
|
||||||
|
|
||||||
|
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
||||||
|
|
||||||
|
// if they have the same id the check if the contents have changed
|
||||||
|
if (previousEntry?.id === currentEntry?.id) {
|
||||||
|
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
||||||
|
}
|
||||||
|
// at this point we know that either the id or the contents has changed
|
||||||
|
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
||||||
|
RuntimeService.previousState[key] = structuredClone(currentEntry);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// save the restore state
|
// save the restore state
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Offset,
|
Offset,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Rundown,
|
Rundown,
|
||||||
RuntimeStore,
|
|
||||||
TimerState,
|
TimerState,
|
||||||
TimerType,
|
TimerType,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
@@ -13,8 +12,6 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { millisToSeconds } from 'ontime-utils';
|
import { millisToSeconds } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
|
||||||
|
|
||||||
export function isNewSecond(
|
export function isNewSecond(
|
||||||
previousValue: MaybeNumber | undefined,
|
previousValue: MaybeNumber | undefined,
|
||||||
currentValue: MaybeNumber | undefined,
|
currentValue: MaybeNumber | undefined,
|
||||||
@@ -71,104 +68,6 @@ export function getShouldOffsetUpdate(
|
|||||||
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Diffs a runtime state snapshot against the previously broadcast state
|
|
||||||
* and collects the store keys that should be sent to clients
|
|
||||||
*
|
|
||||||
* !!! mutates previousState in place: emitted keys are persisted so the
|
|
||||||
* next diff compares against what clients last received
|
|
||||||
*/
|
|
||||||
export function collectRuntimeStateChanges(
|
|
||||||
previousState: RuntimeState,
|
|
||||||
state: Readonly<RuntimeState>,
|
|
||||||
): { patch: Partial<RuntimeStore>; hasImmediateChanges: boolean } {
|
|
||||||
const patch: Partial<RuntimeStore> = {};
|
|
||||||
|
|
||||||
// we do the comparison by explicitly for each property
|
|
||||||
// to apply custom logic for different datasets
|
|
||||||
|
|
||||||
// Update the entry if they have changed
|
|
||||||
let entryChanged = false;
|
|
||||||
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
|
||||||
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
|
||||||
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
|
||||||
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
|
||||||
|
|
||||||
// for the very fist run there will be nothing in the previousState so we force an update
|
|
||||||
const justStarted = !previousState?.timer;
|
|
||||||
|
|
||||||
// offset mode has been changed
|
|
||||||
const offsetModeChanged = previousState?.offset?.mode !== state.offset.mode;
|
|
||||||
|
|
||||||
// if playback changes most things should update
|
|
||||||
const hasChangedPlayback = previousState.timer?.playback !== state.timer.playback;
|
|
||||||
|
|
||||||
const addedTimeChanged = !justStarted && previousState?.timer.addedTime !== state.timer.addedTime;
|
|
||||||
|
|
||||||
// combine all big changes
|
|
||||||
const hasImmediateChanges =
|
|
||||||
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* if any values have changed.
|
|
||||||
* values that have the possibility to tick are updated when the seconds roll over
|
|
||||||
*/
|
|
||||||
const updateTimer = getShouldTimerUpdate(previousState?.timer, state.timer);
|
|
||||||
if (updateTimer) {
|
|
||||||
patch.timer = state.timer;
|
|
||||||
previousState.timer = { ...state.timer };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* clock has changed by a second or more.
|
|
||||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
|
||||||
*/
|
|
||||||
const updateClock = updateTimer || getShouldClockUpdate(previousState.clock, state.clock);
|
|
||||||
if (updateClock) {
|
|
||||||
patch.clock = state.clock;
|
|
||||||
previousState.clock = state.clock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* if any values have changed.
|
|
||||||
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
|
||||||
*/
|
|
||||||
const updateRuntime = getShouldOffsetUpdate(previousState?.offset, state.offset, updateClock || hasImmediateChanges);
|
|
||||||
if (updateRuntime) {
|
|
||||||
patch.offset = state.offset;
|
|
||||||
previousState.offset = structuredClone(state.offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* if any values have changed.
|
|
||||||
*/
|
|
||||||
const updateRundownData = !deepEqual(previousState.rundown, state.rundown);
|
|
||||||
if (updateRundownData) {
|
|
||||||
patch.rundown = state.rundown;
|
|
||||||
previousState.rundown = structuredClone(state.rundown);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
|
||||||
const previousEntry = previousState[key];
|
|
||||||
const currentEntry = state[key];
|
|
||||||
|
|
||||||
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
|
||||||
|
|
||||||
// if they have the same id the check if the contents have changed
|
|
||||||
if (previousEntry?.id === currentEntry?.id) {
|
|
||||||
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
|
||||||
}
|
|
||||||
// at this point we know that either the id or the contents has changed
|
|
||||||
patch[key] = currentEntry as RuntimeStore[K]; // we know that there is the necessary overlap in the types to cast this
|
|
||||||
previousState[key] = structuredClone(currentEntry);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { patch, hasImmediateChanges };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* finds the previous playable event, if it exists
|
* finds the previous playable event, if it exists
|
||||||
*/
|
*/
|
||||||
|
|||||||
-313
@@ -1,313 +0,0 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
||||||
|
|
||||||
exports[`characterisation: playback across midnight > playing across midnight increments currentDay and keeps offsets 1`] = `
|
|
||||||
{
|
|
||||||
"_end": {
|
|
||||||
"accumulatedGap": 0,
|
|
||||||
"event": {
|
|
||||||
"dayOffset": 1,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 1800000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "night3",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 3600000,
|
|
||||||
"timeStart": 1800000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"isLinkedToLoaded": undefined,
|
|
||||||
},
|
|
||||||
"_flag": null,
|
|
||||||
"_group": null,
|
|
||||||
"_rundown": {
|
|
||||||
"totalDelay": 0,
|
|
||||||
},
|
|
||||||
"_startDayOffset": 0,
|
|
||||||
"_startEpoch": 978734100000,
|
|
||||||
"_timer": {
|
|
||||||
"forceFinish": null,
|
|
||||||
"hasFinished": false,
|
|
||||||
"pausedAt": null,
|
|
||||||
"secondaryTarget": null,
|
|
||||||
},
|
|
||||||
"clock": 600000,
|
|
||||||
"eventFlag": null,
|
|
||||||
"eventNext": {
|
|
||||||
"dayOffset": 1,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 1800000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "night3",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 3600000,
|
|
||||||
"timeStart": 1800000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"eventNow": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 3600000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "night2",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 1800000,
|
|
||||||
"timeStart": 84600000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"groupNow": null,
|
|
||||||
"offset": {
|
|
||||||
"absolute": 300000,
|
|
||||||
"expectedFlagStart": null,
|
|
||||||
"expectedGroupEnd": null,
|
|
||||||
"expectedRundownEnd": 3900000,
|
|
||||||
"mode": "absolute",
|
|
||||||
"relative": -5400000,
|
|
||||||
},
|
|
||||||
"rundown": {
|
|
||||||
"actualGroupStart": null,
|
|
||||||
"actualStart": 84900000,
|
|
||||||
"currentDay": 1,
|
|
||||||
"numEvents": 3,
|
|
||||||
"plannedEnd": 90000000,
|
|
||||||
"plannedStart": 79200000,
|
|
||||||
"selectedEventIndex": 1,
|
|
||||||
},
|
|
||||||
"timer": {
|
|
||||||
"addedTime": 0,
|
|
||||||
"current": 1500000,
|
|
||||||
"duration": 3600000,
|
|
||||||
"elapsed": 2100000,
|
|
||||||
"expectedFinish": 2100000,
|
|
||||||
"phase": "default",
|
|
||||||
"playback": "play",
|
|
||||||
"secondaryTimer": null,
|
|
||||||
"startedAt": 84900000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`characterisation: roll mode > roll into overnight event after midnight backdates start metadata 1`] = `
|
|
||||||
{
|
|
||||||
"_end": {
|
|
||||||
"accumulatedGap": 0,
|
|
||||||
"event": {
|
|
||||||
"dayOffset": 1,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 1800000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "night3",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 3600000,
|
|
||||||
"timeStart": 1800000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"isLinkedToLoaded": undefined,
|
|
||||||
},
|
|
||||||
"_flag": null,
|
|
||||||
"_group": null,
|
|
||||||
"_rundown": {
|
|
||||||
"totalDelay": 0,
|
|
||||||
},
|
|
||||||
"_startDayOffset": 0,
|
|
||||||
"_startEpoch": 978733800000,
|
|
||||||
"_timer": {
|
|
||||||
"forceFinish": null,
|
|
||||||
"hasFinished": false,
|
|
||||||
"pausedAt": null,
|
|
||||||
"secondaryTarget": null,
|
|
||||||
},
|
|
||||||
"clock": 300032,
|
|
||||||
"eventFlag": null,
|
|
||||||
"eventNext": {
|
|
||||||
"dayOffset": 1,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 1800000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "night3",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 3600000,
|
|
||||||
"timeStart": 1800000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"eventNow": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 3600000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "night2",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 1800000,
|
|
||||||
"timeStart": 84600000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"groupNow": null,
|
|
||||||
"offset": {
|
|
||||||
"absolute": 0,
|
|
||||||
"expectedFlagStart": null,
|
|
||||||
"expectedGroupEnd": null,
|
|
||||||
"expectedRundownEnd": 3600000,
|
|
||||||
"mode": "absolute",
|
|
||||||
"relative": -5400000,
|
|
||||||
},
|
|
||||||
"rundown": {
|
|
||||||
"actualGroupStart": null,
|
|
||||||
"actualStart": 84600000,
|
|
||||||
"currentDay": 1,
|
|
||||||
"numEvents": 3,
|
|
||||||
"plannedEnd": 90000000,
|
|
||||||
"plannedStart": 79200000,
|
|
||||||
"selectedEventIndex": 1,
|
|
||||||
},
|
|
||||||
"timer": {
|
|
||||||
"addedTime": 0,
|
|
||||||
"current": 1499968,
|
|
||||||
"duration": 3600000,
|
|
||||||
"elapsed": 2100032,
|
|
||||||
"expectedFinish": 1800000,
|
|
||||||
"phase": "default",
|
|
||||||
"playback": "roll",
|
|
||||||
"secondaryTimer": null,
|
|
||||||
"startedAt": 84600000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`characterisation: timed playback > late start: absolute and relative offsets with gap compensation 1`] = `
|
|
||||||
{
|
|
||||||
"_end": {
|
|
||||||
"accumulatedGap": 1200000,
|
|
||||||
"event": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 600000,
|
|
||||||
"gap": 600000,
|
|
||||||
"id": "flat4",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 39600000,
|
|
||||||
"timeStart": 39000000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"isLinkedToLoaded": undefined,
|
|
||||||
},
|
|
||||||
"_flag": {
|
|
||||||
"accumulatedGap": 0,
|
|
||||||
"event": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 600000,
|
|
||||||
"flag": true,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "flat2",
|
|
||||||
"linkStart": true,
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 37200000,
|
|
||||||
"timeStart": 36600000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"isLinkedToLoaded": true,
|
|
||||||
},
|
|
||||||
"_group": null,
|
|
||||||
"_rundown": {
|
|
||||||
"totalDelay": 0,
|
|
||||||
},
|
|
||||||
"_startDayOffset": 0,
|
|
||||||
"_startEpoch": 978685500000,
|
|
||||||
"_timer": {
|
|
||||||
"forceFinish": null,
|
|
||||||
"hasFinished": false,
|
|
||||||
"pausedAt": null,
|
|
||||||
"secondaryTarget": null,
|
|
||||||
},
|
|
||||||
"clock": 36300000,
|
|
||||||
"eventFlag": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 600000,
|
|
||||||
"flag": true,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "flat2",
|
|
||||||
"linkStart": true,
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 37200000,
|
|
||||||
"timeStart": 36600000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"eventNext": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 600000,
|
|
||||||
"flag": true,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "flat2",
|
|
||||||
"linkStart": true,
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeEnd": 37200000,
|
|
||||||
"timeStart": 36600000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"eventNow": {
|
|
||||||
"dayOffset": 0,
|
|
||||||
"delay": 0,
|
|
||||||
"duration": 600000,
|
|
||||||
"gap": 0,
|
|
||||||
"id": "flat1",
|
|
||||||
"parent": null,
|
|
||||||
"revision": 1,
|
|
||||||
"skip": false,
|
|
||||||
"timeDanger": 60000,
|
|
||||||
"timeEnd": 36600000,
|
|
||||||
"timeStart": 36000000,
|
|
||||||
"timeWarning": 120000,
|
|
||||||
"type": "event",
|
|
||||||
},
|
|
||||||
"groupNow": null,
|
|
||||||
"offset": {
|
|
||||||
"absolute": 300000,
|
|
||||||
"expectedFlagStart": 36900000,
|
|
||||||
"expectedGroupEnd": null,
|
|
||||||
"expectedRundownEnd": 39900000,
|
|
||||||
"mode": "relative",
|
|
||||||
"relative": 0,
|
|
||||||
},
|
|
||||||
"rundown": {
|
|
||||||
"actualGroupStart": null,
|
|
||||||
"actualStart": 36300000,
|
|
||||||
"currentDay": 0,
|
|
||||||
"numEvents": 4,
|
|
||||||
"plannedEnd": 39600000,
|
|
||||||
"plannedStart": 36000000,
|
|
||||||
"selectedEventIndex": 0,
|
|
||||||
},
|
|
||||||
"timer": {
|
|
||||||
"addedTime": 0,
|
|
||||||
"current": 600000,
|
|
||||||
"duration": 600000,
|
|
||||||
"elapsed": 0,
|
|
||||||
"expectedFinish": 36900000,
|
|
||||||
"phase": "default",
|
|
||||||
"playback": "play",
|
|
||||||
"secondaryTimer": null,
|
|
||||||
"startedAt": 36300000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { Rundown } from 'ontime-types';
|
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
makeOntimeDelay,
|
|
||||||
makeOntimeEvent,
|
|
||||||
makeOntimeGroup,
|
|
||||||
makeRundown,
|
|
||||||
} from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
|
||||||
|
|
||||||
const h = MILLIS_PER_HOUR;
|
|
||||||
const m = MILLIS_PER_MINUTE;
|
|
||||||
|
|
||||||
export const time = { h, m };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flat rundown exercising gaps, linked events and flags
|
|
||||||
* - flat1: 10:00 - 10:10
|
|
||||||
* - flat2: 10:10 - 10:20 (linked to flat1, flagged)
|
|
||||||
* - flat3: 10:30 - 10:40 (10min gap, flagged)
|
|
||||||
* - flat4: 10:50 - 11:00 (10min gap)
|
|
||||||
*
|
|
||||||
* total gap from flat1: 20min, planned 10:00 - 11:00
|
|
||||||
*/
|
|
||||||
export function makeFlatRundown(): Rundown {
|
|
||||||
return makeRundown({
|
|
||||||
entries: {
|
|
||||||
flat1: makeOntimeEvent({
|
|
||||||
id: 'flat1',
|
|
||||||
timeStart: 10 * h,
|
|
||||||
timeEnd: 10 * h + 10 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
timeWarning: 2 * m,
|
|
||||||
timeDanger: 1 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
flat2: makeOntimeEvent({
|
|
||||||
id: 'flat2',
|
|
||||||
timeStart: 10 * h + 10 * m,
|
|
||||||
timeEnd: 10 * h + 20 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
linkStart: true,
|
|
||||||
flag: true,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
flat3: makeOntimeEvent({
|
|
||||||
id: 'flat3',
|
|
||||||
timeStart: 10 * h + 30 * m,
|
|
||||||
timeEnd: 10 * h + 40 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
flag: true,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
flat4: makeOntimeEvent({
|
|
||||||
id: 'flat4',
|
|
||||||
timeStart: 10 * h + 50 * m,
|
|
||||||
timeEnd: 11 * h,
|
|
||||||
duration: 10 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
order: ['flat1', 'flat2', 'flat3', 'flat4'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rundown with a group and a trailing event
|
|
||||||
* - group: [grouped1: 10:00 - 10:30, grouped2: 10:30 - 11:00]
|
|
||||||
* - after: 11:30 - 12:00 (30min gap)
|
|
||||||
*/
|
|
||||||
export function makeGroupedRundown(): Rundown {
|
|
||||||
return makeRundown({
|
|
||||||
entries: {
|
|
||||||
group: makeOntimeGroup({ id: 'group', entries: ['grouped1', 'grouped2'] }),
|
|
||||||
grouped1: makeOntimeEvent({
|
|
||||||
id: 'grouped1',
|
|
||||||
timeStart: 10 * h,
|
|
||||||
timeEnd: 10 * h + 30 * m,
|
|
||||||
duration: 30 * m,
|
|
||||||
parent: 'group',
|
|
||||||
}),
|
|
||||||
grouped2: makeOntimeEvent({
|
|
||||||
id: 'grouped2',
|
|
||||||
timeStart: 10 * h + 30 * m,
|
|
||||||
timeEnd: 11 * h,
|
|
||||||
duration: 30 * m,
|
|
||||||
parent: 'group',
|
|
||||||
}),
|
|
||||||
after: makeOntimeEvent({
|
|
||||||
id: 'after',
|
|
||||||
timeStart: 11 * h + 30 * m,
|
|
||||||
timeEnd: 12 * h,
|
|
||||||
duration: 30 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
order: ['group', 'after'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overnight rundown
|
|
||||||
* - night1: 22:00 - 23:30
|
|
||||||
* - night2: 23:30 - 00:30 (crosses midnight)
|
|
||||||
* - night3: 00:30 - 01:00 (next day)
|
|
||||||
*/
|
|
||||||
export function makeOvernightRundown(): Rundown {
|
|
||||||
return makeRundown({
|
|
||||||
entries: {
|
|
||||||
night1: makeOntimeEvent({
|
|
||||||
id: 'night1',
|
|
||||||
timeStart: 22 * h,
|
|
||||||
timeEnd: 23 * h + 30 * m,
|
|
||||||
duration: 90 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
night2: makeOntimeEvent({
|
|
||||||
id: 'night2',
|
|
||||||
timeStart: 23 * h + 30 * m,
|
|
||||||
timeEnd: 30 * m,
|
|
||||||
duration: 60 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
night3: makeOntimeEvent({
|
|
||||||
id: 'night3',
|
|
||||||
timeStart: 30 * m,
|
|
||||||
timeEnd: 1 * h,
|
|
||||||
duration: 30 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
order: ['night1', 'night2', 'night3'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rundown with a count-to-end event
|
|
||||||
* - lead: 10:00 - 10:30
|
|
||||||
* - toEnd: 10:30 - 11:00 (countToEnd)
|
|
||||||
*/
|
|
||||||
export function makeCountToEndRundown(): Rundown {
|
|
||||||
return makeRundown({
|
|
||||||
entries: {
|
|
||||||
lead: makeOntimeEvent({
|
|
||||||
id: 'lead',
|
|
||||||
timeStart: 10 * h,
|
|
||||||
timeEnd: 10 * h + 30 * m,
|
|
||||||
duration: 30 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
toEnd: makeOntimeEvent({
|
|
||||||
id: 'toEnd',
|
|
||||||
timeStart: 10 * h + 30 * m,
|
|
||||||
timeEnd: 11 * h,
|
|
||||||
duration: 30 * m,
|
|
||||||
countToEnd: true,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
order: ['lead', 'toEnd'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rundown with a delay entry
|
|
||||||
* - delayed1: 10:00 - 10:10
|
|
||||||
* - (delay 5min)
|
|
||||||
* - delayed2: 10:20 - 10:30 (10min gap)
|
|
||||||
*/
|
|
||||||
export function makeDelayedRundown(): Rundown {
|
|
||||||
return makeRundown({
|
|
||||||
entries: {
|
|
||||||
delayed1: makeOntimeEvent({
|
|
||||||
id: 'delayed1',
|
|
||||||
timeStart: 10 * h,
|
|
||||||
timeEnd: 10 * h + 10 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
delay: makeOntimeDelay({ id: 'delay', duration: 5 * m, parent: null }),
|
|
||||||
delayed2: makeOntimeEvent({
|
|
||||||
id: 'delayed2',
|
|
||||||
timeStart: 10 * h + 20 * m,
|
|
||||||
timeEnd: 10 * h + 30 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
parent: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
order: ['delayed1', 'delay', 'delayed2'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { EntryId, MaybeString, OffsetMode, PlayableEvent, Rundown } from 'ontime-types';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
|
|
||||||
import { rundownCache } from '../../../api-data/rundown/rundown.dao.js';
|
|
||||||
import { initRundown } from '../../../api-data/rundown/rundown.service.js';
|
|
||||||
import { RundownMetadata } from '../../../api-data/rundown/rundown.types.js';
|
|
||||||
import { timerConfig } from '../../../setup/config.js';
|
|
||||||
import {
|
|
||||||
type RuntimeState,
|
|
||||||
type UpdateResult,
|
|
||||||
addTime,
|
|
||||||
clearState,
|
|
||||||
getState,
|
|
||||||
load,
|
|
||||||
pause,
|
|
||||||
roll,
|
|
||||||
setOffsetMode,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
update,
|
|
||||||
updateAll,
|
|
||||||
updateLoaded,
|
|
||||||
updateRundownData,
|
|
||||||
} from '../../runtimeState.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A compact, review-friendly projection of the runtime state
|
|
||||||
* This is the primary characterisation contract: it contains every field
|
|
||||||
* whose semantics are consumed by clients (offsets, expected times, playback)
|
|
||||||
* plus the private fields that drive those calculations
|
|
||||||
*/
|
|
||||||
export function digest() {
|
|
||||||
const state = getState();
|
|
||||||
return {
|
|
||||||
clock: state.clock,
|
|
||||||
playback: state.timer.playback,
|
|
||||||
phase: state.timer.phase,
|
|
||||||
current: state.timer.current,
|
|
||||||
duration: state.timer.duration,
|
|
||||||
elapsed: state.timer.elapsed,
|
|
||||||
addedTime: state.timer.addedTime,
|
|
||||||
startedAt: state.timer.startedAt,
|
|
||||||
secondaryTimer: state.timer.secondaryTimer,
|
|
||||||
expectedFinish: state.timer.expectedFinish,
|
|
||||||
absolute: state.offset.absolute,
|
|
||||||
relative: state.offset.relative,
|
|
||||||
mode: state.offset.mode,
|
|
||||||
expectedGroupEnd: state.offset.expectedGroupEnd,
|
|
||||||
expectedFlagStart: state.offset.expectedFlagStart,
|
|
||||||
expectedRundownEnd: state.offset.expectedRundownEnd,
|
|
||||||
plannedStart: state.rundown.plannedStart,
|
|
||||||
plannedEnd: state.rundown.plannedEnd,
|
|
||||||
actualStart: state.rundown.actualStart,
|
|
||||||
actualGroupStart: state.rundown.actualGroupStart,
|
|
||||||
currentDay: state.rundown.currentDay,
|
|
||||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
|
||||||
eventNow: state.eventNow?.id ?? null,
|
|
||||||
eventNext: state.eventNext?.id ?? null,
|
|
||||||
eventFlag: state.eventFlag?.id ?? null,
|
|
||||||
groupNow: state.groupNow?.id ?? null,
|
|
||||||
pausedAt: state._timer.pausedAt,
|
|
||||||
secondaryTarget: state._timer.secondaryTarget,
|
|
||||||
hasFinished: state._timer.hasFinished,
|
|
||||||
startDayOffset: state._startDayOffset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Scenario = {
|
|
||||||
/** the processed rundown, after going through the rundown cache */
|
|
||||||
rundown: Rundown;
|
|
||||||
metadata: RundownMetadata;
|
|
||||||
load: (eventId: EntryId) => boolean;
|
|
||||||
start: () => boolean;
|
|
||||||
pause: () => boolean;
|
|
||||||
stop: () => boolean;
|
|
||||||
addTime: (amount: number) => boolean;
|
|
||||||
/** calls roll, optionally passing the current offset for roll-continuation */
|
|
||||||
roll: (keepOffset?: boolean) => { eventId: MaybeString; didStart: boolean };
|
|
||||||
setOffsetMode: (mode: OffsetMode) => void;
|
|
||||||
updateRundownData: (data: Parameters<typeof updateRundownData>[0]) => void;
|
|
||||||
/**
|
|
||||||
* emulates a rundown edit while playback continues: re-processes the
|
|
||||||
* rundown through the cache and hot-reloads the loaded events, as the
|
|
||||||
* rundown service does on a committed mutation
|
|
||||||
*/
|
|
||||||
hotReload: (newRundown: Rundown) => void;
|
|
||||||
/** re-arms the loaded event, resetting timer progress, as used by reload() */
|
|
||||||
reloadLoaded: () => string | undefined;
|
|
||||||
/**
|
|
||||||
* advances fake time and calls update() in chunks, exactly as EventTimer would
|
|
||||||
* @param ms total time to advance
|
|
||||||
* @param stepMs chunk size, defaults to the production update rate (32ms)
|
|
||||||
* @returns the result of every update call, so tests can assert on finish flags
|
|
||||||
*/
|
|
||||||
tick: (ms?: number, stepMs?: number) => UpdateResult[];
|
|
||||||
/** jumps the wall clock to an absolute time and runs a single update */
|
|
||||||
setTime: (time: string) => UpdateResult;
|
|
||||||
digest: () => ReturnType<typeof digest>;
|
|
||||||
state: () => Readonly<RuntimeState>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a test scenario around the real runtimeState singleton
|
|
||||||
* - initialises the rundown through the production rundown service
|
|
||||||
* - resets playback state and offset mode between scenarios
|
|
||||||
*
|
|
||||||
* Requires vi.useFakeTimers() to be active
|
|
||||||
*/
|
|
||||||
export async function createScenario(initialRundown: Rundown, startTime: string): Promise<Scenario> {
|
|
||||||
vi.setSystemTime(startTime);
|
|
||||||
clearState();
|
|
||||||
// clearState intentionally preserves offset mode, reset it for test isolation
|
|
||||||
setOffsetMode(OffsetMode.Absolute);
|
|
||||||
|
|
||||||
await initRundown(initialRundown, {});
|
|
||||||
// flush the debounced rundown processing
|
|
||||||
vi.runAllTimers();
|
|
||||||
|
|
||||||
const { rundown, metadata } = rundownCache.get();
|
|
||||||
|
|
||||||
const getPlayableEvent = (eventId: EntryId): PlayableEvent => {
|
|
||||||
const event = rundown.entries[eventId];
|
|
||||||
if (event === undefined) {
|
|
||||||
throw new Error(`Test scenario: event ${eventId} not found in rundown`);
|
|
||||||
}
|
|
||||||
return event as PlayableEvent;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
rundown,
|
|
||||||
metadata,
|
|
||||||
load: (eventId) => load(getPlayableEvent(eventId), rundown, metadata),
|
|
||||||
start,
|
|
||||||
pause,
|
|
||||||
stop,
|
|
||||||
addTime,
|
|
||||||
roll: (keepOffset) => roll(rundown, metadata, keepOffset ? getState().offset : undefined),
|
|
||||||
setOffsetMode,
|
|
||||||
updateRundownData,
|
|
||||||
hotReload: (newRundown) => {
|
|
||||||
rundownCache.init(newRundown, {});
|
|
||||||
const { rundown: processedRundown, metadata: processedMetadata } = rundownCache.get();
|
|
||||||
updateAll(processedRundown, processedMetadata);
|
|
||||||
},
|
|
||||||
reloadLoaded: () => updateLoaded(),
|
|
||||||
tick: (ms = timerConfig.updateRate, stepMs = timerConfig.updateRate) => {
|
|
||||||
const results: UpdateResult[] = [];
|
|
||||||
let remaining = ms;
|
|
||||||
while (remaining > 0) {
|
|
||||||
const step = Math.min(stepMs, remaining);
|
|
||||||
vi.advanceTimersByTime(step);
|
|
||||||
results.push(update());
|
|
||||||
remaining -= step;
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
setTime: (time) => {
|
|
||||||
vi.setSystemTime(time);
|
|
||||||
return update();
|
|
||||||
},
|
|
||||||
digest,
|
|
||||||
state: getState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to assert on the update results collected during a tick
|
|
||||||
*/
|
|
||||||
export function countFinishes(results: UpdateResult[]) {
|
|
||||||
return {
|
|
||||||
timerFinished: results.filter((result) => result.hasTimerFinished).length,
|
|
||||||
secondaryFinished: results.filter((result) => result.hasSecondaryTimerFinished).length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
/**
|
|
||||||
* Characterisation tests for the runtimeState machinery
|
|
||||||
*
|
|
||||||
* These tests lock down the CURRENT behaviour of the runtime state hot path:
|
|
||||||
* playback transitions, tick updates, absolute and relative offsets,
|
|
||||||
* expected times (rundown end, group end, flag start) and roll mode.
|
|
||||||
*
|
|
||||||
* They drive the real runtimeState singleton through scripted scenarios
|
|
||||||
* with fake timers, exactly as the EventTimer would in production.
|
|
||||||
*
|
|
||||||
* !!! These tests are a behavioural baseline for refactors: if one of these
|
|
||||||
* assertions fails, production behaviour has changed !!!
|
|
||||||
*/
|
|
||||||
import { OffsetMode, Playback, TimerPhase } from 'ontime-types';
|
|
||||||
import { dayInMs } from 'ontime-utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
makeCountToEndRundown,
|
|
||||||
makeDelayedRundown,
|
|
||||||
makeFlatRundown,
|
|
||||||
makeGroupedRundown,
|
|
||||||
makeOvernightRundown,
|
|
||||||
time,
|
|
||||||
} from './harness/scenario.fixtures.js';
|
|
||||||
import { countFinishes, createScenario } from './harness/scenario.utils.js';
|
|
||||||
|
|
||||||
const { h, m } = time;
|
|
||||||
|
|
||||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
|
||||||
return {
|
|
||||||
getDataProvider: vi.fn().mockImplementation(() => {
|
|
||||||
return {
|
|
||||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('characterisation: loading and rundown data', () => {
|
|
||||||
test('initialising a rundown populates planned times', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:00');
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Stop,
|
|
||||||
plannedStart: 10 * h,
|
|
||||||
plannedEnd: 11 * h,
|
|
||||||
absolute: 0,
|
|
||||||
relative: 0,
|
|
||||||
expectedRundownEnd: null,
|
|
||||||
expectedFlagStart: null,
|
|
||||||
expectedGroupEnd: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('loading an event arms the timer and resolves now/next/flag', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
|
||||||
scenario.load('flat1');
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Armed,
|
|
||||||
eventNow: 'flat1',
|
|
||||||
eventNext: 'flat2',
|
|
||||||
// the loaded event cannot be the flag, so the first flag after flat1 is flat2
|
|
||||||
eventFlag: 'flat2',
|
|
||||||
groupNow: null,
|
|
||||||
current: 10 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
selectedEventIndex: 0,
|
|
||||||
actualStart: null,
|
|
||||||
currentDay: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setOffsetMode while stopped only changes the mode', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
|
||||||
scenario.setOffsetMode(OffsetMode.Relative);
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
mode: OffsetMode.Relative,
|
|
||||||
expectedRundownEnd: null,
|
|
||||||
expectedFlagStart: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('characterisation: timed playback', () => {
|
|
||||||
test('on-time start ticks through phases and finishes exactly once', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
|
||||||
scenario.load('flat1');
|
|
||||||
|
|
||||||
scenario.setTime('jan 5 10:00');
|
|
||||||
expect(scenario.start()).toBe(true);
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Play,
|
|
||||||
phase: TimerPhase.Default,
|
|
||||||
startedAt: 10 * h,
|
|
||||||
actualStart: 10 * h,
|
|
||||||
currentDay: 0,
|
|
||||||
startDayOffset: 0,
|
|
||||||
current: 10 * m,
|
|
||||||
expectedFinish: 10 * h + 10 * m,
|
|
||||||
absolute: 0,
|
|
||||||
relative: 0,
|
|
||||||
// flag (flat2) is linked to the loaded event: it follows the offset directly
|
|
||||||
expectedFlagStart: 10 * h + 10 * m,
|
|
||||||
// rundown ends on schedule
|
|
||||||
expectedRundownEnd: 11 * h,
|
|
||||||
expectedGroupEnd: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// tick to the warning threshold (2min)
|
|
||||||
let results = scenario.tick(8 * m, 1000);
|
|
||||||
expect(countFinishes(results).timerFinished).toBe(0);
|
|
||||||
expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Warning, current: 2 * m });
|
|
||||||
|
|
||||||
// tick to the danger threshold (1min)
|
|
||||||
results = scenario.tick(1 * m, 1000);
|
|
||||||
expect(countFinishes(results).timerFinished).toBe(0);
|
|
||||||
expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Danger, current: 1 * m });
|
|
||||||
|
|
||||||
// tick to the end: the finish flag is raised exactly once, when current <= triggerAhead
|
|
||||||
results = scenario.tick(1 * m, 1000);
|
|
||||||
expect(countFinishes(results).timerFinished).toBe(1);
|
|
||||||
expect(scenario.digest()).toMatchObject({ current: 0, hasFinished: true });
|
|
||||||
|
|
||||||
// continuing into overtime does not re-trigger the finish
|
|
||||||
results = scenario.tick(30 * 1000, 1000);
|
|
||||||
expect(countFinishes(results).timerFinished).toBe(0);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
phase: TimerPhase.Overtime,
|
|
||||||
current: -30 * 1000,
|
|
||||||
// overtime pushes the rundown behind schedule
|
|
||||||
absolute: 30 * 1000,
|
|
||||||
relative: 30 * 1000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('late start: absolute and relative offsets with gap compensation', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
|
||||||
scenario.load('flat1');
|
|
||||||
|
|
||||||
scenario.setTime('jan 5 10:05');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
startedAt: 10 * h + 5 * m,
|
|
||||||
actualStart: 10 * h + 5 * m,
|
|
||||||
// positive offset: we are 5min behind schedule
|
|
||||||
absolute: 5 * m,
|
|
||||||
// relative to our actual start, we are on time
|
|
||||||
relative: 0,
|
|
||||||
// flat2 is linked to the loaded event, so it follows the offset: 10:10 + 5min
|
|
||||||
expectedFlagStart: 10 * h + 15 * m,
|
|
||||||
// the accumulated gap (20min) is larger than the offset (5min): rundown still ends on schedule
|
|
||||||
expectedRundownEnd: 11 * h,
|
|
||||||
});
|
|
||||||
|
|
||||||
// switching to relative mode re-anchors the schedule to the actual start
|
|
||||||
scenario.setOffsetMode(OffsetMode.Relative);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
mode: OffsetMode.Relative,
|
|
||||||
absolute: 5 * m,
|
|
||||||
relative: 0,
|
|
||||||
// linked flag: same wall time as in absolute mode
|
|
||||||
expectedFlagStart: 10 * h + 15 * m,
|
|
||||||
// in relative mode the whole schedule shifts by the late start: gaps are kept, not consumed
|
|
||||||
expectedRundownEnd: 11 * h + 5 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// full state checkpoint
|
|
||||||
expect(scenario.state()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('early start in relative mode', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:50');
|
|
||||||
scenario.load('flat1');
|
|
||||||
|
|
||||||
scenario.setTime('jan 5 09:56');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
// negative offset: we are 4min ahead of schedule
|
|
||||||
absolute: -4 * m,
|
|
||||||
relative: 0,
|
|
||||||
// linked flag follows the offset
|
|
||||||
expectedFlagStart: 10 * h + 6 * m,
|
|
||||||
// ahead of schedule: unlinked events are still expected at their scheduled time
|
|
||||||
expectedRundownEnd: 11 * h,
|
|
||||||
});
|
|
||||||
|
|
||||||
scenario.setOffsetMode(OffsetMode.Relative);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
// the schedule shifts 4min earlier
|
|
||||||
expectedFlagStart: 10 * h + 6 * m,
|
|
||||||
expectedRundownEnd: 10 * h + 56 * m,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial gap compensation consumes the gap', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat2');
|
|
||||||
|
|
||||||
// start flat2 15min late (planned 10:10)
|
|
||||||
scenario.setTime('jan 5 10:25');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
eventNow: 'flat2',
|
|
||||||
eventNext: 'flat3',
|
|
||||||
// first flag after the loaded event is flat3
|
|
||||||
eventFlag: 'flat3',
|
|
||||||
absolute: 15 * m,
|
|
||||||
// relative to the actual start, flat2 began 10min early
|
|
||||||
// (the rundown started at flat2, skipping flat1's 10min)
|
|
||||||
relative: -10 * m,
|
|
||||||
// flag flat3: gap (10min) only partially compensates the offset (15min)
|
|
||||||
// expected start = scheduled (10:30) + offset (15min) - gap (10min) = 10:35
|
|
||||||
expectedFlagStart: 10 * h + 35 * m,
|
|
||||||
// rundown end: gap (20min) fully compensates the offset (15min)
|
|
||||||
expectedRundownEnd: 11 * h,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('addTime adjusts offsets, can force finish and un-finish', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat1');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
scenario.tick(1 * m, 1000);
|
|
||||||
expect(scenario.digest()).toMatchObject({ current: 9 * m });
|
|
||||||
|
|
||||||
// adding time puts us behind schedule
|
|
||||||
scenario.addTime(2 * m);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
addedTime: 2 * m,
|
|
||||||
current: 11 * m,
|
|
||||||
absolute: 2 * m,
|
|
||||||
relative: 2 * m,
|
|
||||||
expectedFinish: 10 * h + 12 * m,
|
|
||||||
expectedRundownEnd: 11 * h,
|
|
||||||
});
|
|
||||||
|
|
||||||
// removing more time than the timer has forces a finish
|
|
||||||
scenario.addTime(-12 * m);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
addedTime: -10 * m,
|
|
||||||
current: -1 * m,
|
|
||||||
hasFinished: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// the finish is reported on the next update
|
|
||||||
let results = scenario.tick();
|
|
||||||
expect(countFinishes(results).timerFinished).toBe(1);
|
|
||||||
expect(scenario.digest()).toMatchObject({ hasFinished: true, phase: TimerPhase.Overtime });
|
|
||||||
|
|
||||||
// !!! characterised quirk: the forced finish flag (_timer.forceFinish) is never
|
|
||||||
// cleared by update(), so every subsequent update keeps reporting a finished timer
|
|
||||||
results = scenario.tick();
|
|
||||||
expect(countFinishes(results).timerFinished).toBe(1);
|
|
||||||
|
|
||||||
// adding time back over zero un-finishes the timer
|
|
||||||
scenario.addTime(5 * m);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
addedTime: -5 * m,
|
|
||||||
hasFinished: false,
|
|
||||||
// net added time: we are now 5min ahead of schedule
|
|
||||||
absolute: -5 * m,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pause freezes the timer and accumulates paused time into the offset', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat1');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
scenario.tick(2 * m, 1000);
|
|
||||||
expect(scenario.pause()).toBe(true);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Pause,
|
|
||||||
pausedAt: 10 * h + 2 * m,
|
|
||||||
current: 8 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// while paused, the timer freezes but the offset grows with the pause
|
|
||||||
scenario.setTime('jan 5 10:04');
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
current: 8 * m,
|
|
||||||
absolute: 2 * m,
|
|
||||||
relative: 2 * m,
|
|
||||||
expectedFinish: 10 * h + 12 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// restarting folds the paused time into addedTime
|
|
||||||
expect(scenario.start()).toBe(true);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Play,
|
|
||||||
pausedAt: null,
|
|
||||||
addedTime: 2 * m,
|
|
||||||
current: 8 * m,
|
|
||||||
absolute: 2 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// pause then stop clears everything
|
|
||||||
scenario.pause();
|
|
||||||
expect(scenario.stop()).toBe(true);
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Stop,
|
|
||||||
eventNow: null,
|
|
||||||
current: null,
|
|
||||||
startedAt: null,
|
|
||||||
actualStart: null,
|
|
||||||
absolute: 0,
|
|
||||||
relative: 0,
|
|
||||||
expectedRundownEnd: null,
|
|
||||||
currentDay: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('countToEnd: absolute offset is overtime only', async () => {
|
|
||||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:35');
|
|
||||||
scenario.load('toEnd');
|
|
||||||
|
|
||||||
// start the count-to-end event 10min late
|
|
||||||
scenario.setTime('jan 5 10:40');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
// !!! characterised: start() does not recompute current, the value
|
|
||||||
// set at load time (11:00 - 10:35) sticks until the next update()
|
|
||||||
current: 25 * m,
|
|
||||||
expectedFinish: 11 * h,
|
|
||||||
// count-to-end absorbs the late start: absolute offset is overtime only
|
|
||||||
absolute: 0,
|
|
||||||
// the relative offset still reflects the raw event start offset
|
|
||||||
relative: -30 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// the next update recalculates current against the scheduled end
|
|
||||||
scenario.tick(1000, 1000);
|
|
||||||
expect(scenario.digest()).toMatchObject({ current: 20 * m - 1000 });
|
|
||||||
|
|
||||||
// going into overtime
|
|
||||||
scenario.setTime('jan 5 11:05');
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
current: -5 * m,
|
|
||||||
phase: TimerPhase.Overtime,
|
|
||||||
absolute: 5 * m,
|
|
||||||
relative: -25 * m,
|
|
||||||
expectedFinish: 11 * h,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('group: expectedGroupEnd and actualGroupStart', async () => {
|
|
||||||
const scenario = await createScenario(makeGroupedRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('grouped1');
|
|
||||||
|
|
||||||
scenario.setTime('jan 5 10:05');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
groupNow: 'group',
|
|
||||||
actualGroupStart: 10 * h + 5 * m,
|
|
||||||
absolute: 5 * m,
|
|
||||||
// group end: grouped2 is not linked and has no gap to absorb the 5min offset
|
|
||||||
// expected start = 10:30 + 5min, group ends 30min later
|
|
||||||
expectedGroupEnd: 11 * h + 5 * m,
|
|
||||||
// rundown end: the 30min gap before 'after' absorbs the offset
|
|
||||||
expectedRundownEnd: 12 * h,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateRundownData recomputes expected times while playing', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat1');
|
|
||||||
scenario.setTime('jan 5 10:05');
|
|
||||||
scenario.start();
|
|
||||||
scenario.setOffsetMode(OffsetMode.Relative);
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({ expectedRundownEnd: 11 * h + 5 * m });
|
|
||||||
|
|
||||||
// a rundown edit moves the planned start earlier
|
|
||||||
scenario.updateRundownData({
|
|
||||||
numEvents: 4,
|
|
||||||
firstStart: 9 * h + 30 * m,
|
|
||||||
lastEnd: 10 * h + 30 * m,
|
|
||||||
totalDelay: 0,
|
|
||||||
totalDuration: 1 * h,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
plannedStart: 9 * h + 30 * m,
|
|
||||||
plannedEnd: 10 * h + 30 * m,
|
|
||||||
// in relative mode the expected end shifts with the (actualStart - plannedStart) delta
|
|
||||||
expectedRundownEnd: 11 * h + 35 * m,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateRundownData while stopped does not produce expected times', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
|
|
||||||
scenario.updateRundownData({
|
|
||||||
numEvents: 4,
|
|
||||||
firstStart: 9 * h + 30 * m,
|
|
||||||
lastEnd: 10 * h + 30 * m,
|
|
||||||
totalDelay: 0,
|
|
||||||
totalDuration: 1 * h,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
plannedStart: 9 * h + 30 * m,
|
|
||||||
plannedEnd: 10 * h + 30 * m,
|
|
||||||
expectedRundownEnd: null,
|
|
||||||
absolute: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delay entries push the expected start of unlinked events', async () => {
|
|
||||||
const scenario = await createScenario(makeDelayedRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('delayed1');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
absolute: 0,
|
|
||||||
// delayed2 is delayed by 5min: expected start 10:25, ends 10min later
|
|
||||||
expectedRundownEnd: 10 * h + 35 * m,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('characterisation: hot reload during playback', () => {
|
|
||||||
test('editing the running event recomputes the timer without interrupting playback', async () => {
|
|
||||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('lead');
|
|
||||||
scenario.start();
|
|
||||||
scenario.tick(2 * m, 1000);
|
|
||||||
|
|
||||||
// the running event (10:00 - 10:30) is shortened to 10:00 - 10:20
|
|
||||||
const editedRundown = makeCountToEndRundown();
|
|
||||||
// @ts-expect-error -- fixture entries are events
|
|
||||||
editedRundown.entries.lead.timeEnd = 10 * h + 20 * m;
|
|
||||||
// @ts-expect-error -- fixture entries are events
|
|
||||||
editedRundown.entries.lead.duration = 20 * m;
|
|
||||||
scenario.hotReload(editedRundown);
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
eventNow: 'lead',
|
|
||||||
// playback is not interrupted
|
|
||||||
playback: Playback.Play,
|
|
||||||
startedAt: 10 * h,
|
|
||||||
actualStart: 10 * h,
|
|
||||||
// the timer is recomputed against the new duration
|
|
||||||
duration: 20 * m,
|
|
||||||
current: 18 * m,
|
|
||||||
expectedFinish: 10 * h + 20 * m,
|
|
||||||
// expected times are recomputed: the 10min gap before toEnd absorbs
|
|
||||||
// nothing (offset 0), the rundown still ends on schedule
|
|
||||||
expectedRundownEnd: 11 * h,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('removing the running event slides the selection to the event at the same index', async () => {
|
|
||||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('lead');
|
|
||||||
scenario.start();
|
|
||||||
scenario.tick(2 * m, 1000);
|
|
||||||
|
|
||||||
// the running event is deleted from the rundown
|
|
||||||
const editedRundown = makeCountToEndRundown();
|
|
||||||
delete editedRundown.entries.lead;
|
|
||||||
editedRundown.order = ['toEnd'];
|
|
||||||
scenario.hotReload(editedRundown);
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
// the event at the previously selected index is loaded in its place
|
|
||||||
eventNow: 'toEnd',
|
|
||||||
eventNext: null,
|
|
||||||
selectedEventIndex: 0,
|
|
||||||
playback: Playback.Play,
|
|
||||||
// !!! characterised: the original start time is kept, the replacement
|
|
||||||
// event plays as if it had started with the removed event
|
|
||||||
startedAt: 10 * h,
|
|
||||||
actualStart: 10 * h,
|
|
||||||
duration: 30 * m,
|
|
||||||
// toEnd counts to its scheduled end: 11:00 - 10:02
|
|
||||||
current: 58 * m,
|
|
||||||
expectedFinish: 11 * h,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reloading the loaded event re-arms the timer and clears progress', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat1');
|
|
||||||
scenario.start();
|
|
||||||
scenario.tick(2 * m, 1000);
|
|
||||||
scenario.addTime(1 * m);
|
|
||||||
|
|
||||||
const reloadedId = scenario.reloadLoaded();
|
|
||||||
|
|
||||||
expect(reloadedId).toBe('flat1');
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
eventNow: 'flat1',
|
|
||||||
playback: Playback.Armed,
|
|
||||||
startedAt: null,
|
|
||||||
addedTime: 0,
|
|
||||||
pausedAt: null,
|
|
||||||
current: 10 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
elapsed: null,
|
|
||||||
expectedFinish: null,
|
|
||||||
hasFinished: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('characterisation: playback across midnight', () => {
|
|
||||||
test('playing across midnight increments currentDay and keeps offsets', async () => {
|
|
||||||
const scenario = await createScenario(makeOvernightRundown(), 'jan 5 23:30');
|
|
||||||
scenario.load('night2');
|
|
||||||
|
|
||||||
scenario.setTime('jan 5 23:35');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
startedAt: 23 * h + 35 * m,
|
|
||||||
actualStart: 23 * h + 35 * m,
|
|
||||||
currentDay: 0,
|
|
||||||
absolute: 5 * m,
|
|
||||||
// relative offset re-anchors to the rundown start (planned 22:00, actual 23:35)
|
|
||||||
relative: -90 * m,
|
|
||||||
// before midnight, next-day events are normalised over 24h:
|
|
||||||
// night3 expected start = 24:30 + 5min offset, ends 30min later
|
|
||||||
expectedRundownEnd: 25 * h + 5 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// cross midnight in 1min steps
|
|
||||||
scenario.tick(35 * m, 1 * m);
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
clock: 10 * m,
|
|
||||||
currentDay: 1,
|
|
||||||
// started 23:35 with 60min duration: ends 00:35, 25min left at 00:10
|
|
||||||
current: 25 * m,
|
|
||||||
absolute: 5 * m,
|
|
||||||
relative: -90 * m,
|
|
||||||
// after midnight the same expected end is expressed in today's time
|
|
||||||
expectedRundownEnd: 1 * h + 5 * m,
|
|
||||||
});
|
|
||||||
|
|
||||||
// full state checkpoint
|
|
||||||
expect(scenario.state()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('characterisation: roll mode', () => {
|
|
||||||
test('roll: pending then start on schedule', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:55');
|
|
||||||
|
|
||||||
const result = scenario.roll();
|
|
||||||
expect(result).toStrictEqual({ eventId: 'flat1', didStart: false });
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Roll,
|
|
||||||
phase: TimerPhase.Pending,
|
|
||||||
secondaryTimer: 5 * m,
|
|
||||||
secondaryTarget: 10 * h,
|
|
||||||
actualStart: null,
|
|
||||||
currentDay: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// the secondary timer counts down to the event start
|
|
||||||
const results = scenario.tick(5 * m, 1000);
|
|
||||||
expect(countFinishes(results).secondaryFinished).toBe(1);
|
|
||||||
expect(scenario.digest()).toMatchObject({ secondaryTimer: 0 });
|
|
||||||
|
|
||||||
// the runtime service reacts to the secondary timer finishing by calling roll again
|
|
||||||
const startResult = scenario.roll();
|
|
||||||
expect(startResult).toStrictEqual({ eventId: 'flat1', didStart: true });
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Roll,
|
|
||||||
startedAt: 10 * h,
|
|
||||||
actualStart: 10 * h,
|
|
||||||
currentDay: 0,
|
|
||||||
absolute: 0,
|
|
||||||
secondaryTimer: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roll: takeover from play keeps the running state', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat1');
|
|
||||||
scenario.setTime('jan 5 10:05');
|
|
||||||
scenario.start();
|
|
||||||
|
|
||||||
const result = scenario.roll();
|
|
||||||
expect(result).toStrictEqual({ eventId: 'flat1', didStart: false });
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
playback: Playback.Roll,
|
|
||||||
startedAt: 10 * h + 5 * m,
|
|
||||||
absolute: 5 * m,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roll: continuation carries the offset to the next event', async () => {
|
|
||||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
|
||||||
scenario.load('flat1');
|
|
||||||
scenario.setTime('jan 5 10:05');
|
|
||||||
scenario.start();
|
|
||||||
scenario.roll();
|
|
||||||
|
|
||||||
// flat1 (started 10:05, 10min duration) is finished at 10:16
|
|
||||||
// the update pushes flat1 1min into overtime, growing the offset to 6min
|
|
||||||
scenario.setTime('jan 5 10:16');
|
|
||||||
expect(scenario.digest()).toMatchObject({ absolute: 6 * m });
|
|
||||||
|
|
||||||
// the runtime service rolls into the next event passing the current offset
|
|
||||||
const result = scenario.roll(true);
|
|
||||||
// with a 6min offset, the offset-clock is 10:10 which is inside flat2 (10:10-10:20)
|
|
||||||
expect(result).toStrictEqual({ eventId: 'flat2', didStart: true });
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
eventNow: 'flat2',
|
|
||||||
// start times are backdated to the planned start
|
|
||||||
startedAt: 10 * h + 10 * m,
|
|
||||||
// the offset passed by the service is kept
|
|
||||||
absolute: 6 * m,
|
|
||||||
// !!! characterised: unlike the pending-roll branch, the roll-continuation
|
|
||||||
// branch overwrites actualStart with the new event's planned start
|
|
||||||
actualStart: 10 * h + 10 * m,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roll into overnight event after midnight backdates start metadata', async () => {
|
|
||||||
const scenario = await createScenario(makeOvernightRundown(), 'jan 6 00:05');
|
|
||||||
|
|
||||||
const result = scenario.roll();
|
|
||||||
expect(result).toStrictEqual({ eventId: 'night2', didStart: true });
|
|
||||||
|
|
||||||
scenario.tick();
|
|
||||||
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
eventNow: 'night2',
|
|
||||||
// started mid-event: times are backdated to the planned start
|
|
||||||
startedAt: 23 * h + 30 * m,
|
|
||||||
actualStart: 23 * h + 30 * m,
|
|
||||||
startDayOffset: 0,
|
|
||||||
// the backdated epoch is yesterday: we are on day 1
|
|
||||||
currentDay: 1,
|
|
||||||
absolute: 0,
|
|
||||||
// night3 is expected on schedule
|
|
||||||
expectedRundownEnd: 1 * h,
|
|
||||||
});
|
|
||||||
|
|
||||||
// full state checkpoint
|
|
||||||
expect(scenario.state()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roll: pending across midnight renormalises the secondary target', async () => {
|
|
||||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 23:50');
|
|
||||||
|
|
||||||
// all events are in the past: roll pends for tomorrow's first event (10:00)
|
|
||||||
const result = scenario.roll();
|
|
||||||
expect(result).toStrictEqual({ eventId: 'lead', didStart: false });
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
phase: TimerPhase.Pending,
|
|
||||||
// 10h10min until tomorrow 10:00, target normalised over 24h
|
|
||||||
secondaryTimer: 10 * h + 10 * m,
|
|
||||||
secondaryTarget: dayInMs + 10 * h,
|
|
||||||
currentDay: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// crossing midnight renormalises the target to today's time
|
|
||||||
scenario.setTime('jan 6 00:10');
|
|
||||||
expect(scenario.digest()).toMatchObject({
|
|
||||||
secondaryTimer: 9 * h + 50 * m,
|
|
||||||
secondaryTarget: 10 * h,
|
|
||||||
currentDay: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
/**
|
|
||||||
* Direct tests for the parameterized runtimeState cores
|
|
||||||
*
|
|
||||||
* These run the calculation logic against plain state objects: no module
|
|
||||||
* mocks, no singleton, no rundown cache. They document the arithmetic of
|
|
||||||
* the tick, the added-time edge cases and the expected-times projection.
|
|
||||||
*/
|
|
||||||
import { OffsetMode, OntimeGroup, PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { makeOntimeEvent, makeOntimeGroup } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
|
||||||
import { makeRuntimeStateData } from '../__mocks__/runtimeState.mocks.js';
|
|
||||||
import { type RuntimeState, addTimeCore, getExpectedTimesCore, updateCore } from '../runtimeState.js';
|
|
||||||
|
|
||||||
const h = MILLIS_PER_HOUR;
|
|
||||||
const m = MILLIS_PER_MINUTE;
|
|
||||||
|
|
||||||
/** a 10min event, planned 10:00 - 10:10 */
|
|
||||||
function makeRunningEvent(): PlayableEvent {
|
|
||||||
return makeOntimeEvent({
|
|
||||||
id: 'running',
|
|
||||||
timeStart: 10 * h,
|
|
||||||
timeEnd: 10 * h + 10 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
gap: 0,
|
|
||||||
countToEnd: false,
|
|
||||||
}) as PlayableEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** state as it would be after starting the running event on time at 10:00 */
|
|
||||||
function makePlayingState(): RuntimeState {
|
|
||||||
return makeRuntimeStateData({
|
|
||||||
eventNow: makeRunningEvent(),
|
|
||||||
timer: {
|
|
||||||
playback: Playback.Play,
|
|
||||||
duration: 10 * m,
|
|
||||||
current: 10 * m,
|
|
||||||
elapsed: 0,
|
|
||||||
startedAt: 10 * h,
|
|
||||||
expectedFinish: 10 * h + 10 * m,
|
|
||||||
},
|
|
||||||
rundown: {
|
|
||||||
selectedEventIndex: 0,
|
|
||||||
numEvents: 1,
|
|
||||||
plannedStart: 10 * h,
|
|
||||||
plannedEnd: 10 * h + 10 * m,
|
|
||||||
actualStart: 10 * h,
|
|
||||||
currentDay: 0,
|
|
||||||
},
|
|
||||||
_startDayOffset: 0,
|
|
||||||
_startEpoch: new Date('2026-01-05T10:00:00').getTime(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('updateCore()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recomputes the timer against the wall clock', () => {
|
|
||||||
const state = makePlayingState();
|
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
|
|
||||||
const result = updateCore(state);
|
|
||||||
|
|
||||||
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
|
|
||||||
expect(state.timer.current).toBe(6 * m);
|
|
||||||
expect(state.timer.elapsed).toBe(4 * m);
|
|
||||||
expect(state.timer.expectedFinish).toBe(10 * h + 10 * m);
|
|
||||||
expect(state.offset.absolute).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the finish triggers once, when current enters the trigger-ahead window (10ms)', () => {
|
|
||||||
const state = makePlayingState();
|
|
||||||
|
|
||||||
// 11ms left on the timer: not finished yet
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:09:59.989'));
|
|
||||||
expect(updateCore(state).hasTimerFinished).toBe(false);
|
|
||||||
expect(state._timer.hasFinished).toBe(false);
|
|
||||||
|
|
||||||
// 10ms left: the finish is triggered and latched
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:09:59.990'));
|
|
||||||
expect(updateCore(state).hasTimerFinished).toBe(true);
|
|
||||||
expect(state._timer.hasFinished).toBe(true);
|
|
||||||
|
|
||||||
// subsequent updates do not re-trigger, the timer runs into overtime
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:10:30'));
|
|
||||||
expect(updateCore(state).hasTimerFinished).toBe(false);
|
|
||||||
expect(state.timer.current).toBe(-30_000);
|
|
||||||
expect(state.timer.phase).toBe(TimerPhase.Overtime);
|
|
||||||
// overtime pushes the rundown behind schedule
|
|
||||||
expect(state.offset.absolute).toBe(30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a pending forceFinish reports the timer as finished', () => {
|
|
||||||
const state = makePlayingState();
|
|
||||||
state._timer.forceFinish = 10 * h + 2 * m;
|
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:02:00'));
|
|
||||||
expect(updateCore(state).hasTimerFinished).toBe(true);
|
|
||||||
expect(state._timer.hasFinished).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does nothing but update the clock when playback is idle', () => {
|
|
||||||
const state = makeRuntimeStateData();
|
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
|
|
||||||
const result = updateCore(state);
|
|
||||||
|
|
||||||
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
|
|
||||||
expect(state.clock).toBe(10 * h + 4 * m);
|
|
||||||
expect(state.timer.current).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addTimeCore()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date('2026-01-05T10:05:00'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('added time moves the offset and the expected finish', () => {
|
|
||||||
const state = makePlayingState();
|
|
||||||
state.timer.current = 5 * m; // 10:05, halfway through
|
|
||||||
|
|
||||||
expect(addTimeCore(state, 2 * m)).toBe(true);
|
|
||||||
|
|
||||||
expect(state.timer.addedTime).toBe(2 * m);
|
|
||||||
expect(state.timer.current).toBe(7 * m);
|
|
||||||
// positive offset: we are now 2min behind schedule
|
|
||||||
expect(state.offset.absolute).toBe(2 * m);
|
|
||||||
expect(state.timer.expectedFinish).toBe(10 * h + 12 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('removing more time than remains forces a finish', () => {
|
|
||||||
const state = makePlayingState();
|
|
||||||
state.timer.current = 5 * m;
|
|
||||||
|
|
||||||
addTimeCore(state, -8 * m);
|
|
||||||
|
|
||||||
// the force flag is raised for the next update to report
|
|
||||||
expect(state._timer.forceFinish).not.toBeNull();
|
|
||||||
expect(state.timer.current).toBe(-3 * m);
|
|
||||||
// 3min overtime minus 8min removed: 5min ahead of schedule
|
|
||||||
expect(state.offset.absolute).toBe(-5 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('adding time back over zero un-finishes the timer', () => {
|
|
||||||
const state = makePlayingState();
|
|
||||||
state.timer.current = -1 * m;
|
|
||||||
state.timer.addedTime = -6 * m;
|
|
||||||
state._timer.hasFinished = true;
|
|
||||||
|
|
||||||
addTimeCore(state, 5 * m);
|
|
||||||
|
|
||||||
expect(state.timer.current).toBe(4 * m);
|
|
||||||
expect(state._timer.hasFinished).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refuses when there is no timer', () => {
|
|
||||||
const state = makeRuntimeStateData();
|
|
||||||
expect(addTimeCore(state, 1 * m)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getExpectedTimesCore()', () => {
|
|
||||||
/** a downstream 10min event, planned 11:00 - 11:10 */
|
|
||||||
function makeDownstreamEvent(patch?: Record<string, unknown>) {
|
|
||||||
return makeOntimeEvent({
|
|
||||||
id: 'downstream',
|
|
||||||
timeStart: 11 * h,
|
|
||||||
timeEnd: 11 * h + 10 * m,
|
|
||||||
duration: 10 * m,
|
|
||||||
dayOffset: 0,
|
|
||||||
delay: 0,
|
|
||||||
...patch,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* playing state with a downstream rundown end
|
|
||||||
* @param offset current absolute offset (positive = behind schedule)
|
|
||||||
* @param accumulatedGap total gap between the running and the downstream event
|
|
||||||
* @param isLinkedToLoaded whether the downstream event links back to the running one
|
|
||||||
*/
|
|
||||||
function makeStateWithEnd(args: {
|
|
||||||
offset: number;
|
|
||||||
accumulatedGap: number;
|
|
||||||
isLinkedToLoaded: boolean;
|
|
||||||
mode?: OffsetMode;
|
|
||||||
actualStart?: number;
|
|
||||||
downstream?: ReturnType<typeof makeOntimeEvent>;
|
|
||||||
}): RuntimeState {
|
|
||||||
const state = makePlayingState();
|
|
||||||
state.offset.absolute = args.offset;
|
|
||||||
state.offset.mode = args.mode ?? OffsetMode.Absolute;
|
|
||||||
if (args.actualStart !== undefined) state.rundown.actualStart = args.actualStart;
|
|
||||||
state._end = {
|
|
||||||
event: args.downstream ?? makeDownstreamEvent(),
|
|
||||||
accumulatedGap: args.accumulatedGap,
|
|
||||||
isLinkedToLoaded: args.isLinkedToLoaded,
|
|
||||||
};
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('on schedule: the rundown ends at the planned time', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: 0, accumulatedGap: 0, isLinkedToLoaded: false });
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// 11:00 + 10min duration
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('behind schedule: a larger gap absorbs the whole offset', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: false });
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// gap (20min) > offset (5min): the downstream event still starts as scheduled
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('behind schedule: a smaller gap absorbs part of the offset', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 2 * m, isLinkedToLoaded: false });
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// expected start 11:00 + 5min offset - 2min gap = 11:03, ends 10min later
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 13 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('behind schedule: linked events follow the offset, gaps do not apply', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: true });
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// expected start 11:00 + 5min offset = 11:05, ends 10min later
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ahead of schedule: unlinked events wait for their scheduled time', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: false });
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ahead of schedule: linked events pull in with the offset', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: true });
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// expected start 10:56, ends 10min later
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 6 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('relative mode: the schedule is re-anchored to the actual start', () => {
|
|
||||||
// started 5min late (actual 10:05, planned 10:00) with no accrued drift
|
|
||||||
const state = makeStateWithEnd({
|
|
||||||
offset: 0,
|
|
||||||
accumulatedGap: 20 * m,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
mode: OffsetMode.Relative,
|
|
||||||
actualStart: 10 * h + 5 * m,
|
|
||||||
});
|
|
||||||
state.offset.relative = 0;
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// the whole schedule shifts by the 5min late start: 11:05 + 10min
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delays push the expected start', () => {
|
|
||||||
const state = makeStateWithEnd({
|
|
||||||
offset: 0,
|
|
||||||
accumulatedGap: 10 * m,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
downstream: makeDownstreamEvent({ delay: 5 * m }),
|
|
||||||
});
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// delayed start 11:05, ends 10min later
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('events on a later day are normalised over 24h', () => {
|
|
||||||
const state = makeStateWithEnd({
|
|
||||||
offset: 0,
|
|
||||||
accumulatedGap: 0,
|
|
||||||
isLinkedToLoaded: false,
|
|
||||||
downstream: makeDownstreamEvent({ dayOffset: 1 }),
|
|
||||||
});
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
// tomorrow 11:00 relative to today: 24h + 11:00, ends 10min later
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(dayInMs + 11 * h + 10 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('flag start and group end use the same projection', () => {
|
|
||||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 0, isLinkedToLoaded: false });
|
|
||||||
const downstream = makeDownstreamEvent() as PlayableEvent;
|
|
||||||
|
|
||||||
state.eventFlag = downstream;
|
|
||||||
state._flag = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
|
|
||||||
state.groupNow = makeOntimeGroup({ id: 'group' }) as OntimeGroup;
|
|
||||||
state._group = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
|
|
||||||
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
|
|
||||||
// no gap to absorb the 5min offset: expected start 11:05
|
|
||||||
expect(state.offset.expectedFlagStart).toBe(11 * h + 5 * m);
|
|
||||||
// the group ends when its last event finishes: 11:05 + 10min
|
|
||||||
expect(state.offset.expectedGroupEnd).toBe(11 * h + 15 * m);
|
|
||||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('without a loaded event there are no expected times', () => {
|
|
||||||
const state = makeRuntimeStateData();
|
|
||||||
getExpectedTimesCore(state);
|
|
||||||
|
|
||||||
expect(state.offset.expectedRundownEnd).toBeNull();
|
|
||||||
expect(state.offset.expectedFlagStart).toBeNull();
|
|
||||||
expect(state.offset.expectedGroupEnd).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,12 +11,11 @@ import {
|
|||||||
clearState,
|
clearState,
|
||||||
getState,
|
getState,
|
||||||
load,
|
load,
|
||||||
loadGroupFlagAndEndCore,
|
loadGroupFlagAndEnd,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
roll,
|
roll,
|
||||||
start,
|
start,
|
||||||
startCore,
|
|
||||||
stop,
|
stop,
|
||||||
update,
|
update,
|
||||||
} from '../runtimeState.js';
|
} from '../runtimeState.js';
|
||||||
@@ -83,7 +82,7 @@ describe('mutation on runtimeState', () => {
|
|||||||
await initRundown(makeRundown({}), {});
|
await initRundown(makeRundown({}), {});
|
||||||
vi.runAllTimers();
|
vi.runAllTimers();
|
||||||
|
|
||||||
let success = startCore(mockState);
|
let success = start(mockState);
|
||||||
expect(success).toBe(false);
|
expect(success).toBe(false);
|
||||||
|
|
||||||
success = pause();
|
success = pause();
|
||||||
@@ -869,7 +868,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
|
|
||||||
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
|
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
|
||||||
|
|
||||||
loadGroupFlagAndEndCore(state, rundown, metadata, 2);
|
loadGroupFlagAndEnd(rundown, metadata, 2, state);
|
||||||
|
|
||||||
expect(state).toMatchObject({
|
expect(state).toMatchObject({
|
||||||
groupNow: rundown.entries[1],
|
groupNow: rundown.entries[1],
|
||||||
@@ -897,7 +896,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
|
|
||||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||||
|
|
||||||
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
|
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
||||||
|
|
||||||
expect(state).toMatchObject({
|
expect(state).toMatchObject({
|
||||||
groupNow: rundown.entries[2],
|
groupNow: rundown.entries[2],
|
||||||
@@ -925,7 +924,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
|
|
||||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||||
|
|
||||||
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
|
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
||||||
|
|
||||||
expect(state).toMatchObject({
|
expect(state).toMatchObject({
|
||||||
groupNow: null,
|
groupNow: null,
|
||||||
@@ -950,7 +949,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
|
|
||||||
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
|
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
|
||||||
|
|
||||||
loadGroupFlagAndEndCore(state, rundown, metadata, 0);
|
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||||
|
|
||||||
expect(state).toMatchObject({
|
expect(state).toMatchObject({
|
||||||
groupNow: null,
|
groupNow: null,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "4.10.0",
|
"version": "4.9.0",
|
||||||
"name": "ontime-types",
|
"name": "ontime-types",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type BaseURLPreset = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string;
|
alias: string;
|
||||||
search: string;
|
search: string;
|
||||||
displayInNav: boolean;
|
|
||||||
options?: Record<string, string>;
|
options?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +28,6 @@ type CuesheetUrlPreset = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string;
|
alias: string;
|
||||||
search: string;
|
search: string;
|
||||||
displayInNav: boolean;
|
|
||||||
options?: {
|
options?: {
|
||||||
read?: string;
|
read?: string;
|
||||||
write?: string;
|
write?: string;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const langEn = {
|
|||||||
'timeline.done': 'done',
|
'timeline.done': 'done',
|
||||||
'timeline.due': 'due',
|
'timeline.due': 'due',
|
||||||
'timeline.followedby': 'Followed by',
|
'timeline.followedby': 'Followed by',
|
||||||
'timeline.standby': 'Standby',
|
|
||||||
'project.title': 'Title',
|
'project.title': 'Title',
|
||||||
'project.description': 'Description',
|
'project.description': 'Description',
|
||||||
'project.info': 'Project Info',
|
'project.info': 'Project Info',
|
||||||
|
|||||||
Reference in New Issue
Block a user