528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { EventWatcher } from '../../../src/card-controller/hass/event-watcher';
|
|
import { HASSManager } from '../../../src/card-controller/hass/hass-manager';
|
|
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
|
|
import { InitializationAspect } from '../../../src/card-controller/initialization/initialization-manager';
|
|
import type { HomeAssistant } from '../../../src/ha/types';
|
|
import { createCameraManager, createStore } from '../../camera-manager/test-utils';
|
|
import { createCameraConfig, createConfig } from '../../config/test-utils';
|
|
import { createCardAPI, createHASS, createStateEntity } from '../../test-utils';
|
|
import { createView } from '../../view/test-utils';
|
|
|
|
// The Home Assistant frontend merges each update into the previous `hass`, so
|
|
// until it has read the configuration again the card keeps being handed the
|
|
// exact object it already had.
|
|
const createHASSWithConfigOf = (
|
|
source: HomeAssistant,
|
|
options: { connected: boolean },
|
|
): HomeAssistant => {
|
|
const hass = createHASS();
|
|
hass.connected = options.connected;
|
|
hass.config = source.config;
|
|
return hass;
|
|
};
|
|
|
|
describe('HASSManager', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('should have null hass on construction', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
expect(manager.getHASS()).toBeNull();
|
|
expect(manager.hasHASS()).toBeFalsy();
|
|
});
|
|
|
|
it('should get state watcher', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
expect(manager.getStateWatcher()).toEqual(expect.any(StateWatcher));
|
|
});
|
|
|
|
it('should get event watcher', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
expect(manager.getEventWatcher()).toEqual(expect.any(EventWatcher));
|
|
});
|
|
|
|
it('should get hass after set', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.getHASS()).toBe(hass);
|
|
expect(manager.hasHASS()).toBeTruthy();
|
|
});
|
|
|
|
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);
|
|
|
|
const hass1 = createHASS();
|
|
manager.setHASS(hass1);
|
|
expect(listener).toHaveBeenCalledWith(hass1, null);
|
|
|
|
const hass2 = createHASS();
|
|
manager.setHASS(hass2);
|
|
expect(listener).toHaveBeenCalledWith(hass2, hass1);
|
|
});
|
|
|
|
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(createHASS());
|
|
|
|
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.toHaveBeenCalled();
|
|
});
|
|
|
|
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.toHaveBeenCalled();
|
|
});
|
|
|
|
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 report readiness when', () => {
|
|
it('should report not ready before any hass is set', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
|
|
expect(manager.isReady()).toBeFalsy();
|
|
});
|
|
|
|
it('should report not ready when disconnected', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = false;
|
|
hass.config.state = STATE_RUNNING;
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.isReady()).toBeFalsy();
|
|
});
|
|
|
|
it('should report not ready while integrations are still loading', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = true;
|
|
hass.config.state = STATE_STARTING;
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.isReady()).toBeFalsy();
|
|
});
|
|
|
|
it('should report not ready when reconnected with the pre-disconnect config', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
|
|
const hass = createHASS();
|
|
hass.connected = true;
|
|
hass.config.state = STATE_RUNNING;
|
|
manager.setHASS(hass);
|
|
expect(manager.isReady()).toBeTruthy();
|
|
|
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: false }));
|
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: true }));
|
|
|
|
expect(manager.isReady()).toBeFalsy();
|
|
});
|
|
|
|
it('should report ready when connected and running', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = true;
|
|
hass.config.state = STATE_RUNNING;
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.isReady()).toBeTruthy();
|
|
});
|
|
|
|
it('should report disconnected readiness before any hass is set', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
expect(manager.getReadiness()).toBe('disconnected');
|
|
});
|
|
|
|
it('should report disconnected readiness when disconnected', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = false;
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.getReadiness()).toBe('disconnected');
|
|
});
|
|
|
|
it('should report starting readiness while integrations are still loading', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = true;
|
|
hass.config.state = STATE_STARTING;
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.getReadiness()).toBe('starting');
|
|
});
|
|
|
|
it('should report starting readiness when reconnected with stale config', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = true;
|
|
hass.config.state = STATE_RUNNING;
|
|
manager.setHASS(hass);
|
|
|
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: false }));
|
|
manager.setHASS(createHASSWithConfigOf(hass, { connected: true }));
|
|
|
|
expect(manager.getReadiness()).toBe('starting');
|
|
});
|
|
|
|
it('should report ready readiness when connected and running', () => {
|
|
const manager = new HASSManager(createCardAPI());
|
|
const hass = createHASS();
|
|
hass.connected = true;
|
|
hass.config.state = STATE_RUNNING;
|
|
manager.setHASS(hass);
|
|
|
|
expect(manager.getReadiness()).toBe('ready');
|
|
});
|
|
});
|
|
|
|
describe('should handle connection state change when', () => {
|
|
it('should end the session on ready → lost transition', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
expect(
|
|
api.getInitializationManager().getSessionManager().end,
|
|
).not.toHaveBeenCalled();
|
|
|
|
const disconnectedHASS = createHASS();
|
|
disconnectedHASS.connected = false;
|
|
manager.setHASS(disconnectedHASS);
|
|
|
|
// A card cannot be started without Home Assistant, so the session ends
|
|
// when it goes away rather than when it returns. The aspects built during
|
|
// the session are untouched until then.
|
|
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Ending it would be unrecoverable: a call with no hass leaves `_hass`
|
|
// untouched, so the next ready hass does not look like a transition and
|
|
// nothing would restart the session.
|
|
it('should not end the session when given no hass at all', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
manager.setHASS(null);
|
|
manager.setHASS();
|
|
|
|
expect(
|
|
api.getInitializationManager().getSessionManager().end,
|
|
).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reinitialize cameras and view on lost → ready transition', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
// First establish a fully-ready state.
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
// Simulate disconnection.
|
|
const disconnectedHASS = createHASS();
|
|
disconnectedHASS.connected = false;
|
|
manager.setHASS(disconnectedHASS);
|
|
|
|
// Simulate full recovery (connected AND running).
|
|
const recoveredHASS = createHASS();
|
|
recoveredHASS.connected = true;
|
|
recoveredHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(recoveredHASS);
|
|
|
|
// Cameras and view should be uninitialized so they get re-subscribed
|
|
// to event sources (e.g. Frigate WebSocket events) on the next
|
|
// render cycle.
|
|
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
|
|
InitializationAspect.CAMERAS,
|
|
);
|
|
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
|
|
InitializationAspect.VIEW,
|
|
);
|
|
expect(api.getCameraManager().destroy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reinitialize on starting → ready transition (integrations finished loading)', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
// WebSocket reconnected but HA still booting.
|
|
const startingHASS = createHASS();
|
|
startingHASS.connected = true;
|
|
startingHASS.config.state = STATE_STARTING;
|
|
manager.setHASS(startingHASS);
|
|
|
|
// No reinit yet -- HA isn't fully ready.
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
|
|
|
// HA finishes booting.
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
|
|
InitializationAspect.CAMERAS,
|
|
);
|
|
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
|
|
InitializationAspect.VIEW,
|
|
);
|
|
expect(api.getCameraManager().destroy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not reinitialize on lost → starting transition', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const disconnectedHASS = createHASS();
|
|
disconnectedHASS.connected = false;
|
|
manager.setHASS(disconnectedHASS);
|
|
|
|
const startingHASS = createHASS();
|
|
startingHASS.connected = true;
|
|
startingHASS.config.state = STATE_STARTING;
|
|
manager.setHASS(startingHASS);
|
|
|
|
// WS came back but integrations still loading -- wait for RUNNING.
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not reinitialize on first hass set (no previous hass)', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
// First-ever hass set -- there's no "previous not-ready state" to
|
|
// transition from, so the normal first-load init flow applies and we must
|
|
// not blow away cameras.
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not reinitialize on ready → ready (no transition)', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
const anotherReadyHASS = createHASS();
|
|
anotherReadyHASS.connected = true;
|
|
anotherReadyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(anotherReadyHASS);
|
|
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not crash when hass is null', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
const connectedHASS = createHASS();
|
|
connectedHASS.connected = true;
|
|
|
|
manager.setHASS(connectedHASS);
|
|
manager.setHASS(null);
|
|
manager.setHASS(connectedHASS);
|
|
});
|
|
|
|
it('should end the session when the connection is lost and the configuration is unchanged', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
|
|
|
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not reinitialize while a reconnection still reports the pre-disconnection configuration', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
|
|
|
// The socket is back, but Home Assistant has not answered the frontend's
|
|
// request for its configuration, so the RUNNING it reports is the one
|
|
// from before the restart.
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true }));
|
|
|
|
expect(manager.isReady()).toBeFalsy();
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not reinitialize when a freshly read configuration reports Home Assistant still starting', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true }));
|
|
|
|
const startingHASS = createHASS();
|
|
startingHASS.connected = true;
|
|
startingHASS.config.state = STATE_STARTING;
|
|
manager.setHASS(startingHASS);
|
|
|
|
expect(manager.isReady()).toBeFalsy();
|
|
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
|
|
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reinitialize once after a freshly read configuration reports Home Assistant running', () => {
|
|
const api = createCardAPI();
|
|
const manager = new HASSManager(api);
|
|
|
|
const readyHASS = createHASS();
|
|
readyHASS.connected = true;
|
|
readyHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(readyHASS);
|
|
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: false }));
|
|
manager.setHASS(createHASSWithConfigOf(readyHASS, { connected: true }));
|
|
|
|
const startingHASS = createHASS();
|
|
startingHASS.connected = true;
|
|
startingHASS.config.state = STATE_STARTING;
|
|
manager.setHASS(startingHASS);
|
|
|
|
const recoveredHASS = createHASS();
|
|
recoveredHASS.connected = true;
|
|
recoveredHASS.config.state = STATE_RUNNING;
|
|
manager.setHASS(recoveredHASS);
|
|
|
|
expect(manager.isReady()).toBeTruthy();
|
|
expect(api.getCameraManager().destroy).toHaveBeenCalledOnce();
|
|
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
|
|
InitializationAspect.CAMERAS,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('should not set default view when', () => {
|
|
it('should not set default view when selected camera is unknown', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
|
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
|
createStore([
|
|
{
|
|
cameraID: 'camera.foo',
|
|
config: createCameraConfig({
|
|
triggers: {
|
|
entities: ['binary_sensor.motion'],
|
|
},
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
|
createView({
|
|
camera: 'camera.UNKNOWN',
|
|
}),
|
|
);
|
|
|
|
const manager = new HASSManager(api);
|
|
const hass = createHASS({
|
|
'binary_sensor.motion': createStateEntity(),
|
|
});
|
|
|
|
manager.setHASS(hass);
|
|
|
|
expect(api.getViewManager().setViewDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not set default view when there is card interaction', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
|
createConfig({
|
|
view: {
|
|
default_reset: {
|
|
entities: ['sensor.force_default_view'],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true);
|
|
|
|
const manager = new HASSManager(api);
|
|
const hass = createHASS({
|
|
'sensor.force_default_view': createStateEntity(),
|
|
});
|
|
|
|
manager.setHASS(hass);
|
|
|
|
expect(api.getViewManager().setViewDefault).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|