feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -0,0 +1,191 @@
|
||||
import { HassEventBase } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesEventContext, matchesEventData } from '../../src/ha/event-match';
|
||||
|
||||
const ctx = (
|
||||
overrides: Partial<HassEventBase['context']> = {},
|
||||
): HassEventBase['context'] => ({
|
||||
id: 'ctx-id',
|
||||
user_id: null,
|
||||
parent_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('matchesEventData', () => {
|
||||
// Without a nested dict, HA does a plain items-subset compare -- every filter
|
||||
// key present, values strictly equal.
|
||||
describe('no nested dict in the filter', () => {
|
||||
it.each([['string'], [42], [true], [null], [undefined]])(
|
||||
'should reject non-object data (%s)',
|
||||
(data) => {
|
||||
expect(matchesEventData({ a: 1 }, data)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('should match when every filter key matches, ignoring extra data keys', () => {
|
||||
expect(
|
||||
matchesEventData({ command: 'press' }, { command: 'press', extra: 1 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject when a filter value differs', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { command: 'release' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when a filter key is missing from data', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { other: 'press' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should distinguish null from a different scalar', () => {
|
||||
expect(matchesEventData({ a: null }, { a: null })).toBe(true);
|
||||
expect(matchesEventData({ a: null }, { a: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept an empty filter against any object', () => {
|
||||
expect(matchesEventData({}, {})).toBe(true);
|
||||
expect(matchesEventData({}, { anything: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
// HA's test_event_data_with_list: top-level lists are strict (order+length).
|
||||
it('should match a top-level list by strict equality', () => {
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: [1, 2] })).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a top-level list that is a superset, subset, or scalar', () => {
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: [1, 2, 3] })).toBe(false);
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: [1] })).toBe(false);
|
||||
expect(matchesEventData({ tags: [1, 2] }, { tags: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
// Python's `bool` is a subtype of `int`, so HA equates true/false with 1/0.
|
||||
it('should equate booleans with 0/1 (Python ==)', () => {
|
||||
expect(matchesEventData({ flag: true }, { flag: 1 })).toBe(true);
|
||||
expect(matchesEventData({ flag: 1 }, { flag: true })).toBe(true);
|
||||
expect(matchesEventData({ flag: false }, { flag: 0 })).toBe(true);
|
||||
expect(matchesEventData({ flag: true }, { flag: 2 })).toBe(false);
|
||||
expect(matchesEventData({ flag: 2 }, { flag: true })).toBe(false);
|
||||
expect(matchesEventData({ flag: true }, { flag: 'on' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A nested dict makes HA validate the whole filter via voluptuous
|
||||
// (extra=ALLOW_EXTRA, required=True).
|
||||
describe('nested dict in the filter', () => {
|
||||
// HA's test_if_fires_on_event_with_nested_data: nested dicts are
|
||||
// subset-matched -- listed keys required, extra keys allowed.
|
||||
it('should allow extra keys inside a nested object', () => {
|
||||
expect(
|
||||
matchesEventData(
|
||||
{ device: { id: 'abc' } },
|
||||
{ device: { id: 'abc', name: 'Front Door' } },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should match a nested object with exact contents', () => {
|
||||
expect(
|
||||
matchesEventData({ device: { id: 'abc' } }, { device: { id: 'abc' } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a nested object missing a required key', () => {
|
||||
expect(matchesEventData({ device: { id: 'abc' } }, { device: { x: 'abc' } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a nested object when a value differs', () => {
|
||||
expect(
|
||||
matchesEventData({ device: { id: 'abc' } }, { device: { id: 'xyz' } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when the event value is not an object', () => {
|
||||
expect(matchesEventData({ device: { id: 'abc' } }, { device: 'abc' })).toBe(false);
|
||||
});
|
||||
|
||||
// HA's test_event_data_with_list_nested: nested lists are
|
||||
// membership-matched -- every event element must be one of the filter
|
||||
// array's entries.
|
||||
it('should match a nested list by membership', () => {
|
||||
const filter = { svc: { tags: [1, 2] } };
|
||||
expect(matchesEventData(filter, { svc: { tags: [1, 2] } })).toBe(true);
|
||||
expect(matchesEventData(filter, { svc: { tags: [1] } })).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a nested list with an element outside the filter set', () => {
|
||||
expect(
|
||||
matchesEventData({ svc: { tags: [1, 2] } }, { svc: { tags: [1, 2, 3] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a nested list when the event value is not a list', () => {
|
||||
expect(matchesEventData({ svc: { tags: [1, 2] } }, { svc: { tags: 1 } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
matchesEventData({ svc: { tags: [1, 2] } }, { svc: { other: [1, 2] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should membership-match a sibling top-level list given a nested dict', () => {
|
||||
expect(matchesEventData({ meta: {}, tags: [1, 2] }, { meta: {}, tags: [1] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The Python bool/int equivalence also propagates through the slow path.
|
||||
it('should equate booleans with 0/1 inside nested structures', () => {
|
||||
expect(
|
||||
matchesEventData({ device: { armed: true } }, { device: { armed: 1 } }),
|
||||
).toBe(true);
|
||||
expect(matchesEventData({ meta: {}, vals: [true] }, { meta: {}, vals: [1] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesEventContext', () => {
|
||||
it('should accept any context when the filter is empty', () => {
|
||||
expect(matchesEventContext({}, ctx())).toBe(true);
|
||||
});
|
||||
|
||||
it('should match a scalar filter against an equal value', () => {
|
||||
expect(matchesEventContext({ id: 'ctx-id' }, ctx({ id: 'ctx-id' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a scalar filter when the value differs', () => {
|
||||
expect(matchesEventContext({ id: 'other' }, ctx({ id: 'ctx-id' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should match a list filter when the value is a member', () => {
|
||||
expect(matchesEventContext({ id: ['ctx-id', 'other'] }, ctx({ id: 'ctx-id' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a list filter when the value is not a member', () => {
|
||||
expect(matchesEventContext({ id: ['a', 'b'] }, ctx({ id: 'ctx-id' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when an explicit filter targets a null event field', () => {
|
||||
expect(matchesEventContext({ user_id: 'u1' }, ctx({ user_id: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should AND across multiple filter fields', () => {
|
||||
const event = ctx({ id: 'i', user_id: 'u', parent_id: 'p' });
|
||||
expect(matchesEventContext({ id: 'i', user_id: 'u', parent_id: 'p' }, event)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matchesEventContext({ id: 'i', user_id: 'u', parent_id: 'OTHER' }, event),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore undefined filter fields', () => {
|
||||
expect(
|
||||
matchesEventContext({ id: undefined, user_id: 'u' }, ctx({ user_id: 'u' })),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user