mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-17 03:53:06 +00:00
feat(automation): add composer
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Automation, ErrorResponse } from 'ontime-types';
|
||||
import type { Automation, AutomationComposition, ErrorResponse } from 'ontime-types';
|
||||
|
||||
import { editAutomation, postAutomation } from '../automation.controller.js';
|
||||
import { editAutomation, postAutomation, postAutomationComposition } from '../automation.controller.js';
|
||||
import * as automationDao from '../automation.dao.js';
|
||||
|
||||
vi.mock('../automation.dao.js', () => ({
|
||||
addAutomation: vi.fn(),
|
||||
createAutomationComposition: vi.fn(),
|
||||
editAutomation: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -28,6 +29,15 @@ describe('automation controllers', () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(automationDao.addAutomation).mockImplementation(async (automation) => ({ id: 'new-id', ...automation }));
|
||||
vi.mocked(automationDao.editAutomation).mockImplementation(async (id, automation) => ({ id, ...automation }));
|
||||
vi.mocked(automationDao.createAutomationComposition).mockImplementation(async ({ automation, lifecycles }) => ({
|
||||
automation: { id: 'new-id', ...automation },
|
||||
triggers: lifecycles.map((trigger, index) => ({
|
||||
id: String(index),
|
||||
title: `Global ${trigger}`,
|
||||
trigger,
|
||||
automationId: 'new-id',
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
it('persists normalized outputs when creating an automation', async () => {
|
||||
@@ -81,4 +91,19 @@ describe('automation controllers', () => {
|
||||
expect.objectContaining({ message: 'Automation definitions cannot include triggers' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a definition and global lifecycle bindings through the composition command', async () => {
|
||||
const response = makeResponse() as unknown as Response<AutomationComposition | ErrorResponse>;
|
||||
const request = {
|
||||
body: { automation: requestBody, lifecycles: ['onStart', 'onFinish'] },
|
||||
} as Request;
|
||||
|
||||
await postAutomationComposition(request, response);
|
||||
|
||||
expect(automationDao.createAutomationComposition).toHaveBeenCalledWith({
|
||||
automation: expect.objectContaining({ title: 'OSC definition' }),
|
||||
lifecycles: ['onStart', 'onFinish'],
|
||||
});
|
||||
expect(response.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Automation, AutomationDTO, ProjectRundowns, TimerLifeCycle, TriggerDTO
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
addAutomation,
|
||||
createAutomationComposition,
|
||||
addTrigger,
|
||||
deleteAll,
|
||||
deleteAllTriggers,
|
||||
@@ -137,6 +138,37 @@ describe('addAutomation()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAutomationComposition()', () => {
|
||||
beforeEach(async () => {
|
||||
await deleteAll();
|
||||
});
|
||||
|
||||
it('writes a definition and all requested global bindings together', async () => {
|
||||
const composition = await createAutomationComposition({
|
||||
automation: { title: 'New definition', filterRule: 'all', filters: [], outputs: [makeHTTPAction()] },
|
||||
lifecycles: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
|
||||
});
|
||||
|
||||
expect(getAutomations()[composition.automation.id]).toEqual(composition.automation);
|
||||
expect(getAutomationTriggers()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onStart }),
|
||||
expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onFinish }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes only a reusable definition when no lifecycle is selected', async () => {
|
||||
const composition = await createAutomationComposition({
|
||||
automation: { title: 'Reusable definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: [],
|
||||
});
|
||||
|
||||
expect(getAutomations()[composition.automation.id]).toEqual(composition.automation);
|
||||
expect(getAutomationTriggers()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAutomation()', () => {
|
||||
// saving the ID of the added automation
|
||||
let firstAutomation: Automation;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ProjectRundowns, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { isAutomationUsed, isHostname, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||
import {
|
||||
getAutomationUsage,
|
||||
isAutomationUsed,
|
||||
isHostname,
|
||||
parseTemplateNested,
|
||||
stringToOSCArgs,
|
||||
} from '../automation.utils.js';
|
||||
|
||||
describe('isHostname()', () => {
|
||||
it.each(['localhost', 'qlab', 'osc.example.com', 'osc-target.example'])('accepts %s', (hostname) => {
|
||||
@@ -367,3 +373,52 @@ describe('isAutomationUsed()', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutomationUsage()', () => {
|
||||
it('counts global and event bindings across multiple rundowns without counting missing references', () => {
|
||||
const usage = getAutomationUsage(
|
||||
{
|
||||
used: { id: 'used', title: 'Used', filterRule: 'all', filters: [], outputs: [] },
|
||||
unused: { id: 'unused', title: 'Unused', filterRule: 'all', filters: [], outputs: [] },
|
||||
},
|
||||
[
|
||||
{ id: 'global-1', title: 'Global', trigger: TimerLifeCycle.onStart, automationId: 'used' },
|
||||
{ id: 'global-missing', title: 'Missing', trigger: TimerLifeCycle.onStart, automationId: 'missing' },
|
||||
],
|
||||
{
|
||||
one: {
|
||||
id: 'one',
|
||||
title: 'One',
|
||||
order: ['event-1'],
|
||||
flatOrder: ['event-1'],
|
||||
entries: {
|
||||
'event-1': makeOntimeEvent({
|
||||
id: 'event-1',
|
||||
triggers: [
|
||||
{ id: 'event-trigger-1', title: 'Event', trigger: TimerLifeCycle.onFinish, automationId: 'used' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
two: {
|
||||
id: 'two',
|
||||
title: 'Two',
|
||||
order: ['event-2'],
|
||||
flatOrder: ['event-2'],
|
||||
entries: {
|
||||
'event-2': makeOntimeEvent({
|
||||
id: 'event-2',
|
||||
triggers: [
|
||||
{ id: 'event-trigger-2', title: 'Event', trigger: TimerLifeCycle.onFinish, automationId: 'used' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(usage).toEqual({ used: { global: 1, event: 2 }, unused: { global: 0, event: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseAutomation, parseOutput } from '../automation.validation.js';
|
||||
import { parseAutomation, parseAutomationComposition, parseOutput } from '../automation.validation.js';
|
||||
|
||||
describe('parseAutomation', () => {
|
||||
it('rejects trigger bindings from definition payloads', () => {
|
||||
@@ -8,6 +8,29 @@ describe('parseAutomation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAutomationComposition', () => {
|
||||
it('accepts a reusable definition without global lifecycles', () => {
|
||||
expect(
|
||||
parseAutomationComposition({
|
||||
automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: [],
|
||||
}),
|
||||
).toEqual({
|
||||
automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate global lifecycles', () => {
|
||||
expect(() =>
|
||||
parseAutomationComposition({
|
||||
automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: ['onStart', 'onStart'],
|
||||
}),
|
||||
).toThrow('Duplicate automation lifecycle: onStart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOutput', () => {
|
||||
describe('handles OSC outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
|
||||
import {
|
||||
Automation,
|
||||
AutomationComposition,
|
||||
AutomationSettings,
|
||||
AutomationUsage,
|
||||
ErrorResponse,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { oscServer } from '../../adapters/OscAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as automationDao from './automation.dao.js';
|
||||
import * as automationService from './automation.service.js';
|
||||
import { parseAutomation, parseOutput } from './automation.validation.js';
|
||||
import { getAutomationUsage } from './automation.utils.js';
|
||||
import { parseAutomation, parseAutomationComposition, parseOutput } from './automation.validation.js';
|
||||
|
||||
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
|
||||
res.status(200).json(automationDao.getAutomationSettings());
|
||||
}
|
||||
|
||||
export function getAutomationUsageCounts(_req: Request, res: Response<AutomationUsage>) {
|
||||
const settings = automationDao.getAutomationSettings();
|
||||
res
|
||||
.status(200)
|
||||
.json(getAutomationUsage(settings.automations, settings.triggers, getDataProvider().getProjectRundowns()));
|
||||
}
|
||||
|
||||
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
|
||||
try {
|
||||
// body payload is a patch object that must contain root properties
|
||||
@@ -84,6 +99,17 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
|
||||
}
|
||||
}
|
||||
|
||||
export async function postAutomationComposition(req: Request, res: Response<AutomationComposition | ErrorResponse>) {
|
||||
try {
|
||||
const composition = parseAutomationComposition(req.body);
|
||||
const created = await automationDao.createAutomationComposition(composition);
|
||||
res.status(201).send(created);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
try {
|
||||
const automation = parseAutomation(req.body);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationComposition,
|
||||
AutomationCompositionDTO,
|
||||
AutomationDTO,
|
||||
AutomationSettings,
|
||||
NormalisedAutomation,
|
||||
@@ -121,6 +123,37 @@ export async function addAutomation(newAutomation: AutomationDTO): Promise<Autom
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a definition and its global bindings with one settings write. An empty lifecycle list
|
||||
* deliberately creates a reusable definition only.
|
||||
*/
|
||||
export async function createAutomationComposition({
|
||||
automation: newAutomation,
|
||||
lifecycles,
|
||||
}: AutomationCompositionDTO): Promise<AutomationComposition> {
|
||||
const settings = getAutomationSettings();
|
||||
const automations = { ...settings.automations };
|
||||
const triggers = [...settings.triggers];
|
||||
const automationId = getUniqueAutomationId(automations);
|
||||
const automation: Automation = { ...newAutomation, id: automationId };
|
||||
const newTriggers: Trigger[] = [];
|
||||
for (const trigger of lifecycles) {
|
||||
const binding = {
|
||||
id: getUniqueTriggerId([...triggers, ...newTriggers]),
|
||||
title: `Global ${trigger}`,
|
||||
trigger,
|
||||
automationId,
|
||||
};
|
||||
newTriggers.push(binding);
|
||||
}
|
||||
|
||||
automations[automationId] = automation;
|
||||
triggers.push(...newTriggers);
|
||||
await getDataProvider().setAutomation({ ...settings, automations, triggers });
|
||||
|
||||
return { automation, triggers: newTriggers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing automation with a new entry
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
deleteTrigger,
|
||||
editAutomation,
|
||||
getAutomationSettings,
|
||||
getAutomationUsageCounts,
|
||||
postAutomationComposition,
|
||||
postAutomation,
|
||||
postAutomationSettings,
|
||||
postTrigger,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
} from './automation.controller.js';
|
||||
import {
|
||||
validateAutomation,
|
||||
validateAutomationComposition,
|
||||
validateAutomationPatch,
|
||||
validateAutomationSettings,
|
||||
validateTestPayload,
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
export const router: Router = express.Router();
|
||||
|
||||
router.get('/', getAutomationSettings);
|
||||
router.get('/usage', getAutomationUsageCounts);
|
||||
router.post('/', validateAutomationSettings, postAutomationSettings);
|
||||
|
||||
router.post('/trigger', validateTrigger, postTrigger);
|
||||
@@ -31,6 +35,7 @@ router.put('/trigger/:id', validateTriggerPatch, putTrigger);
|
||||
router.delete('/trigger/:id', paramsWithId, deleteTrigger);
|
||||
|
||||
router.post('/automation', validateAutomation, postAutomation);
|
||||
router.post('/composition', validateAutomationComposition, postAutomationComposition);
|
||||
router.put('/automation/:id', validateAutomationPatch, editAutomation);
|
||||
router.delete('/automation/:id', paramsWithId, deleteAutomation);
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
AutomationFilter,
|
||||
AutomationUsage,
|
||||
EntryId,
|
||||
FilterRule,
|
||||
MaybeNumber,
|
||||
OntimeAction,
|
||||
NormalisedAutomation,
|
||||
ProjectRundowns,
|
||||
RundownEntries,
|
||||
Trigger,
|
||||
isOntimeEvent,
|
||||
ontimeActionKeyValues,
|
||||
} from 'ontime-types';
|
||||
@@ -283,3 +286,32 @@ export function isAutomationUsed(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts global and event bindings in one pass over global triggers and project rundowns. */
|
||||
export function getAutomationUsage(
|
||||
automations: NormalisedAutomation,
|
||||
globalTriggers: Trigger[],
|
||||
projectRundowns: ProjectRundowns,
|
||||
): AutomationUsage {
|
||||
const usage: AutomationUsage = Object.fromEntries(
|
||||
Object.keys(automations).map((automationId) => [automationId, { global: 0, event: 0 }]),
|
||||
);
|
||||
|
||||
for (const trigger of globalTriggers) {
|
||||
const count = usage[trigger.automationId];
|
||||
if (count) count.global += 1;
|
||||
}
|
||||
|
||||
for (const rundown of Object.values(projectRundowns)) {
|
||||
for (const entryId of rundown.flatOrder) {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (!isOntimeEvent(entry)) continue;
|
||||
for (const trigger of entry.triggers ?? []) {
|
||||
const count = usage[trigger.automationId];
|
||||
if (count) count.event += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isIP } from 'node:net';
|
||||
import { body, param } from 'express-validator';
|
||||
import {
|
||||
AutomationDTO,
|
||||
AutomationCompositionDTO,
|
||||
AutomationFilter,
|
||||
AutomationOutput,
|
||||
HTTPOutput,
|
||||
@@ -48,6 +49,8 @@ export const validateTriggerPatch = [
|
||||
|
||||
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
|
||||
|
||||
export const validateAutomationComposition = [body().custom(parseAutomationComposition), requestValidationFunction];
|
||||
|
||||
export const validateAutomationPatch = [
|
||||
param('id').isString().notEmpty(),
|
||||
body().custom(parseAutomation),
|
||||
@@ -81,6 +84,31 @@ export function parseAutomation(maybeAutomation: unknown): AutomationDTO {
|
||||
return { title, filterRule, filters, outputs: parsedOutputs };
|
||||
}
|
||||
|
||||
/** Parses a create-only command for a reusable definition and its global bindings. */
|
||||
export function parseAutomationComposition(maybeComposition: unknown): AutomationCompositionDTO {
|
||||
assert.isObject(maybeComposition);
|
||||
assert.hasKeys(maybeComposition, ['automation', 'lifecycles']);
|
||||
|
||||
const { automation, lifecycles } = maybeComposition;
|
||||
const parsedAutomation = parseAutomation(automation);
|
||||
assert.isArray(lifecycles);
|
||||
|
||||
const parsedLifecycles = lifecycles.map((lifecycle) => {
|
||||
assert.isString(lifecycle);
|
||||
if (!timerLifecycleValues.includes(lifecycle)) {
|
||||
throw new Error(`Invalid automation lifecycle: ${lifecycle}`);
|
||||
}
|
||||
return lifecycle as AutomationCompositionDTO['lifecycles'][number];
|
||||
});
|
||||
|
||||
const duplicate = parsedLifecycles.find((lifecycle, index) => parsedLifecycles.indexOf(lifecycle) !== index);
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate automation lifecycle: ${duplicate}`);
|
||||
}
|
||||
|
||||
return { automation: parsedAutomation, lifecycles: parsedLifecycles };
|
||||
}
|
||||
|
||||
function validateFilters(filters: Array<unknown>): asserts filters is AutomationFilter[] {
|
||||
filters.forEach((condition) => {
|
||||
assert.isObject(condition);
|
||||
|
||||
Reference in New Issue
Block a user