Files
advanced-camera-card/tests/ha/event-match.test.ts
T

193 lines
7.1 KiB
TypeScript

import type { 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);
});
});