feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SubscriptionHealthMonitor } from '../../../src/ha/connection/subscription-health-monitor';
|
||||
import { HASSWebSocketSubscriptionStatus } from '../../../src/ha/connection/subscription-manager';
|
||||
|
||||
interface TestRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type State = HASSWebSocketSubscriptionStatus<string, TestRequest>['state'];
|
||||
|
||||
const status = (
|
||||
state: State,
|
||||
request: TestRequest,
|
||||
key: string,
|
||||
extra?: { error?: unknown; failureCount?: number },
|
||||
): HASSWebSocketSubscriptionStatus<string, TestRequest> => ({
|
||||
state,
|
||||
request,
|
||||
key,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('SubscriptionHealthMonitor', () => {
|
||||
it('should report a failing key with its error and failure count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const error = new Error('boom');
|
||||
|
||||
monitor.update(
|
||||
status('failing', { id: 'a' }, 'zha_event', { error, failureCount: 2 }),
|
||||
);
|
||||
|
||||
expect(monitor.getFailures()).toEqual([
|
||||
{ key: 'zha_event', error, failureCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore waiting so it never clears a failing key', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('waiting', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should clear a key once it subscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear a key once its request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('unsubscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report each failing key once regardless of subscriber count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', { id: 'b' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should notify listeners only when a key changes failing state', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
const request = { id: 'a' };
|
||||
|
||||
// Healthy -> failing: one notification.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Still failing (next attempt, same key): no membership change, no notify.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 2 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Failing -> healthy: one more notification.
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should stop notifying after the returned unsubscribe is called', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
const remove = monitor.addListener(listener);
|
||||
|
||||
remove();
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should retry one request per failing key and leave healthy keys alone', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const failingA = { id: 'a' };
|
||||
const failingB = { id: 'b' };
|
||||
const healthy = { id: 'c' };
|
||||
|
||||
// Two requests on a failing key, plus a separate healthy key.
|
||||
monitor.update(status('failing', failingA, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', failingB, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', healthy, 'deconz_event'));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
// Exactly one retry, for one of the failing key's requests; never the
|
||||
// healthy key.
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect([failingA, failingB]).toContainEqual(retry.mock.calls[0][0]);
|
||||
});
|
||||
|
||||
it('should retry the failing request even when a subscribed sibling is stored first', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const subscribed = { id: 'a' };
|
||||
const failing = { id: 'b' };
|
||||
|
||||
// Subscribed sibling recorded before the failing one on the same key.
|
||||
monitor.update(status('subscribed', subscribed, 'zha_event'));
|
||||
monitor.update(status('failing', failing, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect(retry).toBeCalledWith(failing);
|
||||
});
|
||||
|
||||
it('should retry one request for each distinct failing key', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const a = { id: 'a' };
|
||||
const b = { id: 'b' };
|
||||
|
||||
monitor.update(status('failing', a, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', b, 'deconz_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(2);
|
||||
expect(retry.mock.calls.map((c) => c[0])).toEqual(expect.arrayContaining([a, b]));
|
||||
});
|
||||
|
||||
it('should not notify when a never-failed request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
|
||||
monitor.update(status('unsubscribed', { id: 'a' }, 'zha_event'));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,797 @@
|
||||
import { Connection, STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
HASSConnectionSubscriptionManager,
|
||||
HASSWebSocketStatusCallback,
|
||||
HASSWebSocketSubscriptionStatus,
|
||||
} from '../../../src/ha/connection/subscription-manager';
|
||||
import {
|
||||
HASSWebSocketLiveness,
|
||||
HASSWebSocketOpenCallback,
|
||||
} from '../../../src/ha/connection/types';
|
||||
import { HASSSource } from '../../../src/ha/source';
|
||||
import {
|
||||
createHASS,
|
||||
createHASSSource,
|
||||
flushPromises,
|
||||
useDeterministicTimers,
|
||||
} from '../../test-utils';
|
||||
|
||||
interface TestRequest {
|
||||
key: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface CapturedOpenCall {
|
||||
request: TestRequest;
|
||||
connection: Connection;
|
||||
guard: HASSWebSocketLiveness;
|
||||
unsub: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Captures every open-callback invocation so tests can drive its dispatcher and
|
||||
// observe the guard state.
|
||||
const createRecordingOpenCallback = (): {
|
||||
openCallback: HASSWebSocketOpenCallback;
|
||||
calls: CapturedOpenCall[];
|
||||
} => {
|
||||
const calls: CapturedOpenCall[] = [];
|
||||
const openCallback: HASSWebSocketOpenCallback = async (connection, guard) => {
|
||||
const unsub = vi.fn().mockResolvedValue(undefined);
|
||||
calls.push({ request: { key: 'unused' }, connection, guard, unsub });
|
||||
return unsub;
|
||||
};
|
||||
return { openCallback, calls };
|
||||
};
|
||||
|
||||
// An open callback that always rejects, driving the retry/backoff machinery.
|
||||
const createFailingOpenCallback = (): HASSWebSocketOpenCallback =>
|
||||
vi.fn().mockRejectedValue(new Error('boom'));
|
||||
|
||||
const createManager = (
|
||||
source: HASSSource,
|
||||
): HASSConnectionSubscriptionManager<string, TestRequest> =>
|
||||
new HASSConnectionSubscriptionManager<string, TestRequest>((r) => r.key, source);
|
||||
|
||||
// Wires a source (seeded with `initial`) to a fresh manager, returning the
|
||||
// source's drivers alongside it.
|
||||
const setup = (initial: Parameters<typeof createHASSSource>[0] = createHASS()) => {
|
||||
const { source, push, getListenerCount } = createHASSSource(initial);
|
||||
return { manager: createManager(source), push, getListenerCount };
|
||||
};
|
||||
|
||||
// A HASS on a brand-new connection, so pushing it forces an era swap.
|
||||
const createSwappedHASS = (): ReturnType<typeof createHASS> => {
|
||||
const hass = createHASS();
|
||||
hass.connection = mock<Connection>();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(vi.fn());
|
||||
return hass;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('HASSConnectionSubscriptionManager', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('attach lifecycle', () => {
|
||||
it('should not attach to source until first subscribe', () => {
|
||||
const { getListenerCount } = setup();
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should attach on first subscribe and detach on last unsubscribe', () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const req = { key: 'a' };
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
manager.unsubscribe(req);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should bootstrap from source on first attach', async () => {
|
||||
const hass = createHASS();
|
||||
const { manager } = setup(hass);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].connection).toBe(hass.connection);
|
||||
});
|
||||
|
||||
it('should defer when source has no HASS yet', async () => {
|
||||
const { manager, push } = setup(null);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
push(createHASS());
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should defer when initial HASS is not ready', async () => {
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
const { manager, push } = setup(notReady);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
const ready = createHASS();
|
||||
ready.config.state = STATE_RUNNING;
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era transitions', () => {
|
||||
it('should replace KSM on connection swap and re-submit requests', async () => {
|
||||
const hass1 = createHASS();
|
||||
const { manager, push } = setup(hass1);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].connection).toBe(hass1.connection);
|
||||
|
||||
const hass2 = createSwappedHASS();
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].connection).toBe(hass2.connection);
|
||||
});
|
||||
|
||||
it('should flip old-era guards to dead on swap', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
const era1Guard = calls[0].guard;
|
||||
expect(era1Guard.isConnected()).toBe(true);
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
expect(era1Guard.isConnected()).toBe(false);
|
||||
expect(calls[1].guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should close old-era subscriptions on a connection swap', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
// The old era's subscription is closed, not abandoned.
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close old-era subscriptions when HA goes not-ready', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
notReady.connection = ready.connection;
|
||||
push(notReady);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should mint a fresh era when reanimating from a dead not-ready era, even with the same Connection', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
const oldGuard = calls[0].guard;
|
||||
expect(oldGuard.isConnected()).toBe(true);
|
||||
|
||||
// Go not-ready, keeping the same connection identity.
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
notReady.connection = ready.connection;
|
||||
push(notReady);
|
||||
expect(oldGuard.isConnected()).toBe(false);
|
||||
|
||||
// Re-ready with the SAME Connection: a fresh era must be minted so old
|
||||
// guards stay dead.
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
|
||||
expect(oldGuard.isConnected()).toBe(false);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not replace KSM on a same-connection HASS push', async () => {
|
||||
const hass = createHASS();
|
||||
const { manager, push } = setup(hass);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
push(hass);
|
||||
push(hass);
|
||||
await flushPromises();
|
||||
|
||||
// No retry triggered because nothing failed; same era.
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should drop the live era on not-ready and re-establish on the next ready push', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
// Old guard should be dead.
|
||||
expect(calls[0].guard.isConnected()).toBe(false);
|
||||
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
|
||||
// Fresh era, fresh submit.
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('should return current subscribers from getRequestsForKey synchronously', () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const r1 = { key: 'a', label: 'r1' };
|
||||
const r2 = { key: 'a', label: 'r2' };
|
||||
const r3 = { key: 'b', label: 'r3' };
|
||||
|
||||
manager.subscribe(r1, openCallback);
|
||||
manager.subscribe(r2, openCallback);
|
||||
manager.subscribe(r3, openCallback);
|
||||
|
||||
expect(manager.getRequestsForKey('a')).toEqual([r1, r2]);
|
||||
expect(manager.getRequestsForKey('b')).toEqual([r3]);
|
||||
|
||||
manager.unsubscribe(r1);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([r2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify refcount and serialization are delegated to KSM', () => {
|
||||
it('should refcount per key: a single WS sub per key regardless of subscribers', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a', label: 'first' }, openCallback);
|
||||
manager.subscribe({ key: 'a', label: 'second' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should open distinct WS subs for distinct keys', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time-spaced retries', () => {
|
||||
it('should not retry on subsequent HASS pushes; retries fire on the timer', async () => {
|
||||
useDeterministicTimers();
|
||||
const hass = createHASS();
|
||||
const { manager, push } = setup(hass);
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
|
||||
// HASS pushes do not retry while the retry-timer is scheduled.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
push(hass);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry after the backoff delay elapses', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
|
||||
// 1st retry: ~1s.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failing).toBeCalledTimes(2);
|
||||
|
||||
// 2nd retry: ~2s.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(failing).toBeCalledTimes(3);
|
||||
|
||||
// 3rd retry: ~4s.
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should reset the backoff on a connection swap', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager, push } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Burn through a few backoff steps so the next would be a longer delay.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
const callsBeforeSwap = vi.mocked(failing).mock.calls.length;
|
||||
|
||||
// Swap. The fresh era should retry immediately on the new connection.
|
||||
push(createSwappedHASS());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 1);
|
||||
|
||||
// First retry on the new era is back at ~1s, not whatever the previous
|
||||
// era's accumulated delay was.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 2);
|
||||
});
|
||||
|
||||
it('should reset the backoff on a readiness transition (not-ready -> ready)', async () => {
|
||||
useDeterministicTimers();
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
const callsBefore = vi.mocked(failing).mock.calls.length;
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
push(ready);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Re-ready triggers a fresh era and a fresh submission.
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 1);
|
||||
|
||||
// Next retry is back to ~1s (fresh backoff).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale catch token guard', () => {
|
||||
it('should not let a stale rejection from a swapped-out KSM clear the new submission marker', async () => {
|
||||
const { manager, push } = setup();
|
||||
|
||||
let rejectFirst: ((e: Error) => void) | undefined;
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
// First call (on the initial connection) rejects later.
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<() => Promise<void>>((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
// Subsequent calls succeed.
|
||||
.mockResolvedValue(vi.fn());
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
|
||||
// Swap to a new connection BEFORE the first call settles.
|
||||
const hass2 = createSwappedHASS();
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
|
||||
// Now reject the stale first call. The catch must NOT wipe the marker for
|
||||
// the in-flight second submission, so the next same-connection push must
|
||||
// NOT submit again.
|
||||
rejectFirst?.(new Error('boom'));
|
||||
await flushPromises();
|
||||
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should be a no-op when called without any subscribers', () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
manager.destroy();
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should swallow a failed KSM unsubscribe during drain', async () => {
|
||||
const { manager } = setup();
|
||||
const failingUnsub = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(failingUnsub);
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
manager.destroy();
|
||||
await flushPromises();
|
||||
|
||||
expect(failingUnsub).toBeCalled();
|
||||
});
|
||||
|
||||
it('should detach source listener, drain KSM, clear state, and flip guards dead', async () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const guardA = calls[0].guard;
|
||||
const guardB = calls[1].guard;
|
||||
expect(guardA.isConnected()).toBe(true);
|
||||
expect(guardB.isConnected()).toBe(true);
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(getListenerCount()).toBe(0);
|
||||
expect(guardA.isConnected()).toBe(false);
|
||||
expect(guardB.isConnected()).toBe(false);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([]);
|
||||
expect(manager.getRequestsForKey('b')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should close a subscription still queued in KSM when destroy is called', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
// Destroy before KSM admits the subscribe task: the durable mirror still
|
||||
// drives teardown, so the subscription is closed once it opens rather
|
||||
// than leaking.
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.destroy();
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribe', () => {
|
||||
it('should not flip guards dead on unsubscribe as era still live for other subscribers', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
const r1 = { key: 'a' };
|
||||
const r2 = { key: 'a' };
|
||||
manager.subscribe(r1, openCallback);
|
||||
manager.subscribe(r2, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const guard = calls[0].guard;
|
||||
manager.unsubscribe(r1);
|
||||
expect(guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detach from source when the last subscriber leaves', async () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
|
||||
const req = { key: 'a' };
|
||||
manager.subscribe(req, openCallback);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
manager.unsubscribe(req);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should swallow a failed unsubscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const failingUnsub = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(failingUnsub);
|
||||
|
||||
const req = { key: 'a' };
|
||||
manager.subscribe(req, openCallback);
|
||||
await flushPromises();
|
||||
manager.unsubscribe(req);
|
||||
await flushPromises();
|
||||
|
||||
expect(failingUnsub).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status callback', () => {
|
||||
const collectStatuses = (): {
|
||||
statusCallback: HASSWebSocketStatusCallback<string, TestRequest>;
|
||||
events: HASSWebSocketSubscriptionStatus<string, TestRequest>[];
|
||||
} => {
|
||||
const events: HASSWebSocketSubscriptionStatus<string, TestRequest>[] = [];
|
||||
return { statusCallback: (s) => events.push(s), events };
|
||||
};
|
||||
|
||||
it('should emit waiting then subscribed on a successful subscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
expect(events[1].failureCount).toBeUndefined();
|
||||
expect(events[1].error).toBeUndefined();
|
||||
expect(events[1].key).toBe('a');
|
||||
});
|
||||
|
||||
it('should emit waiting when subscribed before HA is ready', () => {
|
||||
const { manager } = setup(null);
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].state).toBe('waiting');
|
||||
});
|
||||
|
||||
it('should emit failing with the error and incremented failureCount', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing, statusCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// waiting, failing(1).
|
||||
const failures = events.filter((e) => e.state === 'failing');
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(String(failures[0].error)).toMatch(/boom/);
|
||||
expect(failures[0].failureCount).toBe(1);
|
||||
|
||||
// Second attempt also fails -> failing(2).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const failures2 = events.filter((e) => e.state === 'failing');
|
||||
expect(failures2).toHaveLength(2);
|
||||
expect(failures2[1].failureCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit subscribed without a failureCount after eventual success', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined));
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const subscribed = events.filter((e) => e.state === 'subscribed');
|
||||
expect(subscribed).toHaveLength(1);
|
||||
expect(subscribed[0].failureCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit unsubscribed on unsubscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
const before = events.length;
|
||||
|
||||
manager.unsubscribe(req);
|
||||
|
||||
expect(events.length).toBe(before + 1);
|
||||
expect(events[events.length - 1].state).toBe('unsubscribed');
|
||||
});
|
||||
|
||||
it('should not emit on unsubscribe for an unknown request', () => {
|
||||
const { manager } = setup();
|
||||
const { events } = collectStatuses();
|
||||
|
||||
manager.unsubscribe({ key: 'never-subscribed' });
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should emit waiting for all requests when HA goes not-ready', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const a = collectStatuses();
|
||||
const b = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, a.statusCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback, b.statusCallback);
|
||||
await flushPromises();
|
||||
a.events.length = 0;
|
||||
b.events.length = 0;
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
expect(a.events.map((e) => e.state)).toEqual(['waiting']);
|
||||
expect(b.events.map((e) => e.state)).toEqual(['waiting']);
|
||||
});
|
||||
|
||||
it('should emit subscribed after an era reanimation', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
events.length = 0;
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
});
|
||||
|
||||
it('should isolate a throwing status callback from the retry state machine', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
const events: HASSWebSocketSubscriptionStatus<string, TestRequest>[] = [];
|
||||
const statusCallback: HASSWebSocketStatusCallback<string, TestRequest> = (s) => {
|
||||
events.push(s);
|
||||
if (s.state === 'subscribed') {
|
||||
throw new Error('observer boom');
|
||||
}
|
||||
};
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
|
||||
// The throw during `subscribed` is swallowed: no `failing` transition and
|
||||
// no retry.
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('should cancel the pending retry timer and submit immediately', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined));
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
|
||||
// Pending timer would fire ~1s from now. retry runs the second attempt
|
||||
// synchronously instead of waiting.
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
|
||||
// Advance well past the original 1s schedule: nothing further fires.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should reset the backoff so the next failure schedules at the base delay', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, failing);
|
||||
|
||||
// Burn through three failures to escalate the backoff.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(failing).toBeCalledTimes(3);
|
||||
|
||||
// User clicks retry: counter resets. Next failure should schedule at ~1s
|
||||
// again, not ~8s.
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(failing).toBeCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should be a no-op for an unknown request', () => {
|
||||
const { manager } = setup();
|
||||
expect(() => manager.retry({ key: 'never-subscribed' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not submit in a dead era, but reset the backoff for next era', async () => {
|
||||
useDeterministicTimers();
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
const { manager, push } = setup(notReady);
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(vi.fn().mockResolvedValue(undefined));
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(openCallback).not.toBeCalled();
|
||||
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(openCallback).not.toBeCalled();
|
||||
|
||||
// Era starts; the request submits.
|
||||
const ready = createHASS();
|
||||
ready.config.state = STATE_RUNNING;
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesEventData } from '../../src/ha/event-data-match';
|
||||
|
||||
describe('matchesEventData', () => {
|
||||
describe('non-object data', () => {
|
||||
it.each([['string'], [42], [true], [null], [undefined]])(
|
||||
'rejects non-object data (%s) with a non-empty filter',
|
||||
(data) => {
|
||||
expect(matchesEventData({ a: 1 }, data)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('matches when every filter key matches', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { command: 'press', extra: 1 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when a filter value differs', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { command: 'release' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when a filter key is missing from data', () => {
|
||||
expect(matchesEventData({ command: 'press' }, { other: 'press' })).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores extra keys in data', () => {
|
||||
expect(matchesEventData({ a: 1 }, { a: 1, b: 2, c: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches nested objects as a subset', () => {
|
||||
expect(
|
||||
matchesEventData(
|
||||
{ device: { id: 'abc' } },
|
||||
{ device: { id: 'abc', name: 'Front Door' } },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects nested objects when a nested key differs', () => {
|
||||
expect(matchesEventData({ device: { id: 'abc' } }, { device: { id: 'xyz' } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches arrays element-wise', () => {
|
||||
expect(matchesEventData({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches arrays as a subset by index (filter shorter than data)', () => {
|
||||
// Same partial-match semantics lodash uses for objects: a shorter filter
|
||||
// array matches if every index it specifies matches in data. Useful when
|
||||
// the user only cares about the first N values.
|
||||
expect(matchesEventData({ tags: ['a'] }, { tags: ['a', 'b'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects arrays with mismatched elements at the same index', () => {
|
||||
expect(matchesEventData({ tags: ['a', 'c'] }, { tags: ['a', 'b'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when filter array is longer than data array', () => {
|
||||
expect(matchesEventData({ tags: ['a', 'b'] }, { tags: ['a'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes null from undefined', () => {
|
||||
expect(matchesEventData({ a: null }, { a: null })).toBe(true);
|
||||
expect(matchesEventData({ a: null }, { a: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an empty filter against any object', () => {
|
||||
expect(matchesEventData({}, {})).toBe(true);
|
||||
expect(matchesEventData({}, { anything: 1, nested: { x: 2 } })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isHassReady } from '../../src/ha/is-hass-ready';
|
||||
import { createHASS } from '../test-utils';
|
||||
|
||||
describe('isHassReady', () => {
|
||||
it('should return false for null', () => {
|
||||
expect(isHassReady(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isHassReady(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when disconnected', () => {
|
||||
const hass = createHASS();
|
||||
hass.connected = false;
|
||||
expect(isHassReady(hass)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when integrations are still loading', () => {
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = 'NOT_RUNNING';
|
||||
expect(isHassReady(hass)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when connected and running', () => {
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_RUNNING;
|
||||
expect(isHassReady(hass)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user