Files
ontime/apps/server/src/api-data/db/migration/migration.test.ts
T
Claude 636b2e7c3e feat(rundown): add opt-in shared group timer
Groups can now opt into a shared timer, so stage outputs show how much time
the whole block has left instead of only the running event.

The value is derived in the client from the running event timer plus the
content still scheduled after it, which makes the group behave as if it were
a single event containing all its children. Keeping it relative to the event
timer (rather than recalculating from the clock) means pause, added time,
overtime, roll and midnight rollovers are all inherited, and moving between
events inside a group cannot introduce a discontinuity.

The remainder aggregation mirrors the server side group duration calculation:
non playable entries are skipped and gaps are accounted for.

Warning and danger thresholds belong to the event and carry no meaning against
a group duration, so they are suppressed while the group timer is showing.
Overtime is kept, but only once the group itself has run out of time.

- add OntimeGroup.useGroupTimer, defaulting to false
- show the group timer in the timer view and the PiP timer
- indicate the override in the timer view, PiP, group header and timer preview
- add a switch to the group editor

The PiP window mounts its own react root, which had no query client and could
therefore not read the rundown. It is now wrapped in a provider, which also
means its view settings are no longer frozen at the moment the window opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 06:24:15 +00:00

600 lines
16 KiB
TypeScript

