feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -1,8 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
||||
import { createCardAPI, flushPromises } from '../test-utils.js';
|
||||
import {
|
||||
createCardAPI,
|
||||
createHASS,
|
||||
createHASSEvent,
|
||||
flushPromises,
|
||||
} from '../test-utils.js';
|
||||
|
||||
describe('AutomationsManager', () => {
|
||||
const actions = [
|
||||
@@ -264,6 +271,40 @@ describe('AutomationsManager', () => {
|
||||
expect(api.getActionsManager().executeActions).toBeCalledTimes(10);
|
||||
});
|
||||
|
||||
it('should execute actions on a matching HA bus event trigger', () => {
|
||||
const api = createCardAPI();
|
||||
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
||||
vi.mocked(eventWatcher.subscribe).mockResolvedValue();
|
||||
vi.mocked(eventWatcher.unsubscribe).mockResolvedValue();
|
||||
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
vi.mocked(api.getHASSManager().getEventWatcher).mockReturnValue(eventWatcher);
|
||||
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const automationsManager = new AutomationsManager(api);
|
||||
automationsManager.addAutomations([
|
||||
{
|
||||
triggers: [{ trigger: 'event' as const, event_type: 'zha_event' }],
|
||||
actions: actions,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
||||
|
||||
// Simulate an event arrival.
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
vi.mocked(eventWatcher.subscribe).mock.calls[0][0].callback(event);
|
||||
|
||||
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
||||
expect(
|
||||
vi.mocked(api.getActionsManager().executeActions).mock.calls[0][0].triggerData,
|
||||
).toEqual({ platform: 'event', event });
|
||||
});
|
||||
|
||||
it('should delete automations', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
||||
|
||||
@@ -32,6 +32,24 @@ describe('CardElementManager', () => {
|
||||
expect(manager.getElement()).toBe(element);
|
||||
});
|
||||
|
||||
it('should report whether the element is connected', () => {
|
||||
const element = createCardHTMLElement();
|
||||
const manager = new CardElementManager(
|
||||
createCardAPI(),
|
||||
element,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
|
||||
document.body.append(element);
|
||||
expect(manager.isConnected()).toBe(true);
|
||||
|
||||
element.remove();
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset scroll', () => {
|
||||
const callback = vi.fn();
|
||||
const manager = new CardElementManager(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock, MockProxy } from 'vitest-mock-extended';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager';
|
||||
@@ -15,6 +16,7 @@ import { DefaultManager } from '../../src/card-controller/default-manager';
|
||||
import { ExpandManager } from '../../src/card-controller/expand-manager';
|
||||
import { FoldersManager } from '../../src/card-controller/folders/manager';
|
||||
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
|
||||
import { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher';
|
||||
import { HASSManager } from '../../src/card-controller/hass/hass-manager';
|
||||
import { InitializationManager } from '../../src/card-controller/initialization-manager';
|
||||
import { InteractionManager } from '../../src/card-controller/interaction-manager';
|
||||
@@ -36,6 +38,7 @@ import { AdvancedCameraCardEditor } from '../../src/editor';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
|
||||
import { createSubscriptionHealth } from './test-utils';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager');
|
||||
vi.mock('../../src/card-controller/actions/actions-manager');
|
||||
@@ -49,7 +52,6 @@ vi.mock('../../src/card-controller/download-manager');
|
||||
vi.mock('../../src/card-controller/expand-manager');
|
||||
vi.mock('../../src/card-controller/folders/manager');
|
||||
vi.mock('../../src/card-controller/fullscreen/fullscreen-manager');
|
||||
vi.mock('../../src/card-controller/hass/hass-manager');
|
||||
vi.mock('../../src/card-controller/initialization-manager');
|
||||
vi.mock('../../src/card-controller/interaction-manager');
|
||||
vi.mock('../../src/card-controller/keyboard-state-manager');
|
||||
@@ -78,8 +80,21 @@ const createCardElement = (): CardHTMLElement => {
|
||||
return element;
|
||||
};
|
||||
|
||||
const createController = (): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn());
|
||||
// Full HASSManager mock for CardController ctor injection (wires
|
||||
// getEventWatcher().getHealth() so construction resolves). Distinct from the
|
||||
// readonly-interface `createHASSManager` helper in tests/test-utils.ts.
|
||||
const createMockHASSManager = (): MockProxy<HASSManager> => {
|
||||
const hassManager = mock<HASSManager>();
|
||||
hassManager.getEventWatcher.mockReturnValue(
|
||||
mock<EventWatcherSubscriptionInterface>({
|
||||
getHealth: () => createSubscriptionHealth(),
|
||||
}),
|
||||
);
|
||||
return hassManager;
|
||||
};
|
||||
|
||||
const createController = (hassManager = createMockHASSManager()): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn(), hassManager);
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -104,6 +119,26 @@ describe('CardController', () => {
|
||||
expect(controller.getEffectsManager()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should wire ConditionStateManager as the first hass listener so semantic state is fresh before any other listener runs', () => {
|
||||
const hassManager = createMockHASSManager();
|
||||
createController(hassManager);
|
||||
|
||||
const calls = vi.mocked(hassManager.addListener).mock.calls;
|
||||
|
||||
// ConditionStateManager (CSM) first ordering is load-bearing: StateWatcher
|
||||
// lazy-attaches later; its diff handlers can synchronously write to CSM, so
|
||||
// CSM.hass must be fresh before any listener whose dispatch path reads
|
||||
// condition state.
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const csmListener = calls[0][0];
|
||||
const hass = {} as Parameters<typeof csmListener>[0];
|
||||
csmListener(hass, null);
|
||||
expect(vi.mocked(ConditionStateManager).mock.instances[0].setState).toBeCalledWith({
|
||||
hass,
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessors', () => {
|
||||
it('should return getActionsManager', () => {
|
||||
expect(createController().getActionsManager()).toBe(
|
||||
@@ -196,9 +231,8 @@ describe('CardController', () => {
|
||||
});
|
||||
|
||||
it('should return getHASSManager', () => {
|
||||
expect(createController().getHASSManager()).toBe(
|
||||
vi.mocked(HASSManager).mock.instances[0],
|
||||
);
|
||||
const hassManager = createMockHASSManager();
|
||||
expect(createController(hassManager).getHASSManager()).toBe(hassManager);
|
||||
});
|
||||
|
||||
it('should return getInitializationManager', () => {
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import { HassEvent } from 'home-assistant-js-websocket';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Connection, HassEvent } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
|
||||
import { HomeAssistant } from '../../../src/ha/types';
|
||||
import { createHASS } from '../../test-utils';
|
||||
import {
|
||||
createHASS,
|
||||
createHASSEvent,
|
||||
createHASSSource,
|
||||
flushPromises,
|
||||
useDeterministicTimers,
|
||||
} from '../../test-utils';
|
||||
|
||||
// Drive the dispatcher registered with `hass.connection.subscribeEvents` to
|
||||
// simulate an event arriving over the WS bus.
|
||||
const fireEvent = (hass: HomeAssistant, event: HassEvent, n = 0): void => {
|
||||
const mock = vi.mocked(hass.connection.subscribeEvents).mock;
|
||||
expect(mock.calls.length).greaterThan(n);
|
||||
|
||||
// subscribeEvents(callback, event_type) -- callback is the first argument.
|
||||
mock.calls[n][0]?.(event);
|
||||
};
|
||||
|
||||
const createHassEvent = (event_type: string, data: object = {}): HassEvent => ({
|
||||
event_type,
|
||||
data: data as { [key: string]: string },
|
||||
origin: 'LOCAL',
|
||||
time_fired: '2026-05-25T00:00:00Z',
|
||||
context: { id: 'ctx', user_id: null, parent_id: null },
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EventWatcher', () => {
|
||||
it('opens a single WS subscription per event_type regardless of subscribers', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
it('should open a WS subscription keyed by event_type', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
|
||||
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls[0][1]).toBe(
|
||||
@@ -34,100 +40,160 @@ describe('EventWatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens separate WS subscriptions for distinct event_types', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should share one WS subscription across subscribers with the same event_type', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: vi.fn() });
|
||||
await watcher.subscribe(hass, { event_type: 'deconz_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should open separate WS subscriptions for distinct event_types', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
watcher.subscribe({ event_type: 'deconz_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(hass.connection.subscribeEvents).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('only tears down the WS subscription when the last subscriber unsubscribes', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
|
||||
|
||||
const req1 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const req2 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
await watcher.subscribe(hass, req1);
|
||||
await watcher.subscribe(hass, req2);
|
||||
|
||||
await watcher.unsubscribe(req1);
|
||||
expect(unsub).not.toBeCalled();
|
||||
|
||||
await watcher.unsubscribe(req2);
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches to all subscribers whose event_type matches', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should dispatch to every subscriber whose event_type matches', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
const event = createHASSEvent('zha_event', { command: 'press' });
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb1 });
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb2 });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb1 });
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb2 });
|
||||
await flushPromises();
|
||||
|
||||
fireEvent(hass, createHassEvent('zha_event', { command: 'press' }));
|
||||
fireEvent(hass, event);
|
||||
|
||||
expect(cb1).toBeCalledWith({ command: 'press' });
|
||||
expect(cb2).toBeCalledWith({ command: 'press' });
|
||||
expect(cb1).toBeCalledWith(event);
|
||||
expect(cb2).toBeCalledWith(event);
|
||||
});
|
||||
|
||||
it('drops events whose event_type does not match the request', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should gate dispatch on the request matcher when provided', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb = vi.fn();
|
||||
const matcher = vi.fn((event: HassEvent) => (event.data as { x?: number }).x === 1);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', callback: cb });
|
||||
// Inject an unrelated event into the shared dispatcher.
|
||||
fireEvent(hass, createHassEvent('other_event', { x: 1 }));
|
||||
const matching = createHASSEvent('zha_event', { x: 1 });
|
||||
const nonMatching = createHASSEvent('zha_event', { x: 2 });
|
||||
watcher.subscribe({ event_type: 'zha_event', matcher, callback: cb });
|
||||
await flushPromises();
|
||||
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('gates dispatch on the request matcher when provided', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
const hass = createHASS();
|
||||
const cb = vi.fn();
|
||||
const matcher = vi.fn((data: unknown) => (data as { x?: number }).x === 1);
|
||||
|
||||
await watcher.subscribe(hass, { event_type: 'zha_event', matcher, callback: cb });
|
||||
|
||||
fireEvent(hass, createHassEvent('zha_event', { x: 1 }));
|
||||
fireEvent(hass, createHassEvent('zha_event', { x: 2 }));
|
||||
fireEvent(hass, matching);
|
||||
fireEvent(hass, nonMatching);
|
||||
|
||||
expect(matcher).toBeCalledTimes(2);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
expect(cb).toBeCalledWith({ x: 1 });
|
||||
expect(cb).toBeCalledWith(matching);
|
||||
});
|
||||
|
||||
it('handles unsubscribe during a still-pending subscribe without leaking', async () => {
|
||||
const watcher = new EventWatcher();
|
||||
it('should tear down the WS subscription only when the last subscriber unsubscribes', async () => {
|
||||
const hass = createHASS();
|
||||
const unsub = vi.fn();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(unsub);
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const req1 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const req2 = { event_type: 'zha_event', callback: vi.fn() };
|
||||
|
||||
let resolveSubscription: ((cb: () => Promise<void>) => void) | undefined;
|
||||
const subscriptionPromise = new Promise<() => Promise<void>>((resolve) => {
|
||||
resolveSubscription = resolve;
|
||||
});
|
||||
vi.mocked(hass.connection.subscribeEvents).mockReturnValue(subscriptionPromise);
|
||||
watcher.subscribe(req1);
|
||||
watcher.subscribe(req2);
|
||||
await flushPromises();
|
||||
|
||||
const req = { event_type: 'zha_event', callback: vi.fn() };
|
||||
const subscribePromise = watcher.subscribe(hass, req);
|
||||
|
||||
// Unsubscribe before the underlying connection has resolved.
|
||||
const unsubscribePromise = watcher.unsubscribe(req);
|
||||
|
||||
// Resolve the connection -- the watcher should now have the unsub fn and
|
||||
// call it as part of completing the unsubscribe.
|
||||
resolveSubscription?.(unsub);
|
||||
await subscribePromise;
|
||||
await unsubscribePromise;
|
||||
watcher.unsubscribe(req1);
|
||||
await flushPromises();
|
||||
expect(unsub).not.toBeCalled();
|
||||
|
||||
watcher.unsubscribe(req2);
|
||||
await flushPromises();
|
||||
expect(unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should drop events from an old-connection subscription after a swap', async () => {
|
||||
const oldHass = createHASS();
|
||||
const { source, push } = createHASSSource(oldHass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const cb = vi.fn();
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: cb });
|
||||
await flushPromises();
|
||||
|
||||
// Capture the dispatcher registered against the OLD connection BEFORE
|
||||
// the swap, so it still points at the source-bound guard.
|
||||
const oldDispatcher = vi.mocked(oldHass.connection.subscribeEvents).mock.calls[0][0];
|
||||
|
||||
const newHass = createHASS();
|
||||
newHass.connection = mock<Connection>();
|
||||
vi.mocked(newHass.connection.subscribeEvents).mockResolvedValue(vi.fn());
|
||||
push(newHass);
|
||||
await flushPromises();
|
||||
|
||||
// Old dispatcher fires: guard.isConnected() is now false, callback must NOT
|
||||
// receive the event.
|
||||
oldDispatcher?.(createHASSEvent('zha_event', { command: 'press' }));
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not dispatch to a subscriber that registers mid-dispatch', async () => {
|
||||
const hass = createHASS();
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
const lateCallback = vi.fn();
|
||||
const reentrantCallback = vi.fn(() => {
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: lateCallback });
|
||||
});
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: reentrantCallback });
|
||||
await flushPromises();
|
||||
|
||||
fireEvent(hass, createHASSEvent('zha_event', { command: 'press' }));
|
||||
|
||||
expect(reentrantCallback).toBeCalledTimes(1);
|
||||
expect(lateCallback).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('subscription health monitoring', () => {
|
||||
it('should surface and retry failing subscriptions through getHealth', async () => {
|
||||
useDeterministicTimers();
|
||||
|
||||
const hass = createHASS();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockRejectedValue(new Error('boom'));
|
||||
const { source } = createHASSSource(hass);
|
||||
const watcher = new EventWatcher(source);
|
||||
|
||||
watcher.subscribe({ event_type: 'zha_event', callback: vi.fn() });
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
watcher
|
||||
.getHealth()
|
||||
.getFailures()
|
||||
.map((failure) => failure.key),
|
||||
).toEqual(['zha_event']);
|
||||
|
||||
const before = vi.mocked(hass.connection.subscribeEvents).mock.calls.length;
|
||||
|
||||
watcher.getHealth().retry();
|
||||
|
||||
await flushPromises();
|
||||
expect(vi.mocked(hass.connection.subscribeEvents).mock.calls.length).toBe(
|
||||
before + 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,27 +44,62 @@ describe('HASSManager', () => {
|
||||
expect(manager.hasHASS()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should update theme upon setting hass', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new HASSManager(api);
|
||||
describe('as a HASS source', () => {
|
||||
it('should fan out to registered listeners with (hass, oldHass)', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
manager.setHASS(createHASS());
|
||||
const hass1 = createHASS();
|
||||
manager.setHASS(hass1);
|
||||
expect(listener).toBeCalledWith(hass1, null);
|
||||
|
||||
expect(api.getStyleManager().applyTheme).toBeCalled();
|
||||
});
|
||||
const hass2 = createHASS();
|
||||
manager.setHASS(hass2);
|
||||
expect(listener).toBeCalledWith(hass2, hass1);
|
||||
});
|
||||
|
||||
it('should set condition manager state', () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new HASSManager(api);
|
||||
const hass = createHASS();
|
||||
it('should call listeners in insertion order on every fan-out', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const order: string[] = [];
|
||||
manager.addListener(() => order.push('first'));
|
||||
manager.addListener(() => order.push('second'));
|
||||
|
||||
manager.setHASS(hass);
|
||||
manager.setHASS(createHASS());
|
||||
|
||||
expect(api.getConditionStateManager().setState).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
hass: hass,
|
||||
}),
|
||||
);
|
||||
expect(order).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('should detach a listener via the returned unlisten callback', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
const unlisten = manager.addListener(listener);
|
||||
|
||||
unlisten();
|
||||
manager.setHASS(createHASS());
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not fan out on null/undefined hass', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
const listener = vi.fn();
|
||||
manager.addListener(listener);
|
||||
|
||||
manager.setHASS(null);
|
||||
manager.setHASS();
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should expose current hass via getHASS for source consumers', () => {
|
||||
const manager = new HASSManager(createCardAPI());
|
||||
expect(manager.getHASS()).toBeNull();
|
||||
|
||||
const hass = createHASS();
|
||||
manager.setHASS(hass);
|
||||
expect(manager.getHASS()).toBe(hass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle connection state change when', () => {
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
||||
import { createHASS, createStateEntity } from '../../test-utils';
|
||||
import { createHASS, createHASSSource, createStateEntity } from '../../test-utils';
|
||||
|
||||
describe('StateWatcher', () => {
|
||||
it('should not subscribe with no entities', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const { source } = createHASSSource();
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
expect(stateWatcher.subscribe(vi.fn(), [])).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should attach to the source lazily on first subscriber', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
stateWatcher.subscribe(vi.fn(), ['binary_sensor.foo']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should stay attached while other subscribers remain', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
|
||||
stateWatcher.subscribe(cb1, ['binary_sensor.foo']);
|
||||
stateWatcher.subscribe(cb2, ['binary_sensor.bar']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
stateWatcher.unsubscribe(cb1);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should detach from the source when the last subscriber leaves', () => {
|
||||
const { source, getListenerCount } = createHASSSource(createHASS());
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
expect(stateWatcher.subscribe(callback, [])).toBeFalsy();
|
||||
|
||||
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
stateWatcher.unsubscribe(callback);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should call back with state change', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.bar'])).toBeTruthy();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
'binary_sensor.bar': createStateEntity({ state: 'on' }),
|
||||
@@ -46,24 +72,33 @@ describe('StateWatcher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call back without state change', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
it('should not call back when oldHass is null on first observed push', () => {
|
||||
const { source, push } = createHASSSource(null);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
stateWatcher.subscribe(callback, ['binary_sensor.foo']);
|
||||
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
});
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
it('should not call back without state change', () => {
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
@@ -73,24 +108,17 @@ describe('StateWatcher', () => {
|
||||
});
|
||||
|
||||
it('should not call back when unsubscribed', () => {
|
||||
const stateWatcher = new StateWatcher();
|
||||
const initial = createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
});
|
||||
const { source, push } = createHASSSource(initial);
|
||||
const stateWatcher = new StateWatcher(source);
|
||||
const callback = vi.fn();
|
||||
|
||||
expect(stateWatcher.subscribe(callback, ['binary_sensor.foo'])).toBeTruthy();
|
||||
expect(stateWatcher.unsubscribe(callback));
|
||||
stateWatcher.unsubscribe(callback);
|
||||
|
||||
stateWatcher.setHASS(
|
||||
null,
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
|
||||
stateWatcher.setHASS(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'on' }),
|
||||
}),
|
||||
push(
|
||||
createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'off' }),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
@@ -293,4 +293,89 @@ describe('InitializationManager', () => {
|
||||
|
||||
expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.CAMERAS);
|
||||
});
|
||||
|
||||
describe('should decide whether to trigger initialization', () => {
|
||||
const createReadyAPI = () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(true);
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_RUNNING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
vi.mocked(
|
||||
api.getIssueManager().getStateManager().hasFullCardIssue,
|
||||
).mockReturnValue(false);
|
||||
return api;
|
||||
};
|
||||
|
||||
it('should initialize when all conditions are met', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize without config', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(false);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when the element is disconnected', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when hass is not ready', () => {
|
||||
const api = createReadyAPI();
|
||||
const hass = createHASS();
|
||||
hass.connected = true;
|
||||
hass.config.state = STATE_STARTING;
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize when already initialized', () => {
|
||||
const initializer = mock<Initializer>();
|
||||
initializer.isInitializedMultiple.mockReturnValue(true);
|
||||
const manager = new InitializationManager(createReadyAPI(), initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not initialize while a full-card issue is shown', () => {
|
||||
const api = createReadyAPI();
|
||||
vi.mocked(
|
||||
api.getIssueManager().getStateManager().hasFullCardIssue,
|
||||
).mockReturnValue(true);
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
|
||||
manager.triggerInitialization();
|
||||
|
||||
expect(initializer.initializeMultipleIfNecessary).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory'
|
||||
import { IssueManager } from '../../../src/card-controller/issues/issue-manager';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
import { createSubscriptionHealth } from '../test-utils';
|
||||
|
||||
describe('createIssueManager', () => {
|
||||
beforeEach(() => {
|
||||
@@ -15,12 +16,15 @@ describe('createIssueManager', () => {
|
||||
});
|
||||
|
||||
it('should return a IssueManager instance', () => {
|
||||
const manager = createIssueManager(createCardAPI());
|
||||
const manager = createIssueManager(createCardAPI(), createSubscriptionHealth());
|
||||
expect(manager).toBeInstanceOf(IssueManager);
|
||||
});
|
||||
|
||||
it('should register all expected issues', () => {
|
||||
const manager = createIssueManager(createCardAPI()).getStateManager();
|
||||
const manager = createIssueManager(
|
||||
createCardAPI(),
|
||||
createSubscriptionHealth(),
|
||||
).getStateManager();
|
||||
|
||||
expect(manager.getIssueDescriptions()).toHaveLength(0);
|
||||
|
||||
@@ -29,6 +33,7 @@ describe('createIssueManager', () => {
|
||||
'config_upgrade',
|
||||
'config_upgrade_failure',
|
||||
'connection',
|
||||
'event_subscription',
|
||||
'initialization',
|
||||
'legacy_resource',
|
||||
'media_query',
|
||||
@@ -51,7 +56,7 @@ describe('createIssueManager', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
const manager = createIssueManager(api);
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
manager.trigger('media_query', { error: new Error('x') });
|
||||
manager.trigger('initialization', { error: new Error('x') });
|
||||
@@ -76,7 +81,7 @@ describe('createIssueManager', () => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
|
||||
const manager = createIssueManager(api);
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
// Setting view starts the media_load timer (via the condition state
|
||||
// listener → evaluate → detectDynamic).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EventSubscriptionIssue } from '../../../../src/card-controller/issues/issues/event-subscription';
|
||||
import { Issue } from '../../../../src/card-controller/issues/types';
|
||||
import { SubscriptionFailure } from '../../../../src/ha/connection/subscription-health-monitor';
|
||||
import { localize } from '../../../../src/localize/localize';
|
||||
import { createSubscriptionHealth } from '../../test-utils';
|
||||
|
||||
const createSubscriptionFailure = (key: string): SubscriptionFailure<string> => ({
|
||||
key,
|
||||
error: new Error(key),
|
||||
failureCount: 1,
|
||||
});
|
||||
|
||||
describe('EventSubscriptionIssue', () => {
|
||||
it('should register the change callback as a health listener', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const changeCallback = vi.fn();
|
||||
|
||||
new EventSubscriptionIssue(health, changeCallback);
|
||||
|
||||
expect(health.addListener).toBeCalledWith(changeCallback);
|
||||
});
|
||||
|
||||
it('should have no issue when there are no failures', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
expect(issue.getIssue()).toBeNull();
|
||||
expect(issue.getNotification()).toBeNull();
|
||||
});
|
||||
|
||||
it('should describe the failing event types sorted, at medium severity', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
health.getFailures.mockReturnValue([
|
||||
createSubscriptionFailure('zebra_event'),
|
||||
createSubscriptionFailure('alpha_event'),
|
||||
]);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.hasIssue()).toBe(true);
|
||||
|
||||
const description = issue.getIssue();
|
||||
expect(description?.severity).toBe('medium');
|
||||
expect(description?.notification.heading?.text).toBe(
|
||||
localize('issues.event_subscription.heading'),
|
||||
);
|
||||
expect(description?.notification.metadata?.map((detail) => detail.text)).toEqual([
|
||||
'alpha_event',
|
||||
'zebra_event',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should offer a retry control on the notification', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
health.getFailures.mockReturnValue([createSubscriptionFailure('zha_event')]);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.getNotification()?.controls?.[0].icon).toBe('mdi:refresh');
|
||||
});
|
||||
|
||||
it('should re-drive the failing subscriptions on retry', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
expect(issue.retry()).toBe(true);
|
||||
expect(health.retry).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not opt into IssueManager-scheduled retries', () => {
|
||||
// No `needsRetry()` means the subscription manager stays the sole auto-retry
|
||||
// loop; the IssueManager never schedules this issue.
|
||||
const issue: Issue = new EventSubscriptionIssue(createSubscriptionHealth(), vi.fn());
|
||||
|
||||
expect(issue.needsRetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove its health listener on destroy', () => {
|
||||
const health = createSubscriptionHealth();
|
||||
const unsubscribe = vi.fn();
|
||||
health.addListener.mockReturnValue(unsubscribe);
|
||||
const issue = new EventSubscriptionIssue(health, vi.fn());
|
||||
|
||||
issue.destroy();
|
||||
|
||||
expect(unsubscribe).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -539,7 +539,7 @@ describe('IssueStateManager', () => {
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should destroy all issues and clear', () => {
|
||||
it('should clear, reset and destroy all issues', () => {
|
||||
const manager = createManager();
|
||||
manager.destroy();
|
||||
|
||||
@@ -549,6 +549,14 @@ describe('IssueStateManager', () => {
|
||||
expect(mockConfigUpgrade.reset).toBeCalled();
|
||||
expect(mockLegacyResource.reset).toBeCalled();
|
||||
expect(mockMediaLoad.reset).toBeCalled();
|
||||
|
||||
assert(mockConfigUpgrade.destroy);
|
||||
assert(mockLegacyResource.destroy);
|
||||
assert(mockMediaLoad.destroy);
|
||||
expect(mockConfigUpgrade.destroy).toBeCalled();
|
||||
expect(mockLegacyResource.destroy).toBeCalled();
|
||||
expect(mockMediaLoad.destroy).toBeCalled();
|
||||
|
||||
expect(manager.getIssuePresence().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mock, MockProxy } from 'vitest-mock-extended';
|
||||
import { SubscriptionHealthInterface } from '../../src/ha/connection/subscription-health-monitor';
|
||||
|
||||
// A benign mocked event-subscription health surface: no failures, listeners
|
||||
// return a no-op unsubscribe. Tests configure `getFailures`/`retry` as needed.
|
||||
export const createSubscriptionHealth = (): MockProxy<
|
||||
SubscriptionHealthInterface<string>
|
||||
> => {
|
||||
const health = mock<SubscriptionHealthInterface<string>>();
|
||||
health.getFailures.mockReturnValue([]);
|
||||
health.addListener.mockReturnValue(vi.fn());
|
||||
return health;
|
||||
};
|
||||
Reference in New Issue
Block a user