diff --git a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts index a0daeafa4..ec2a5c08b 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts @@ -1,13 +1,15 @@ -import { TimerLifeCycle } from 'ontime-types'; +import { PlayableEvent, TimerLifeCycle } from 'ontime-types'; + +import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; +import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js'; import { deleteAllAutomations, addAutomation, addBlueprint } from '../automation.dao.js'; -import { triggerAutomations } from '../automation.service.js'; - -import { makeOSCAction, makeHTTPAction } from './testUtils.js'; - +import { testConditions, triggerAutomations } from '../automation.service.js'; import * as oscClient from '../clients/osc.client.js'; import * as httpClient from '../clients/http.client.js'; +import { makeOSCAction, makeHTTPAction } from './testUtils.js'; + beforeAll(() => { vi.mock('../../../classes/data-provider/DataProvider.js', () => { // Create a small mock store @@ -67,26 +69,196 @@ describe('triggerAction()', () => { }); it('should trigger automations for a given action', () => { - triggerAutomations(TimerLifeCycle.onLoad, {}); + const state = makeRuntimeStateData(); + triggerAutomations(TimerLifeCycle.onLoad, state); expect(oscSpy).toHaveBeenCalledTimes(1); expect(httpSpy).not.toBeCalled(); oscSpy.mockReset(); httpSpy.mockReset(); - triggerAutomations(TimerLifeCycle.onStart, {}); + triggerAutomations(TimerLifeCycle.onStart, state); expect(oscClient.emitOSC).not.toBeCalled(); expect(httpSpy).not.toBeCalled(); oscSpy.mockReset(); httpSpy.mockReset(); - triggerAutomations(TimerLifeCycle.onFinish, {}); + triggerAutomations(TimerLifeCycle.onFinish, state); expect(oscSpy).not.toBeCalled(); expect(httpSpy).toHaveBeenCalledTimes(1); oscSpy.mockReset(); httpSpy.mockReset(); - triggerAutomations(TimerLifeCycle.onStop, {}); + triggerAutomations(TimerLifeCycle.onStop, state); expect(oscSpy).not.toBeCalled(); expect(httpSpy).not.toBeCalled(); }); }); + +describe('testConditions()', () => { + it('should return true when no filters are provided', () => { + const result = testConditions([], 'all', {}); + expect(result).toBe(true); + }); + + it('should compare two equal values', () => { + const mockStore = makeRuntimeStateData({ clock: 10 }); + const result = testConditions([{ field: 'clock', operator: 'equals', value: '10' }], 'all', mockStore); + expect(result).toBe(true); + }); + + it('should check if a value does not exist', () => { + const mockStore = makeRuntimeStateData({ eventNow: null }); + const result = testConditions([{ field: 'eventNow.title', operator: 'equals', value: '' }], 'all', mockStore); + expect(result).toBe(true); + }); + + it('should check if two values are different', () => { + const mockStore = makeRuntimeStateData({ clock: 10 }); + const result = testConditions([{ field: 'clock', operator: 'not_equals', value: '11' }], 'all', mockStore); + expect(result).toBe(true); + }); + + it('should check if the given value is smaller', () => { + const mockStore = makeRuntimeStateData({ clock: 10 }); + const result = testConditions([{ field: 'clock', operator: 'greater_than', value: '9' }], 'all', mockStore); + expect(result).toBe(true); + }); + + it('should check if the given value is larger', () => { + const mockStore = makeRuntimeStateData({ clock: 10 }); + const result = testConditions([{ field: 'clock', operator: 'less_than', value: '11' }], 'all', mockStore); + expect(result).toBe(true); + }); + + it('should check if value contains given string', () => { + const result = testConditions( + [{ field: 'eventNow.title', operator: 'contains', value: 'lighting' }], + 'all', + makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), + ); + expect(result).toBe(true); + + const result2 = testConditions( + [{ field: 'eventNow.title', operator: 'contains', value: 'sound' }], + 'all', + makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), + ); + expect(result2).toBe(false); + }); + + it('should check if value does not contain given string', () => { + const result = testConditions( + [{ field: 'eventNow.title', operator: 'not_contains', value: 'lighting' }], + 'all', + makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), + ); + expect(result).toBe(false); + + const result2 = testConditions( + [{ field: 'eventNow.title', operator: 'not_contains', value: 'sound' }], + 'all', + makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), + ); + expect(result2).toBe(true); + }); + + describe('for all filter rule', () => { + it('should return true when all filters are true', () => { + const mockStore = makeRuntimeStateData({ + clock: 10, + eventNow: makeOntimeEvent({ + title: 'test', + custom: { av: 'av-value' }, + }) as PlayableEvent, + }); + const result = testConditions( + [ + { field: 'clock', operator: 'equals', value: '10' }, + { field: 'eventNow.title', operator: 'equals', value: 'test' }, + { field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' }, + ], + 'all', + mockStore, + ); + expect(result).toBe(true); + }); + + it('should return false if any filters are false', () => { + const mockStore = makeRuntimeStateData({ + clock: 10, + eventNow: makeOntimeEvent({ + title: 'test', + custom: { av: 'not-av-value' }, + }) as PlayableEvent, + }); + const result = testConditions( + [ + { field: 'clock', operator: 'equals', value: '11' }, + { field: 'eventNow.title', operator: 'equals', value: 'test' }, + { field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' }, + ], + 'all', + mockStore, + ); + expect(result).toBe(false); + }); + }); + + describe('for any filter rule', () => { + it('should return true when all filters are true', () => { + const mockStore = makeRuntimeStateData({ + clock: 10, + eventNow: makeOntimeEvent({ + title: 'test', + custom: { av: 'av-value' }, + }) as PlayableEvent, + }); + const result = testConditions( + [ + { field: 'clock', operator: 'equals', value: '10' }, + { field: 'eventNow.title', operator: 'equals', value: 'test' }, + { field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' }, + ], + 'any', + mockStore, + ); + expect(result).toBe(true); + }); + + it('should return true if any filters are true', () => { + const mockStore = makeRuntimeStateData({ + clock: 10, + eventNow: makeOntimeEvent({ + title: 'not-test', + custom: { av: 'av-value' }, + }) as PlayableEvent, + }); + const result = testConditions( + [ + { field: 'clock', operator: 'equals', value: '11' }, + { field: 'eventNow.title', operator: 'equals', value: 'not-test' }, + { field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' }, + ], + 'any', + mockStore, + ); + expect(result).toBe(true); + }); + + it('should return false if all filters are false', () => { + const mockStore = makeRuntimeStateData({ + clock: 10, + eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent, + }); + const result = testConditions( + [ + { field: 'clock', operator: 'equals', value: '11' }, + { field: 'eventNow.title', operator: 'equals', value: 'not-test' }, + ], + 'any', + mockStore, + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index 73e246acd..3fd2b55b3 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -4,16 +4,16 @@ import { type AutomationFilter, type AutomationOutput, type FilterRule, - type RuntimeStore, type TimerLifeCycle, } from 'ontime-types'; +import { getPropertyFromPath } from 'ontime-utils'; import { getState, type RuntimeState } from '../../stores/runtimeState.js'; +import { isOntimeCloud } from '../../externals.js'; import { emitOSC } from './clients/osc.client.js'; import { emitHTTP } from './clients/http.client.js'; import { getAutomations, getAutomationsEnabled, getBlueprints } from './automation.dao.js'; -import { isOntimeCloud } from '../../externals.js'; /** * Exposes a method for triggering actions based on a TimerLifeCycle event @@ -46,6 +46,9 @@ export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) { }); } +/** + * Exposes a method for bypassing the condition check and testing the sending of an output + */ export function testOutput(payload: AutomationOutput) { send([payload]); } @@ -56,28 +59,33 @@ export function testOutput(payload: AutomationOutput) { export function testConditions( filters: AutomationFilter[], filterRule: FilterRule, - state: Partial, + state: Partial, ): boolean { if (filters.length === 0) { return true; } if (filterRule === 'all') { - return filters.every((filter) => evaluateCondition(filter)); + return filters.every(evaluateCondition); } - return filters.some((filter) => evaluateCondition(filter)); + return filters.some(evaluateCondition); function evaluateCondition(filter: AutomationFilter): boolean { const { field, operator, value } = filter; - const fieldValue = state[field]; + const fieldValue = getPropertyFromPath(field, state); - // TODO: if value is empty string, the user could be meaning to check if the value does not exist + // if value is empty string, the user could be meaning to check if the value does not exist + // we use loose equality to be able to check for converted values (eg '10' == 10) switch (operator) { case 'equals': - return fieldValue === value; + // overload the edge case where we use empty string to check if a value does not exist + if (value === '' && fieldValue === undefined) { + return true; + } + return fieldValue == value; case 'not_equals': - return fieldValue !== value; + return fieldValue != value; case 'greater_than': return fieldValue > value; case 'less_than': diff --git a/apps/server/src/api-data/automation/automation.utils.ts b/apps/server/src/api-data/automation/automation.utils.ts index 649c74d3d..a1af515fb 100644 --- a/apps/server/src/api-data/automation/automation.utils.ts +++ b/apps/server/src/api-data/automation/automation.utils.ts @@ -1,6 +1,7 @@ -import { Argument } from 'node-osc'; import { FilterRule, MaybeNumber } from 'ontime-types'; -import { millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils'; +import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils'; + +import { Argument } from 'node-osc'; type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains'; @@ -76,8 +77,7 @@ export function parseTemplateNested(template: string, state: object, humanReadab value = undefined; } } else { - // iterate through variable parts, and look for the property in the state object - value = variableParts.reduce((obj, key) => obj?.[key], state); + value = getPropertyFromPath(variableName, state); } if (value !== undefined) { parsedTemplate = parsedTemplate.replace(match[0], value); @@ -87,9 +87,14 @@ export function parseTemplateNested(template: string, state: object, humanReadab return parsedTemplate; } +/** + * Handles the specific case where the MaybeNumber is encoded in a string + * After parsed, the value is formatted to a human-readable string + */ function formatDisplayFromString(value: string, hideZero = false): string { let valueInNumber: MaybeNumber = null; + // the value will be a string, so we need to convert it to the Maybe type if (value !== 'null') { const parsedValue = Number(value); if (!Number.isNaN(parsedValue)) { @@ -104,6 +109,11 @@ function formatDisplayFromString(value: string, hideZero = false): string { } type AliasesDefinition = Record string }>; + +/** + * This object matches some common RuntimeState paths + * to a function that formats them to a human-readable string + */ const quickAliases: AliasesDefinition = { clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) }, duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) }, diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index 3596bab6d..379e1449d 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -41,6 +41,6 @@ const baseState: RuntimeState = { }, }; -export function makeRuntimeStateData(patch?: Partial) { - return deepmerge(baseState, patch); +export function makeRuntimeStateData(patch?: Partial): RuntimeState { + return deepmerge(baseState, patch) as RuntimeState; }