import {
AutomationSettings,
CustomFields,
type Day,
DatabaseModel,
EndAction,
OntimeView,
ProjectData,
Rundown,
Settings,
SupportedEntry,
TimeStrategy,
TimerLifeCycle,
TimerType,
URLPreset,
ViewSettings,
} from 'ontime-types';
import * as v3 from './db.migration.v3.js';
import * as v4 from './db.migration.v4.js';
describe('v3 to v4', () => {
const oldDb = {
rundown: [
{
id: 'event1',
type: 'event',
cue: '123',
title: 'ABC',
note: 'DEF',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 10,
duration: 10,
isPublic: false,
skip: false,
colour: 'blue',
timeWarning: 5,
timeDanger: 2,
custom: {
song: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
doseNotExist: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
},
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
},
{ id: 'group0', type: 'block', title: 'GROUP 0' },
{
id: 'event2',
type: SupportedEntry.Event,
cue: '124',
title: 'ABC',
note: 'DEF',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 10,
duration: 10,
isPublic: false,
skip: false,
colour: 'blue',
timeWarning: 5,
timeDanger: 2,
custom: {
wow: 'http://www.agoodimage.com',
artist: 'Ib Andersen',
},
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
},
{
id: 'event3',
type: SupportedEntry.Event,
cue: '125',
// title: 'ABC', this has a missing field
note: 'DEF',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 10,
duration: 10,
isPublic: false,
skip: false,
colour: 'blue',
timeWarning: 5,
timeDanger: 2,
custom: {
wow: 'http://www.agoodimage.com',
artist: 'Ib Andersen',
},
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
},
{ id: 'group1', type: 'block', title: 'GROUP 1' },
{ id: 'delay', type: 'delay', duration: 1000 },
],
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
publicUrl: '123',
publicInfo: '456',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
},
settings: {
version: '3.3.3',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
overrideStyles: false,
normalColor: '#ffffffcc',
warningColor: '#ffa528',
dangerColor: '#ff7300',
freezeEnd: false,
endMessage: '',
},
urlPresets: [
{
enabled: true,
alias: 'clock',
pathAndParams:
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
{
enabled: true,
alias: 'minimal',
pathAndParams:
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
],
customFields: {
song: {
label: 'Song and Dance',
type: 'string',
colour: '#339E4E',
},
artist: {
label: 'Artist and Host',
type: 'string',
colour: '#3E75E8',
},
wow: {
label: 'WOW 123',
type: 'image',
colour: '#E80000',
},
},
automation: {
enabledAutomations: true,
enabledOscIn: true,
oscPortIn: 8888,
triggers: [],
automations: {},
},
};
test('migrate settings', () => {
const expectSettings: Settings & { serverPort: number } = {
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
};
const newSettings = v3.migrateSettings(oldDb);
expect(newSettings).toEqual(expectSettings);
});
test('migrate view settings', () => {
const expectViewSettings: ViewSettings = {
dangerColor: '#ff7300',
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#ffa528',
};
const newViewSettings = v3.migrateViewSettings(oldDb);
expect(newViewSettings).toEqual(expectViewSettings);
});
test('migrate url preset', () => {
const expectUrlPresets: URLPreset[] = [
{
enabled: true,
alias: 'clock',
target: OntimeView.Timer,
search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {},
},
{
enabled: true,
alias: 'minimal',
target: OntimeView.Timer,
search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {},
},
];
const newUrlPreset = v3.migrateURLPresets(oldDb);
expect(newUrlPreset).toEqual(expectUrlPresets);
});
test('migrate project data', () => {
const expectProjectData: ProjectData = {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
url: 'www.github.com/cpvalente/ontime',
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
logo: null,
custom: [],
};
const newProjectData = v3.migrateProjectData(oldDb);
expect(newProjectData).toEqual(expectProjectData);
});
test('migrate custom fields', () => {
const expectCustomFields: CustomFields = {
Song_and_Dance: {
label: 'Song and Dance',
type: 'text',
colour: '#339E4E',
},
Artist_and_Host: {
label: 'Artist and Host',
type: 'text',
colour: '#3E75E8',
},
WOW_123: {
label: 'WOW 123',
type: 'image',
colour: '#E80000',
},
};
const parsedData = v3.migrateCustomFields(oldDb);
expect(parsedData).not.toBeUndefined();
expect(parsedData?.customFields).toEqual(expectCustomFields);
expect(parsedData?.translationTable).toEqual(
new Map([
['song', 'Song_and_Dance'],
['artist', 'Artist_and_Host'],
['wow', 'WOW_123'],
]),
);
});
test('migrate rundown', () => {
const expectedRundown: Rundown = {
id: 'default',
title: 'Default',
order: ['event1', 'group0', 'group1'],
flatOrder: ['event1', 'group0', 'event2', 'event3', 'group1', 'delay'],
entries: {
event1: {
id: 'event1',
type: SupportedEntry.Event,
cue: '123',
title: 'ABC',
note: 'DEF',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 10,
duration: 10,
skip: false,
colour: 'blue',
timeWarning: 5,
timeDanger: 2,
custom: { Song_and_Dance: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' },
triggers: [],
revision: -1,
flag: false,
parent: null,
delay: 0,
dayOffset: 0 as Day,
gap: 0,
},
group0: {
id: 'group0',
type: SupportedEntry.Group,
title: 'GROUP 0',
colour: '',
custom: {},
duration: 0,
entries: ['event2', 'event3'],
isFirstLinked: false,
note: '',
revision: -1,
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
event2: {
id: 'event2',
type: SupportedEntry.Event,
cue: '124',
title: 'ABC',
note: 'DEF',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 10,
duration: 10,
skip: false,
colour: 'blue',
timeWarning: 5,
timeDanger: 2,
custom: {
WOW_123: 'http://www.agoodimage.com',
Artist_and_Host: 'Ib Andersen',
},
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
flag: false,
parent: 'group0',
revision: -1,
delay: 0,
dayOffset: 0 as Day,
gap: 0,
},
event3: {
id: 'event3',
type: SupportedEntry.Event,
cue: '125',
//@ts-expect-error - if a field is missing we should pass it through a let our normal import fix it
title: undefined,
note: 'DEF',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockDuration,
timeStart: 0,
timeEnd: 10,
duration: 10,
skip: false,
colour: 'blue',
timeWarning: 5,
timeDanger: 2,
custom: {
WOW_123: 'http://www.agoodimage.com',
Artist_and_Host: 'Ib Andersen',
},
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
flag: false,
parent: 'group0',
revision: -1,
delay: 0,
dayOffset: 0 as Day,
gap: 0,
},
group1: {
id: 'group1',
type: SupportedEntry.Group,
title: 'GROUP 1',
colour: '',
custom: {},
duration: 0,
entries: ['delay'],
isFirstLinked: false,
note: '',
revision: -1,
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
delay: {
type: SupportedEntry.Delay,
id: 'delay',
duration: 1000,
parent: 'group1',
},
},
revision: 0,
};
const translationTable = new Map([
['song', 'Song_and_Dance'],
['artist', 'Artist_and_Host'],
['wow', 'WOW_123'],
]);
//@ts-expect-error - we know the default rundown should appear
expect(v3.migrateRundown(oldDb, translationTable)['default']).toStrictEqual(expectedRundown);
});
describe('migrate old automation', () => {
test('osc', () => {
const oldData = {
portIn: 8881,
portOut: 55890,
targetIP: '127.0.0.1',
enabledIn: true,
enabledOut: true,
subscriptions: [
{
id: '23f4d8',
cycle: 'onClock',
address: '/test',
payload: 'bip',
enabled: true,
},
],
};
const expectedAutomation: AutomationSettings = {
enabledAutomations: true,
enabledOscIn: true,
oscPortIn: 8881,
triggers: [
{
id: '23f4d8-T',
title: 'Migrated Trigger 23f4d8',
trigger: TimerLifeCycle.onClock,
automationId: '23f4d8-A',
},
],
automations: {
'23f4d8-A': {
id: '23f4d8-A',
title: 'Migrated Automation 23f4d8',
filterRule: 'any',
filters: [],
outputs: [{ type: 'osc', targetIP: '127.0.0.1', targetPort: 55890, address: '/test', args: 'bip' }],
},
},
};
expect(v3.migrateAutomations({ osc: oldData })).toStrictEqual(expectedAutomation);
});
test('http', () => {
const oldData = {
enabledOut: true,
subscriptions: [
{
id: '1ge4r8',
cycle: 'onClock',
message: 'http://www.test.com',
enabled: true,
},
],
};
const expectedAutomation: AutomationSettings = {
enabledAutomations: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [
{
id: '1ge4r8-T',
title: 'Migrated Trigger 1ge4r8',
trigger: TimerLifeCycle.onClock,
automationId: '1ge4r8-A',
},
],
automations: {
'1ge4r8-A': {
id: '1ge4r8-A',
title: 'Migrated Automation 1ge4r8',
filterRule: 'any',
filters: [],
outputs: [{ type: 'http', url: 'http://www.test.com' }],
},
},
};
expect(v3.migrateAutomations({ http: oldData })).toStrictEqual(expectedAutomation);
});
});
});
describe('v4 remove server port', () => {
const demoDb = {
rundowns: {},
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
url: 'www.github.com/cpvalente/ontime',
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
logo: null,
custom: [],
},
settings: {
app: 'ontime',
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
dangerColor: '#ff7300',
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#ffa528',
},
customFields: {
song: {
label: 'Song',
type: 'text',
colour: '#339E4E',
},
artist: {
label: 'Artist',
type: 'text',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'clock',
target: 'timer',
search:
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
{
enabled: true,
alias: 'minimal',
target: 'timer',
search:
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
],
automation: {
enabledAutomations: false,
enabledOscIn: true,
oscPortIn: 8888,
triggers: [],
automations: {},
},
};
it('should migrate if server port exists', () => {
expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy();
});
test('should not migrate if the version is newer', () => {
expect(
v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel),
).toBeFalsy();
});
test('should not migrate if there is no server port', () => {
expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy();
});
it('remove server port from project', () => {
const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel);
expect(result).not.toHaveProperty('settings.serverPort');
expect(serverPort).toBe(4001);
expect(result.automation).toMatchObject(demoDb.automation);
expect(result.customFields).toMatchObject(demoDb.customFields);
expect(result.project).toMatchObject(demoDb.project);
expect(result.rundowns).toMatchObject(demoDb.rundowns);
expect(result.settings).toMatchObject({
app: 'ontime',
version: '4.5.0',
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
expect(result.urlPresets).toMatchObject(demoDb.urlPresets);
expect(result.viewSettings).toMatchObject(demoDb.viewSettings);
});
});