Fix: automation (#1943)

* Fix(automation): Handles undefined values in conditions (#1933)

* Fix(automation): Handles undefined values in conditions

Addresses an issue where conditions with the 'not_equals' operator incorrectly evaluated undefined values. This change ensures that empty string comparisons correctly identify missing values in automation rules.

* Fixes automation "not equals" logic

Simplifies the 'not_equals' condition evaluation in automations by reusing the 'equals' condition, improving code readability and consistency.

Fixes #1932

* Test(automation): Expanding filter condition testing

Expanding test cases to cover various data types and edge case for each operators.
Unexpected behavior have been mark with a TO_DO.

* Fix(automation): Handles default filter case

- Ensures that their is a default scenario.
- Fixing Deepsource issue "No default cases in switch statements JS-0047"

* Test(automation): Remove abstraction in test

* move to test.each

* extract a isEquivalent function

* lowercase contains test

---------

Co-authored-by: Philippe Allard-Rousse <philrousse@gmail.com>
This commit is contained in:
Alex Christoffer Rasmussen
2026-01-21 16:07:21 +01:00
committed by GitHub
parent 904db95f59
commit efede83271
3 changed files with 452 additions and 108 deletions
@@ -9,6 +9,7 @@ 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'; import { makeOSCAction, makeHTTPAction } from './testUtils.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
beforeAll(() => { beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => { vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -100,117 +101,440 @@ describe('testConditions()', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
describe('equals operator', () => { describe('equals', () => {
it('should compare two equal values', () => { test.each([
const mockStore = makeRuntimeStateData({ clock: 10 }); {
const result = testConditions([{ field: 'clock', operator: 'equals', value: '10' }], 'all', mockStore); description: 'number and string number',
expect(result).toBe(true); value: '10',
}); state: 10,
},
it('string comparisons should be case insensitive', () => { {
const mockStore = makeRuntimeStateData({ eventNow: { title: 'Title' } as PlayableEvent }); description: 'string and string',
const result = testConditions( value: 'Title',
[{ field: 'eventNow.title', operator: 'equals', value: 'title' }], state: 'Title',
'all', },
mockStore, {
); description: 'null and empty string',
expect(result).toBe(true); value: '',
}); state: null,
},
it('should check if a value does not exist', () => { {
const mockStore = makeRuntimeStateData({ eventNow: null }); description: 'undefined and empty string',
const result = testConditions([{ field: 'eventNow.title', operator: 'equals', value: '' }], 'all', mockStore); value: '',
expect(result).toBe(true); state: undefined,
}); },
{
it('should handle trueness boolean comparisons', () => { description: 'boolean true and string true',
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: true } as PlayableEvent }); value: 'true',
state: true,
},
{
description: 'boolean false equals string false',
value: 'false',
state: false,
},
{
description: 'not case sensitive for strings',
value: 'title',
state: 'Title',
},
{
description: 'not case sensitive for booleans',
value: 'TRUE',
state: true,
},
])('$description', ({ value, state }) => {
expect( expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore), testConditions([{ field: 'test.path', operator: 'equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true); ).toBe(true);
expect( expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore), testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
).toBe(false); test: { path: state },
}); } as unknown as RuntimeState),
it('should handle falseness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: false } as PlayableEvent });
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
).toBe(false); ).toBe(false);
}); });
}); });
describe('not_equals operator', () => { describe('not_equals', () => {
it('should check if two values are different', () => { test.each([
const mockStore = makeRuntimeStateData({ clock: 10 }); {
const result = testConditions([{ field: 'clock', operator: 'not_equals', value: '11' }], 'all', mockStore); description: 'boolean true and string false',
expect(result).toBe(true); value: 'false',
state: true,
},
{
description: 'boolean false and string true',
value: 'true',
state: false,
},
{
description: 'number and non-numeric string',
value: 'lighting',
state: 10,
},
{
description: 'number and different number',
value: '100',
state: 10,
},
{
description: '10 and 11',
value: '11',
state: 10,
},
{
description: '10 and 5',
value: '5',
state: 10,
},
{
description: '10 and 1',
value: '1',
state: 10,
},
{
description: 'string and substring',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'string and different string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'null and non-empty string',
value: 'not-empty',
state: null,
},
{
description: 'undefined and non-empty string',
value: 'not-empty',
state: undefined,
},
{
description: 'undefined and " " string',
value: ' ',
state: undefined,
},
{
description: 'boolean false and empty string',
value: '',
state: false,
},
{
description: '0 and empty string',
value: '',
state: 0,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
}); });
}); });
describe('greater_than operator', () => { describe('greater_than', () => {
it('should check if the given value is smaller', () => { test.each([
const mockStore = makeRuntimeStateData({ clock: 10 }); {
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: '9' }], 'all', mockStore); description: '10 > 5',
expect(result).toBe(true); value: '5',
}); state: 10,
it('should handle values which are not numbers', () => { },
const mockStore = makeRuntimeStateData({ clock: 10 }); {
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: 'testing' }], 'all', mockStore); description: '10 > 1',
expect(result).toBe(false); value: '1',
state: 10,
},
{
description: '100 > 5',
value: '5',
state: 100,
},
{
description: '11 > 10',
value: '10',
state: 11,
},
{
description: '0 > -1',
value: '-1',
state: 0,
},
{
description: '-100 > -150',
value: '-150',
state: -100,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
}); });
}); });
describe('less_than operator', () => { describe('less_than', () => {
it('should check if the given value is larger', () => { test.each([
const mockStore = makeRuntimeStateData({ clock: 10 }); {
const result = testConditions([{ field: 'clock', operator: 'less_than', value: '11' }], 'all', mockStore); description: '10 > 5',
expect(result).toBe(true); value: '10',
}); state: 5,
it('should handle values which are not numbers', () => { },
const mockStore = makeRuntimeStateData({ clock: 10 }); {
const result = testConditions([{ field: 'clock', operator: 'less_than', value: 'testing' }], 'all', mockStore); description: '10 > 1',
expect(result).toBe(false); value: '10',
state: 1,
},
{
description: '100 > 5',
value: '100',
state: 5,
},
{
description: '11 > 10',
value: '11',
state: 10,
},
{
description: '0 > -1',
value: '0',
state: -1,
},
{
description: '-100 > -150',
value: '-100',
state: -150,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
}); });
}); });
describe('contains operator', () => { describe('invalid and edge case for greater/less_than', () => {
it('should check if value contains given string', () => { test.each([
const result = testConditions( {
[{ field: 'eventNow.title', operator: 'contains', value: 'lighting' }], description: 'number and non-numeric string',
'all', value: 'lighting',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), state: 10,
); },
expect(result).toBe(true); {
description: '0 and empty string',
const result2 = testConditions( value: '',
[{ field: 'eventNow.title', operator: 'contains', value: 'sound' }], state: 0,
'all', },
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), {
); description: 'equal values',
expect(result2).toBe(false); value: '10',
state: 10,
},
{
description: 'boolean false and empty string',
value: '',
state: false,
},
{
description: 'undefined and non-empty string',
value: 'not-empty',
state: undefined,
},
{
description: 'null and non-empty string',
value: 'not-empty',
state: null,
},
{
description: 'string and different string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'string and substring',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'boolean true and uppercase TRUE',
value: 'TRUE',
state: true,
},
{
description: 'string and lowercase string',
value: 'title',
state: 'Title',
},
{
description: 'boolean false and string true',
value: 'true',
state: false,
},
{
description: 'boolean false and string false',
value: 'false',
state: false,
},
{
description: 'boolean true and string false',
value: 'false',
state: true,
},
{
description: 'string and string',
value: 'Title',
state: 'Title',
},
{
description: 'null and empty string',
value: '',
state: null,
},
{
description: 'undefined and empty string',
value: '',
state: undefined,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
}); });
}); });
describe('not_contains operator', () => { describe('contains', () => {
it('should check if value does not contain given string', () => { test.each([
const result = testConditions( {
[{ field: 'eventNow.title', operator: 'not_contains', value: 'lighting' }], description: 'substring in string',
'all', value: 'lighting',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), state: 'testing-lighting-10',
); },
expect(result).toBe(false); {
description: 'number in string',
value: '10',
state: 'testing-lighting-10',
},
{
description: 'digit in string',
value: '1',
state: 'testing-lighting-10',
},
{
description: 'empty in empty',
value: '',
state: '',
},
{
description: 'empty in not-empty',
value: '',
state: '12345', // TODO: is this intended
},
{
description: 'string equals string',
value: 'Title',
state: 'Title',
},
{
description: 'case sensitive',
value: 'title',
state: 'Title',
},
{
description: 'number does contain string',
value: '10',
state: 2105,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'not_contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
const result2 = testConditions( describe('not_contains', () => {
[{ field: 'eventNow.title', operator: 'not_contains', value: 'sound' }], test.each([
'all', {
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }), description: 'number does not contain substring',
); value: '456',
expect(result2).toBe(true); state: 12345,
},
{
description: 'substring not in string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'string and undefined',
value: 'sound',
state: undefined,
},
{
description: 'string and null',
value: 'sound',
state: null,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
{
description: 'boolean false and string false',
value: 'false',
state: false,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'not_contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
}); });
}); });
@@ -17,7 +17,7 @@ import { isOntimeCloud } from '../../setup/environment.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 { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js'; import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isBooleanEquals, isGreaterThan, isLessThan } from './automation.utils.js'; import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js'; import { toOntimeAction } from './clients/ontime.client.js';
/** /**
@@ -92,29 +92,17 @@ export function testConditions(
// we use loose equality to be able to check for converted values (eg '10' == 10) // we use loose equality to be able to check for converted values (eg '10' == 10)
switch (operator) { switch (operator) {
case 'equals': case 'equals':
// handle the case where we are comparing boolean strings return isEquivalent(fieldValue, lowerCasedValue);
if (typeof fieldValue === 'boolean') {
return isBooleanEquals(fieldValue, lowerCasedValue);
}
// make string comparisons case insensitive
if (typeof fieldValue === 'string') {
return fieldValue.toLowerCase() === lowerCasedValue;
}
// 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 !isEquivalent(fieldValue, lowerCasedValue);
case 'greater_than': case 'greater_than':
return isGreaterThan(fieldValue, value); return isGreaterThan(fieldValue, value);
case 'less_than': case 'less_than':
return isLessThan(fieldValue, value); return isLessThan(fieldValue, value);
case 'contains': case 'contains':
return typeof fieldValue === 'string' && fieldValue.includes(value); return isContained(fieldValue, lowerCasedValue);
case 'not_contains': case 'not_contains':
return typeof fieldValue === 'string' && !fieldValue.includes(value); return !isContained(fieldValue, lowerCasedValue);
default: { default: {
operator satisfies never; operator satisfies never;
return false; return false;
@@ -148,6 +148,38 @@ const quickAliases: AliasesDefinition = {
startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) }, startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) },
}; };
export function isEquivalent(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return isBooleanEquals(a, b);
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase() === b;
}
// overload the edge case where we use empty string to check if a value does not exist
// this also avoids the case where 0 == ''
if (b === '') {
return a === null || a === undefined;
}
return a == b;
}
export function isContained(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return false;
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase().includes(b);
}
if (typeof a === 'number') {
return a.toString().toLowerCase().includes(b);
}
return false;
}
/** /**
* Utility encapsulates logic for comparing two strings which may encode numbers * Utility encapsulates logic for comparing two strings which may encode numbers
* @example isGreaterThan('10', '5') // true * @example isGreaterThan('10', '5') // true