mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
feat: filtering logic
This commit is contained in:
committed by
Carlos Valente
parent
2cc434b0e9
commit
bc9345199b
@@ -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 { deleteAllAutomations, addAutomation, addBlueprint } from '../automation.dao.js';
|
||||||
import { triggerAutomations } from '../automation.service.js';
|
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||||
|
|
||||||
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
|
||||||
|
|
||||||
import * as oscClient from '../clients/osc.client.js';
|
import * as oscClient from '../clients/osc.client.js';
|
||||||
import * as httpClient from '../clients/http.client.js';
|
import * as httpClient from '../clients/http.client.js';
|
||||||
|
|
||||||
|
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||||
// Create a small mock store
|
// Create a small mock store
|
||||||
@@ -67,26 +69,196 @@ describe('triggerAction()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger automations for a given action', () => {
|
it('should trigger automations for a given action', () => {
|
||||||
triggerAutomations(TimerLifeCycle.onLoad, {});
|
const state = makeRuntimeStateData();
|
||||||
|
triggerAutomations(TimerLifeCycle.onLoad, state);
|
||||||
expect(oscSpy).toHaveBeenCalledTimes(1);
|
expect(oscSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(httpSpy).not.toBeCalled();
|
expect(httpSpy).not.toBeCalled();
|
||||||
oscSpy.mockReset();
|
oscSpy.mockReset();
|
||||||
httpSpy.mockReset();
|
httpSpy.mockReset();
|
||||||
|
|
||||||
triggerAutomations(TimerLifeCycle.onStart, {});
|
triggerAutomations(TimerLifeCycle.onStart, state);
|
||||||
expect(oscClient.emitOSC).not.toBeCalled();
|
expect(oscClient.emitOSC).not.toBeCalled();
|
||||||
expect(httpSpy).not.toBeCalled();
|
expect(httpSpy).not.toBeCalled();
|
||||||
oscSpy.mockReset();
|
oscSpy.mockReset();
|
||||||
httpSpy.mockReset();
|
httpSpy.mockReset();
|
||||||
|
|
||||||
triggerAutomations(TimerLifeCycle.onFinish, {});
|
triggerAutomations(TimerLifeCycle.onFinish, state);
|
||||||
expect(oscSpy).not.toBeCalled();
|
expect(oscSpy).not.toBeCalled();
|
||||||
expect(httpSpy).toHaveBeenCalledTimes(1);
|
expect(httpSpy).toHaveBeenCalledTimes(1);
|
||||||
oscSpy.mockReset();
|
oscSpy.mockReset();
|
||||||
httpSpy.mockReset();
|
httpSpy.mockReset();
|
||||||
|
|
||||||
triggerAutomations(TimerLifeCycle.onStop, {});
|
triggerAutomations(TimerLifeCycle.onStop, state);
|
||||||
expect(oscSpy).not.toBeCalled();
|
expect(oscSpy).not.toBeCalled();
|
||||||
expect(httpSpy).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import {
|
|||||||
type AutomationFilter,
|
type AutomationFilter,
|
||||||
type AutomationOutput,
|
type AutomationOutput,
|
||||||
type FilterRule,
|
type FilterRule,
|
||||||
type RuntimeStore,
|
|
||||||
type TimerLifeCycle,
|
type TimerLifeCycle,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
import { getPropertyFromPath } from 'ontime-utils';
|
||||||
|
|
||||||
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
|
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
|
||||||
|
import { isOntimeCloud } from '../../externals.js';
|
||||||
|
|
||||||
import { emitOSC } from './clients/osc.client.js';
|
import { emitOSC } from './clients/osc.client.js';
|
||||||
import { emitHTTP } from './clients/http.client.js';
|
import { emitHTTP } from './clients/http.client.js';
|
||||||
import { getAutomations, getAutomationsEnabled, getBlueprints } from './automation.dao.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
|
* 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) {
|
export function testOutput(payload: AutomationOutput) {
|
||||||
send([payload]);
|
send([payload]);
|
||||||
}
|
}
|
||||||
@@ -56,28 +59,33 @@ export function testOutput(payload: AutomationOutput) {
|
|||||||
export function testConditions(
|
export function testConditions(
|
||||||
filters: AutomationFilter[],
|
filters: AutomationFilter[],
|
||||||
filterRule: FilterRule,
|
filterRule: FilterRule,
|
||||||
state: Partial<RuntimeStore>,
|
state: Partial<RuntimeState>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (filters.length === 0) {
|
if (filters.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filterRule === 'all') {
|
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 {
|
function evaluateCondition(filter: AutomationFilter): boolean {
|
||||||
const { field, operator, value } = filter;
|
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) {
|
switch (operator) {
|
||||||
case 'equals':
|
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':
|
case 'not_equals':
|
||||||
return fieldValue !== value;
|
return fieldValue != value;
|
||||||
case 'greater_than':
|
case 'greater_than':
|
||||||
return fieldValue > value;
|
return fieldValue > value;
|
||||||
case 'less_than':
|
case 'less_than':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Argument } from 'node-osc';
|
|
||||||
import { FilterRule, MaybeNumber } from 'ontime-types';
|
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';
|
type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains';
|
||||||
|
|
||||||
@@ -76,8 +77,7 @@ export function parseTemplateNested(template: string, state: object, humanReadab
|
|||||||
value = undefined;
|
value = undefined;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// iterate through variable parts, and look for the property in the state object
|
value = getPropertyFromPath(variableName, state);
|
||||||
value = variableParts.reduce((obj, key) => obj?.[key], state);
|
|
||||||
}
|
}
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
parsedTemplate = parsedTemplate.replace(match[0], value);
|
parsedTemplate = parsedTemplate.replace(match[0], value);
|
||||||
@@ -87,9 +87,14 @@ export function parseTemplateNested(template: string, state: object, humanReadab
|
|||||||
return parsedTemplate;
|
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 {
|
function formatDisplayFromString(value: string, hideZero = false): string {
|
||||||
let valueInNumber: MaybeNumber = null;
|
let valueInNumber: MaybeNumber = null;
|
||||||
|
|
||||||
|
// the value will be a string, so we need to convert it to the Maybe type
|
||||||
if (value !== 'null') {
|
if (value !== 'null') {
|
||||||
const parsedValue = Number(value);
|
const parsedValue = Number(value);
|
||||||
if (!Number.isNaN(parsedValue)) {
|
if (!Number.isNaN(parsedValue)) {
|
||||||
@@ -104,6 +109,11 @@ function formatDisplayFromString(value: string, hideZero = false): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AliasesDefinition = Record<string, { key: string; cb: (value: string) => string }>;
|
type AliasesDefinition = Record<string, { key: string; cb: (value: string) => string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This object matches some common RuntimeState paths
|
||||||
|
* to a function that formats them to a human-readable string
|
||||||
|
*/
|
||||||
const quickAliases: AliasesDefinition = {
|
const quickAliases: AliasesDefinition = {
|
||||||
clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) },
|
clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) },
|
||||||
duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) },
|
duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) },
|
||||||
|
|||||||
@@ -41,6 +41,6 @@ const baseState: RuntimeState = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeRuntimeStateData(patch?: Partial<RuntimeState>) {
|
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
|
||||||
return deepmerge(baseState, patch);
|
return deepmerge(baseState, patch) as RuntimeState;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user