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 { makeOSCAction, makeHTTPAction } from './testUtils.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -100,117 +101,440 @@ describe('testConditions()', () => {
expect(result).toBe(true);
});
describe('equals operator', () => {
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('string comparisons should be case insensitive', () => {
const mockStore = makeRuntimeStateData({ eventNow: { title: 'Title' } as PlayableEvent });
const result = testConditions(
[{ field: 'eventNow.title', operator: 'equals', value: 'title' }],
'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 handle trueness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: true } as PlayableEvent });
describe('equals', () => {
test.each([
{
description: 'number and string number',
value: '10',
state: 10,
},
{
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: '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(
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);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(false);
});
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),
testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('not_equals operator', () => {
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);
describe('not_equals', () => {
test.each([
{
description: 'boolean true and string false',
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', () => {
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 handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
describe('greater_than', () => {
test.each([
{
description: '10 > 5',
value: '5',
state: 10,
},
{
description: '10 > 1',
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', () => {
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 handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
describe('less_than', () => {
test.each([
{
description: '10 > 5',
value: '10',
state: 5,
},
{
description: '10 > 1',
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', () => {
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);
describe('invalid and edge case for greater/less_than', () => {
test.each([
{
description: 'number and non-numeric string',
value: 'lighting',
state: 10,
},
{
description: '0 and empty string',
value: '',
state: 0,
},
{
description: 'equal values',
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', () => {
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);
describe('contains', () => {
test.each([
{
description: 'substring in string',
value: 'lighting',
state: 'testing-lighting-10',
},
{
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(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(true);
describe('not_contains', () => {
test.each([
{
description: 'number does not contain substring',
value: '456',
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);
});
});