Files

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();
});
});
});