fix: Clear the initialized condition state when the card is not usable (#2646)

The `initialized` state (used in conditions/triggers) was written once
and never cleared, so it meant "has this card ever been initialized"
while everything reading it took it as "is this card usable now". Home
Assistant takes a card off the page and puts it back whenever its
dashboard tab is left and returned to, so the card initialized again
while the state claimed it was initialized throughout: `trigger:
initialized` fired once per card rather than once per startup, and
automations were dropped in between.

The card lifecycle is now an explicit state machine (`SessionManager`),
the only writer of `initialized`, which separates a card that is
starting up from one initializing part of itself again while it runs. A
new `ever` parameter (conditions/triggers) selects the old latched
behaviour.

`remote_control` uses that parameter to keep its two camera priorities
correct under repeated starts. With `camera_priority: entity` the card
now re-reads the entity every time it starts, so a camera selected while
the card was away is picked up on return. With `camera_priority: card`
the card writes the entity on its first start only, unchanged, since
repeating that write would overwrite a camera the user had selected.

Closes: #2642


BREAKING CHANGE: `condition: initialized` is now `false` whenever the
card is not usable, and `trigger: initialized` fires each time the card
starts up rather than only the first time. Set `ever: true` on either to
keep the previous behaviour.
This commit is contained in:
Dermot Duffy
2026-08-02 12:52:58 -07:00
committed by GitHub
parent e590f72783
commit 17146c8ca6
44 changed files with 1967 additions and 654 deletions
@@ -46,9 +46,9 @@ describe('AutomationsManager', () => {
it('should do nothing without being initialized', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
false,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(false);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -63,9 +63,9 @@ describe('AutomationsManager', () => {
it('should do nothing when an issue is present', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
vi.mocked(
api.getIssueManager().getStateManager().hasFullCardIssue,
).mockReturnValue(true);
@@ -85,9 +85,9 @@ describe('AutomationsManager', () => {
it('should execute actions when triggered', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -113,7 +113,7 @@ describe('AutomationsManager', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
const isInitialized = vi.mocked(
api.getInitializationManager().isInitializedMandatory,
api.getInitializationManager().areMandatoryAspectsInitialized,
);
isInitialized.mockReturnValue(false);
const stateManager = new ConditionStateManager();
@@ -139,9 +139,9 @@ describe('AutomationsManager', () => {
it('should run actions when the ongoing conditions hold', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -167,9 +167,9 @@ describe('AutomationsManager', () => {
it('should do nothing when the ongoing conditions do not hold', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -192,9 +192,9 @@ describe('AutomationsManager', () => {
it('should do nothing when the actions are empty', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -214,9 +214,9 @@ describe('AutomationsManager', () => {
it('should prevent automation loops', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -259,9 +259,9 @@ describe('AutomationsManager', () => {
it('should reset the nested-execution counter after an overflow', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -307,9 +307,9 @@ describe('AutomationsManager', () => {
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,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -336,9 +336,9 @@ describe('AutomationsManager', () => {
it('should delete automations', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
@@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
import type { StateWatcher } from '../../src/card-controller/hass/state-watcher';
import { InitializationAspect } from '../../src/card-controller/initialization/initialization-manager';
import { QueryResults } from '../../src/view/query-results';
import { View } from '../../src/view/view';
import { createConfig } from '../config/test-utils';
@@ -238,7 +239,13 @@ describe('CardElementManager', () => {
expect(api.getKeyboardStateManager().uninitialize).toHaveBeenCalled();
expect(api.getActionsManager().uninitialize).toHaveBeenCalled();
expect(api.getCallManager().uninitialize).toHaveBeenCalled();
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith('cameras');
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.INITIAL_TRIGGER,
);
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
});
describe('should update card when', () => {
@@ -5,7 +5,7 @@ import { AutomationsManager } from '../../../src/card-controller/automations-man
import { ConfigManager } from '../../../src/card-controller/config/config-manager';
import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities';
import { setKeyboardShortcutsFromConfig } from '../../../src/card-controller/config/load-keyboard-shortcuts';
import { InitializationAspect } from '../../../src/card-controller/initialization-manager';
import { InitializationAspect } from '../../../src/card-controller/initialization/initialization-manager';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import type { Automation } from '../../../src/config/schema/automations';
import type { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types';
@@ -25,7 +25,7 @@ import {
*/
function createConfigManagerTestSetup(options?: {
hasHASS?: boolean;
isInitializedMandatory?: boolean;
areMandatoryAspectsInitialized?: boolean;
}) {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
@@ -34,9 +34,9 @@ function createConfigManagerTestSetup(options?: {
const automationsManager = new AutomationsManager(api);
vi.mocked(api.getAutomationsManager).mockReturnValue(automationsManager);
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(options?.hasHASS ?? true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
options?.isInitializedMandatory ?? true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(options?.areMandatoryAspectsInitialized ?? true);
const manager = new ConfigManager(api);
vi.mocked(api.getConfigManager).mockReturnValue(manager);
@@ -400,13 +400,13 @@ describe('ConfigManager', () => {
manager.setConfig(config);
expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith(
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
stateManager.setState({ fullscreen: true });
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith(
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
});
@@ -432,13 +432,13 @@ describe('ConfigManager', () => {
manager.setConfig(config);
expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith(
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
stateManager.setState({ fullscreen: true });
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith(
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
});
@@ -464,13 +464,13 @@ describe('ConfigManager', () => {
manager.setConfig(config);
expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith(
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalledWith(
InitializationAspect.MICROPHONE_CONNECT,
);
stateManager.setState({ fullscreen: true });
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith(
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.MICROPHONE_CONNECT,
);
});
@@ -510,9 +510,9 @@ describe('ConfigManager', () => {
expect.objectContaining({ change: { config: expect.anything() } }),
);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
stateManager.setState({ fullscreen: true });
await flushPromises();
@@ -96,6 +96,10 @@ describe('setRemoteControlEntityFromConfig', () => {
triggers: [
{
trigger: 'initialized',
// Writes the entity, so it asserts the card's camera once and does
// not repeat on a later initialization.
ever: true,
},
],
tag: setRemoteControlEntityFromConfig,
@@ -178,6 +182,10 @@ describe('setRemoteControlEntityFromConfig', () => {
triggers: [
{
trigger: 'initialized',
// Only updates the card, so it repeats on every initialization and
// picks up an entity change made while the card could not act.
ever: false,
},
],
tag: setRemoteControlEntityFromConfig,
@@ -659,9 +667,9 @@ describe('setRemoteControlEntityFromConfig', () => {
.calls[0][0];
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
true,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
+2 -2
View File
@@ -19,7 +19,7 @@ import { FoldersManager } from '../../src/card-controller/folders/manager';
import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager';
import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher';
import type { HASSManager } from '../../src/card-controller/hass/hass-manager';
import { InitializationManager } from '../../src/card-controller/initialization-manager';
import { InitializationManager } from '../../src/card-controller/initialization/initialization-manager';
import { InteractionManager } from '../../src/card-controller/interaction-manager';
import { IssueManager } from '../../src/card-controller/issues/issue-manager';
import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager';
@@ -54,7 +54,7 @@ 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/initialization-manager');
vi.mock('../../src/card-controller/initialization/initialization-manager');
vi.mock('../../src/card-controller/interaction-manager');
vi.mock('../../src/card-controller/keyboard-state-manager');
vi.mock('../../src/card-controller/lock/manager');
+59 -16
View File
@@ -4,6 +4,7 @@ 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 { createCameraManager, createStore } from '../../camera-manager/test-utils';
import { createCameraConfig, createConfig } from '../../config/test-utils';
import { createCardAPI, createHASS, createStateEntity } from '../../test-utils';
@@ -98,6 +99,50 @@ describe('HASSManager', () => {
});
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);
@@ -122,14 +167,13 @@ describe('HASSManager', () => {
// 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().uninitialize).toHaveBeenCalledWith(
'cameras',
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.VIEW,
);
expect(api.getCameraManager().destroy).toHaveBeenCalled();
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith('view');
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith(
'initial-trigger',
);
});
it('should reinitialize on starting → ready transition (integrations finished loading)', () => {
@@ -143,7 +187,7 @@ describe('HASSManager', () => {
manager.setHASS(startingHASS);
// No reinit yet -- HA isn't fully ready.
expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalled();
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
// HA finishes booting.
@@ -152,14 +196,13 @@ describe('HASSManager', () => {
readyHASS.config.state = STATE_RUNNING;
manager.setHASS(readyHASS);
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith(
'cameras',
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
expect(api.getInitializationManager().invalidateAspect).toHaveBeenCalledWith(
InitializationAspect.VIEW,
);
expect(api.getCameraManager().destroy).toHaveBeenCalled();
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith('view');
expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith(
'initial-trigger',
);
});
it('should not reinitialize on lost → starting transition', () => {
@@ -176,7 +219,7 @@ describe('HASSManager', () => {
manager.setHASS(startingHASS);
// WS came back but integrations still loading -- wait for RUNNING.
expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalled();
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
});
@@ -192,7 +235,7 @@ describe('HASSManager', () => {
// 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().uninitialize).not.toHaveBeenCalled();
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
});
@@ -210,7 +253,7 @@ describe('HASSManager', () => {
anotherReadyHASS.config.state = STATE_RUNNING;
manager.setHASS(anotherReadyHASS);
expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalled();
expect(api.getInitializationManager().invalidateAspect).not.toHaveBeenCalled();
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
});
@@ -1,445 +0,0 @@
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 {
InitializationAspect,
InitializationManager,
} from '../../src/card-controller/initialization-manager';
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager';
import { sideLoadHomeAssistantElements } from '../../src/ha/side-load-ha-elements.js';
import { loadLanguages } from '../../src/localize/localize';
import type { Initializer } from '../../src/utils/initializer/initializer';
import { createConfig } from '../config/test-utils';
import { createCardAPI, createHASS } from '../test-utils';
vi.mock('../../src/localize/localize.js');
vi.mock('../../src/ha/side-load-ha-elements.js');
describe('InitializationManager', () => {
beforeEach(async () => {
vi.resetAllMocks();
});
describe('should correctly determine when mandatory initialization is required', () => {
it('should handle without config', () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
expect(manager.isInitializedMandatory()).toBeFalsy();
});
it('should handle without aspects', () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
expect(manager.isInitializedMandatory()).toBeFalsy();
});
it('should handle with microphone if configured', () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(
api.getMicrophoneManager().shouldConnectOnInitialization,
).mockReturnValue(true);
expect(manager.isInitializedMandatory()).toBeFalsy();
});
});
describe('should initialize mandatory', () => {
it('should handle without hass', async () => {
const manager = new InitializationManager(createCardAPI());
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
});
it('should handle without config', async () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
});
it('should be a no-op when hass.config.state is not RUNNING', async () => {
const api = createCardAPI();
const hass = createHASS();
hass.config.state = STATE_STARTING;
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
await manager.initializeMandatory();
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
expect(initializer.initializeIfNecessary).not.toHaveBeenCalled();
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
expect(manager.wasEverInitialized()).toBeFalsy();
});
it('should succeed', async () => {
const stateListener = vi.fn();
const stateMananger = new ConditionStateManager();
stateMananger.addListener(stateListener);
const api = createCardAPI();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateMananger);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const config = createConfig();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
vi.mocked(
api.getIssueManager().getStateManager().hasFullCardIssue,
).mockReturnValue(false);
vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
false,
);
const manager = new InitializationManager(api);
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeFalsy();
await manager.initializeMandatory();
expect(loadLanguages).toHaveBeenCalled();
expect(sideLoadHomeAssistantElements).toHaveBeenCalled();
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled();
expect(api.getViewManager().initialize).toHaveBeenCalled();
expect(api.getMicrophoneManager().connect).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).toHaveBeenCalled();
expect(manager.wasEverInitialized()).toBeTruthy();
expect(stateListener).toHaveBeenCalledWith(
expect.objectContaining({
change: {
initialized: true,
config,
},
}),
);
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeTruthy();
expect(
manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS),
).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.INITIAL_TRIGGER)).toBeTruthy();
});
it('should load the template renderer for a templated config', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getConfigManager().hasTemplate).mockReturnValue(true);
const loadRenderer = vi.mocked(api.getTemplateManager().loadRenderer);
const manager = new InitializationManager(api);
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeFalsy();
await manager.initializeMandatory();
expect(loadRenderer).toHaveBeenCalled();
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeTruthy();
expect(manager.isInitializedMandatory()).toBeTruthy();
});
it('should not load the template renderer for a config without templates', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getConfigManager().hasTemplate).mockReturnValue(false);
const loadRenderer = vi.mocked(api.getTemplateManager().loadRenderer);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(loadRenderer).not.toHaveBeenCalled();
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeFalsy();
expect(manager.isInitializedMandatory()).toBeTruthy();
});
it('should succeed with microphone if configured', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(
api.getMicrophoneManager().shouldConnectOnInitialization,
).mockReturnValue(true);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
});
it('should handle message set during initialization', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(
api.getIssueManager().getStateManager().hasFullCardIssue,
).mockReturnValue(true);
vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue(
false,
);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(api.getViewManager().initialize).not.toHaveBeenCalled();
});
it('should handle languages and side load elements in progress', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockRejectedValue(
new Error('initialization failed'),
);
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
});
it('should handle cameras initialization failure', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
// First call (languages/side-load) succeeds, second (cameras) fails.
initializer.initializeMultipleIfNecessary
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('cameras failed'));
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
});
it('should handle initial trigger initialization failure', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
// First initializeIfNecessary call (view) succeeds, second
// (initial_trigger) fails.
initializer.initializeIfNecessary
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('triggers failed'));
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
});
it('should handle VIEW initialization failure', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
initializer.initializeIfNecessary.mockRejectedValueOnce(
new Error('view initialization failed'),
);
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
});
it('should stop without an error when an aspect reports failure', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
// An aspect that could not complete (e.g. the view when no view could be
// set) reports failure rather than throwing: the chain stops so a later
// attempt retries, and no initialization error is raised.
initializer.initializeIfNecessary.mockResolvedValueOnce(false);
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).not.toHaveBeenCalledWith(
'initialization',
expect.anything(),
);
});
it('should handle non-Error thrown during initialization', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
// Throw a non-Error to exercise the else-branch in _tryInitialize
initializer.initializeMultipleIfNecessary.mockRejectedValueOnce('string error');
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: 'string error' }),
);
});
});
it('should uninitialize mandatory aspects', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(createCardAPI(), initializer);
manager.uninitializeMandatory();
expect(initializer.uninitialize).toHaveBeenCalledWith(InitializationAspect.CAMERAS);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.MICROPHONE_CONNECT,
);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.TEMPLATE_RENDERER,
);
expect(initializer.uninitialize).toHaveBeenCalledWith(InitializationAspect.VIEW);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.INITIAL_TRIGGER,
);
});
it('should uninitialize', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(createCardAPI(), initializer);
manager.uninitialize(InitializationAspect.CAMERAS);
expect(initializer.uninitialize).toHaveBeenCalledWith(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).toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,708 @@
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 {
InitializationAspect,
InitializationManager,
} from '../../../src/card-controller/initialization/initialization-manager';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements.js';
import { loadLanguages } from '../../../src/localize/localize';
import type { Initializer } from '../../../src/utils/initializer/initializer';
import { createConfig } from '../../config/test-utils';
import { createCardAPI, createHASS } from '../../test-utils';
vi.mock('../../../src/localize/localize.js');
vi.mock('../../../src/ha/side-load-ha-elements.js');
// An API that passes the whole start predicate, checked both when an attempt is
// queued and again when it runs.
const createReadyAPI = (): ReturnType<typeof createCardAPI> => {
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;
};
describe('InitializationManager', () => {
beforeEach(async () => {
vi.resetAllMocks();
});
describe('should correctly determine when mandatory initialization is required', () => {
it('should handle without config', () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
expect(manager.areMandatoryAspectsInitialized()).toBeFalsy();
});
it('should handle without aspects', () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
expect(manager.areMandatoryAspectsInitialized()).toBeFalsy();
});
it('should handle with microphone if configured', () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(
api.getMicrophoneManager().shouldConnectOnInitialization,
).mockReturnValue(true);
expect(manager.areMandatoryAspectsInitialized()).toBeFalsy();
});
});
describe('should initialize mandatory', () => {
it('should handle without hass', async () => {
const manager = new InitializationManager(createCardAPI());
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
});
it('should handle without config', async () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
});
it('should be a no-op when hass.config.state is not RUNNING', async () => {
const api = createReadyAPI();
const hass = createHASS();
hass.config.state = STATE_STARTING;
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
await manager.initializeMandatory();
expect(initializer.initializeMultipleIfNecessary).not.toHaveBeenCalled();
expect(initializer.initializeIfNecessary).not.toHaveBeenCalled();
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
});
it('should succeed', async () => {
const stateListener = vi.fn();
const stateMananger = new ConditionStateManager();
stateMananger.addListener(stateListener);
const api = createReadyAPI();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateMananger);
const config = createConfig();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
const manager = new InitializationManager(api);
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeFalsy();
await manager.initializeMandatory();
expect(loadLanguages).toHaveBeenCalled();
expect(sideLoadHomeAssistantElements).toHaveBeenCalled();
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled();
expect(api.getViewManager().initialize).toHaveBeenCalled();
expect(api.getMicrophoneManager().connect).not.toHaveBeenCalled();
expect(api.getCardElementManager().update).toHaveBeenCalled();
expect(manager.getSessionManager().wasEverInitialized()).toBeTruthy();
expect(stateListener).toHaveBeenCalledWith(
expect.objectContaining({
change: {
initialized: true,
everInitialized: true,
config,
},
}),
);
expect(manager.isInitialized(InitializationAspect.LANGUAGES)).toBeTruthy();
expect(
manager.isInitialized(InitializationAspect.SIDE_LOAD_ELEMENTS),
).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.INITIAL_TRIGGER)).toBeTruthy();
});
it('should load the template renderer for a templated config', async () => {
const api = createReadyAPI();
vi.mocked(api.getConfigManager().hasTemplate).mockReturnValue(true);
const loadRenderer = vi.mocked(api.getTemplateManager().loadRenderer);
const manager = new InitializationManager(api);
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeFalsy();
await manager.initializeMandatory();
expect(loadRenderer).toHaveBeenCalled();
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeTruthy();
expect(manager.areMandatoryAspectsInitialized()).toBeTruthy();
});
it('should not load the template renderer for a config without templates', async () => {
const api = createReadyAPI();
vi.mocked(api.getConfigManager().hasTemplate).mockReturnValue(false);
const loadRenderer = vi.mocked(api.getTemplateManager().loadRenderer);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(loadRenderer).not.toHaveBeenCalled();
expect(manager.isInitialized(InitializationAspect.TEMPLATE_RENDERER)).toBeFalsy();
expect(manager.areMandatoryAspectsInitialized()).toBeTruthy();
});
it('should succeed with microphone if configured', async () => {
const api = createReadyAPI();
vi.mocked(
api.getMicrophoneManager().shouldConnectOnInitialization,
).mockReturnValue(true);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
});
it('should not report a session that ended while it was initializing', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new InitializationManager(api);
// The card leaves the page midway through, e.g. because the dashboard tab
// changed while the cameras were still initializing.
vi.mocked(api.getViewManager().initialize).mockImplementation(async () => {
manager.getSessionManager().end();
return true;
});
await manager.initializeMandatory();
// Ending a session before the card started writes nothing: there is
// nothing to take back.
expect(stateManager.getState().initialized).toBeUndefined();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
});
it('should stop when a full-card issue appears during initialization', async () => {
const api = createReadyAPI();
// The predicate passes at dequeue time, then a full-card issue (from any
// source) appears after the first step: the run stops there without an error
// of its own.
vi.mocked(api.getIssueManager().getStateManager().hasFullCardIssue)
.mockReturnValueOnce(false)
.mockReturnValue(true);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(api.getCameraManager().initializeCamerasFromConfig).not.toHaveBeenCalled();
expect(api.getViewManager().initialize).not.toHaveBeenCalled();
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
});
it('should handle a languages and side load elements failure', async () => {
const api = createReadyAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockRejectedValue(
new Error('initialization failed'),
);
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
});
it('should handle cameras initialization failure', async () => {
const api = createReadyAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
// First call (languages/side-load) succeeds, second (cameras) fails.
initializer.initializeMultipleIfNecessary
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('cameras failed'));
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
});
it('should handle initial trigger initialization failure', async () => {
const api = createReadyAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
// First initializeIfNecessary call (view) succeeds, second
// (initial_trigger) fails.
initializer.initializeIfNecessary
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('triggers failed'));
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
});
it('should handle VIEW initialization failure', async () => {
const api = createReadyAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
initializer.initializeIfNecessary.mockRejectedValueOnce(
new Error('view initialization failed'),
);
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
});
it('should stop without an error when an aspect declines', async () => {
const api = createReadyAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
// An aspect that could not complete (e.g. the view when no view could be
// set) declines rather than throwing: the run stops so a later attempt
// retries, and no initialization error is raised.
initializer.initializeIfNecessary.mockResolvedValueOnce(false);
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).not.toHaveBeenCalledWith(
'initialization',
expect.anything(),
);
});
it('should handle non-Error thrown during initialization', async () => {
const api = createReadyAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
// Throw a non-Error to exercise the else-branch in _runStep
initializer.initializeMultipleIfNecessary.mockRejectedValueOnce('string error');
await manager.initializeMandatory();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: 'string error' }),
);
});
it('should decline when the config vanishes mid-initialization', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
// The config disappears while languages load. The CAMERAS initializer
// quietly does nothing without a configuration, so the run must stop
// before that aspect would be marked initialized against nothing.
vi.mocked(loadLanguages).mockImplementation(async () => {
vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(false);
});
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(api.getCameraManager().initializeCamerasFromConfig).not.toHaveBeenCalled();
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
expect(stateManager.getState().initialized).toBeUndefined();
});
it('should subscribe automations before reporting the card started', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
// A trigger watching `initialized` or `config` must already be attached
// when that state is written, so it can fire on that very change.
const order: string[] = [];
vi.mocked(api.getAutomationsManager().subscribe).mockImplementation(() => {
order.push('subscribe');
});
stateManager.addListener(() => order.push('report'));
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(order).toEqual(['subscribe', 'report']);
});
it('should read the config when reporting the card started', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
// The configuration changes while the view initializes: the card must be
// reported started against the configuration as it stands then, not as it
// stood when the run began.
const newConfig = createConfig({ menu: { style: 'none' } });
vi.mocked(api.getViewManager().initialize).mockImplementation(async () => {
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(newConfig);
return true;
});
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBe(true);
expect(stateManager.getState().config).toBe(newConfig);
});
it('should decline when the config is null at the end of a run', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
vi.mocked(api.getViewManager().initialize).mockImplementation(async () => {
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
return true;
});
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBeUndefined();
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
// A configuration can only return by being set, which invalidates the
// VIEW aspect -- after which the next attempt reports normally.
const config = createConfig();
vi.mocked(api.getViewManager().initialize).mockResolvedValue(true);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config);
manager.invalidateAspect(InitializationAspect.VIEW);
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBe(true);
expect(stateManager.getState().config).toBe(config);
});
});
describe('should handle later runs in a session', () => {
it('should end the session when a later run fails', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBe(true);
manager.invalidateAspect(InitializationAspect.VIEW);
vi.mocked(api.getViewManager().initialize).mockRejectedValue(
new Error('view failed'),
);
await manager.initializeMandatory();
expect(api.getIssueManager().trigger).toHaveBeenCalledWith(
'initialization',
expect.objectContaining({ error: expect.any(Error) }),
);
expect(stateManager.getState().initialized).toBe(false);
// A card that has come back down has still ever been initialized.
expect(stateManager.getState().everInitialized).toBe(true);
});
it('should keep the session when a later run declines', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new InitializationManager(api);
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBe(true);
manager.invalidateAspect(InitializationAspect.VIEW);
vi.mocked(api.getViewManager().initialize).mockResolvedValue(false);
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBe(true);
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
});
});
describe('should refuse initializations that were overtaken', () => {
it('should refuse an attempt queued before a disconnect', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new InitializationManager(api);
let releaseCameras = (): void => {};
const blockedCameras = new Promise<void>((resolve) => {
releaseCameras = resolve;
});
vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockImplementation(
() => blockedCameras,
);
const first = manager.initializeMandatory();
const second = manager.initializeMandatory();
await vi.waitFor(() =>
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled(),
);
// The card leaves the page while the first initialization awaits the
// cameras and the second attempt waits in the queue.
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
manager.invalidateAspect(InitializationAspect.CAMERAS);
manager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
manager.getSessionManager().end();
releaseCameras();
await first;
await second;
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalledTimes(
1,
);
expect(stateManager.getState().initialized).toBeUndefined();
expect(manager.getSessionManager().wasEverInitialized()).toBeFalsy();
});
it('should not raise an issue when a superseded initialization fails', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new InitializationManager(api);
let failCameras = (): void => {};
const blockedCameras = new Promise<void>((_, reject) => {
failCameras = (): void => reject(new Error('cameras torn down'));
});
vi.mocked(api.getCameraManager().initializeCamerasFromConfig).mockImplementation(
() => blockedCameras,
);
const attempt = manager.initializeMandatory();
await vi.waitFor(() =>
expect(api.getCameraManager().initializeCamerasFromConfig).toHaveBeenCalled(),
);
// The card leaves the page, and the in-flight camera work then fails
// because of that very teardown. An error from a card state that no
// longer exists must not become a full-card issue that greets the card
// when it returns.
vi.mocked(api.getCardElementManager().isConnected).mockReturnValue(false);
manager.invalidateAspect(InitializationAspect.CAMERAS);
manager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
manager.getSessionManager().end();
failCameras();
await attempt;
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
expect(stateManager.getState().initialized).toBeUndefined();
});
it('should decline when an aspect was invalidated mid-initialization', async () => {
const api = createReadyAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new InitializationManager(api);
// A configuration change lands while the initial trigger step runs,
// invalidating an aspect an earlier step already completed. Reporting the
// card started then would call it ready with stale cameras -- and the
// initialization that follows would not make `initialized` change again.
vi.mocked(
api.getCameraTriggersManager().handleInitialCameraTriggers,
).mockImplementation(async () => {
manager.invalidateAspect(InitializationAspect.CAMERAS);
return true;
});
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBeUndefined();
expect(api.getIssueManager().trigger).not.toHaveBeenCalled();
// The next attempt initializes the invalidated aspect and reports the
// card started.
await manager.initializeMandatory();
expect(stateManager.getState().initialized).toBe(true);
});
});
describe('should invalidate aspects', () => {
const createInitializedAPI = (): {
api: ReturnType<typeof createCardAPI>;
initializer: ReturnType<typeof mock<Initializer>>;
stateManager: ConditionStateManager;
} => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
stateManager.setState({ initialized: true, everInitialized: true });
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
return { api, initializer: mock<Initializer>(), stateManager };
};
it('should invalidate mandatory aspects without ending the session', () => {
const { api, initializer, stateManager } = createInitializedAPI();
const manager = new InitializationManager(api, initializer);
manager.invalidateMandatoryAspects();
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.MICROPHONE_CONNECT,
);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.TEMPLATE_RENDERER,
);
expect(initializer.uninitialize).toHaveBeenCalledWith(InitializationAspect.VIEW);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.INITIAL_TRIGGER,
);
expect(stateManager.getState().initialized).toBe(true);
});
it('should keep the current session when an aspect is being invalidated', () => {
const { api, initializer, stateManager } = createInitializedAPI();
const manager = new InitializationManager(api, initializer);
manager.invalidateAspect(InitializationAspect.CAMERAS);
expect(initializer.uninitialize).toHaveBeenCalledWith(
InitializationAspect.CAMERAS,
);
expect(stateManager.getState().initialized).toBe(true);
});
});
describe('should decide whether to trigger initialization', () => {
it('should initialize when all conditions are met', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(createReadyAPI(), initializer);
manager.triggerInitialization();
expect(initializer.initializeMultipleIfNecessary).toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
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.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from 'vitest';
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { MountedCard } from '../../browser/mounted-card';
import {
createStillCameraHASS,
createStillImageCameraConfig,
createStillImageCardConfig,
isMediaLoadedInfoEventDetail,
} from '../../browser/test-utils';
const STARTED_MESSAGE = 'card-started';
const OTHER_CAMERA_ENTITY = 'camera.other';
const createConfig = (
overrides?: Partial<RawAdvancedCameraCardConfig>,
): RawAdvancedCameraCardConfig =>
createStillImageCardConfig({
automations: [
{
triggers: [{ trigger: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'log',
message: STARTED_MESSAGE,
},
],
},
],
...overrides,
});
const mount = async (
overrides?: Partial<RawAdvancedCameraCardConfig>,
): Promise<MountedCard> =>
await MountedCard.create(
createConfig(overrides),
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
);
// Only the messages the automation logged, since the card logs other things at
// the same level.
const getStartedMessages = (card: MountedCard): string[] =>
card.console.getMessages('info').filter((message) => message === STARTED_MESSAGE);
// The cameras the card has actually loaded media for, in order. Media that
// named no camera is left out, since these are only read to ask which camera
// the card ended up on.
const getLoadedCameraIDs = (card: MountedCard): string[] =>
card.events
.getEntries('advanced-camera-card:media:loaded')
.map((entry) => entry.detail)
.filter(isMediaLoadedInfoEventDetail)
.map((detail) => detail.info.targetID)
.filter((targetID): targetID is string => targetID !== undefined);
describe('SessionManager', () => {
it('should fire an initialized trigger each time the card starts', async () => {
const card = await mount();
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
// The card leaving the page is a change of the value the trigger watches,
// and must not fire it.
card.detach();
await card.updateComplete;
expect(getStartedMessages(card)).toHaveLength(1);
card.attach();
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
});
it('should start the card again once Home Assistant comes back', async () => {
const card = await mount();
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
// Losing Home Assistant changes the value the trigger watches, and must not
// fire it.
card.setConnected(false);
await card.updateComplete;
expect(getStartedMessages(card)).toHaveLength(1);
card.setConnected(true);
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
});
it('should initialize the new cameras without starting the card again', async () => {
const card = await mount();
await card.events.waitForFirst('advanced-camera-card:media:loaded');
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
// A camera change is the heaviest configuration change there is: the
// cameras are destroyed and initialized again.
card.setConfig(
createConfig({ cameras: [createStillImageCameraConfig(OTHER_CAMERA_ENTITY)] }),
);
// Media loading for the new camera is what says the change took effect at
// all, without which the assertion below would pass on a card that ignored
// the configuration.
await vi.waitFor(() =>
expect(getLoadedCameraIDs(card)).toContain(OTHER_CAMERA_ENTITY),
);
expect(getStartedMessages(card)).toHaveLength(1);
});
it('should apply an override keyed on the card being started every time it starts', async () => {
// The menu is configured away and the override is the only thing that
// brings it back, so a menu on screen means the condition matched.
const card = await mount({
menu: { style: 'none' },
overrides: [
{
conditions: [{ condition: 'initialized' }],
merge: { menu: { style: 'outside' } },
},
],
});
await card.waitForSelector('advanced-camera-card-menu');
card.detach();
card.attach();
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
await card.waitForSelector('advanced-camera-card-menu');
});
it('should not show the loading indicator again once the card has started', async () => {
const card = await mount({
performance: { features: { card_loading_indicator: true } },
});
const loading = await card.waitForSelector('advanced-camera-card-loading');
await vi.waitFor(() => expect(loading.hasAttribute('loaded')).toBe(true));
card.detach();
card.attach();
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(2));
// A card that has been on screen once must not show the loading indicator
// again when it is re-attached.
expect(
(await card.waitForSelector('advanced-camera-card-loading')).hasAttribute(
'loaded',
),
).toBe(true);
});
});
@@ -0,0 +1,274 @@
import { describe, expect, it, vi } from 'vitest';
import {
SessionManager,
SessionState,
} from '../../../src/card-controller/initialization/session-manager';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import { createTriggerEvaluator } from '../../../src/condition-trigger/triggers/factory';
import { initializedTriggerSchema } from '../../../src/config/schema/condition-trigger/triggers/custom/initialized';
import { createTriggerEvaluatorContext } from '../../condition-trigger/triggers/triggers/test-utils';
import { createConfig } from '../../config/test-utils';
import { createCardAPI } from '../../test-utils';
const createSessionManager = (): {
sessionManager: SessionManager;
stateManager: ConditionStateManager;
} => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
return { sessionManager: new SessionManager(api), stateManager };
};
// Take a session through a successful initialization run, leaving the card
// started (RUNNING).
const completeInitialization = (
sessionManager: SessionManager,
config = createConfig(),
): void => {
sessionManager.reportInitializationSucceeded(
sessionManager.startInitialization(),
config,
);
};
describe('SessionManager', () => {
it('should start idle with nothing published', () => {
const { sessionManager, stateManager } = createSessionManager();
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(sessionManager.wasEverInitialized()).toBeFalsy();
expect(stateManager.getState().initialized).toBeUndefined();
});
describe('should start initializations', () => {
it('should start the first initialization of a session', () => {
const { sessionManager } = createSessionManager();
const token = sessionManager.startInitialization();
expect(sessionManager.getState()).toBe(SessionState.INITIALIZING);
expect(sessionManager.isCurrentInitialization(token)).toBeTruthy();
});
it('should keep a started card while an aspect is initialized again', () => {
const { sessionManager } = createSessionManager();
completeInitialization(sessionManager);
sessionManager.startInitialization();
expect(sessionManager.getState()).toBe(SessionState.RUNNING);
});
});
describe('should report a successful initialization', () => {
it('should publish the session and config in one change', () => {
const { sessionManager, stateManager } = createSessionManager();
const listener = vi.fn();
stateManager.addListener(listener);
const config = createConfig();
sessionManager.reportInitializationSucceeded(
sessionManager.startInitialization(),
config,
);
expect(sessionManager.getState()).toBe(SessionState.RUNNING);
expect(sessionManager.wasEverInitialized()).toBeTruthy();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
change: { config: config, initialized: true, everInitialized: true },
}),
);
});
it('should republish only the config on a later initialization', () => {
const { sessionManager, stateManager } = createSessionManager();
completeInitialization(sessionManager);
const listener = vi.fn();
stateManager.addListener(listener);
const newConfig = createConfig({ menu: { style: 'none' } });
sessionManager.reportInitializationSucceeded(
sessionManager.startInitialization(),
newConfig,
);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
change: { config: newConfig },
}),
);
});
it('should publish nothing on a later initialization with an unchanged config', () => {
const { sessionManager, stateManager } = createSessionManager();
completeInitialization(sessionManager);
const listener = vi.fn();
stateManager.addListener(listener);
sessionManager.reportInitializationSucceeded(
sessionManager.startInitialization(),
createConfig(),
);
expect(listener).not.toHaveBeenCalled();
});
});
describe('should decline an initialization', () => {
it('should return to idle before the card has started', () => {
const { sessionManager, stateManager } = createSessionManager();
const listener = vi.fn();
stateManager.addListener(listener);
sessionManager.reportInitializationDeclined(sessionManager.startInitialization());
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(listener).not.toHaveBeenCalled();
});
it('should keep a started card', () => {
const { sessionManager, stateManager } = createSessionManager();
completeInitialization(sessionManager);
const listener = vi.fn();
stateManager.addListener(listener);
sessionManager.reportInitializationDeclined(sessionManager.startInitialization());
expect(sessionManager.getState()).toBe(SessionState.RUNNING);
expect(stateManager.getState().initialized).toBe(true);
expect(listener).not.toHaveBeenCalled();
});
});
describe('should fail an initialization', () => {
it('should return to idle before the card has started', () => {
const { sessionManager, stateManager } = createSessionManager();
const listener = vi.fn();
stateManager.addListener(listener);
sessionManager.reportInitializationFailed(sessionManager.startInitialization());
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(listener).not.toHaveBeenCalled();
});
it('should end a started card', () => {
const { sessionManager, stateManager } = createSessionManager();
completeInitialization(sessionManager);
sessionManager.reportInitializationFailed(sessionManager.startInitialization());
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(stateManager.getState().initialized).toBe(false);
// A card that has been turndown has still been "ever initialized".
expect(stateManager.getState().everInitialized).toBe(true);
expect(sessionManager.wasEverInitialized()).toBeTruthy();
});
});
describe('should end a session', () => {
it('should return to idle after an ended sessiond', () => {
const { sessionManager, stateManager } = createSessionManager();
completeInitialization(sessionManager);
sessionManager.end();
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(stateManager.getState().initialized).toBe(false);
});
it('should write nothing before the card has started', () => {
const { sessionManager, stateManager } = createSessionManager();
const listener = vi.fn();
stateManager.addListener(listener);
sessionManager.startInitialization();
sessionManager.end();
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(listener).not.toHaveBeenCalled();
});
it('should leave the card reported as ever initialized', () => {
const { sessionManager, stateManager } = createSessionManager();
completeInitialization(sessionManager);
sessionManager.end();
expect(stateManager.getState().everInitialized).toBe(true);
expect(sessionManager.wasEverInitialized()).toBeTruthy();
});
});
describe('should refuse stale tokens', () => {
it('should refuse a token from before the session ended', () => {
const { sessionManager, stateManager } = createSessionManager();
const listener = vi.fn();
stateManager.addListener(listener);
const token = sessionManager.startInitialization();
sessionManager.end();
expect(sessionManager.isCurrentInitialization(token)).toBeFalsy();
sessionManager.reportInitializationSucceeded(token, createConfig());
sessionManager.reportInitializationDeclined(token);
sessionManager.reportInitializationFailed(token);
expect(sessionManager.getState()).toBe(SessionState.IDLE);
expect(sessionManager.wasEverInitialized()).toBeFalsy();
expect(listener).not.toHaveBeenCalled();
});
it('should refuse a token that was already used', () => {
const { sessionManager, stateManager } = createSessionManager();
const listener = vi.fn();
stateManager.addListener(listener);
const config = createConfig();
const token = sessionManager.startInitialization();
sessionManager.reportInitializationSucceeded(token, config);
sessionManager.reportInitializationSucceeded(
token,
createConfig({ menu: { style: 'none' } }),
);
expect(listener).toHaveBeenCalledTimes(1);
expect(stateManager.getState().config).toBe(config);
});
});
// The user-facing behaviour the machine exists for, driven through the real
// schema, trigger factory and evaluator rather than hand-written state.
describe('should drive the initialized trigger', () => {
it('should fire once per session and not when a session ends', () => {
const { sessionManager, stateManager } = createSessionManager();
const trigger = initializedTriggerSchema.parse({ trigger: 'initialized' });
const evaluator = createTriggerEvaluator(
trigger,
createTriggerEvaluatorContext({ stateManager }),
);
const callback = vi.fn();
evaluator.subscribe(callback);
completeInitialization(sessionManager);
expect(callback).toHaveBeenCalledTimes(1);
// The session ending is a true -> false change of the watched value, but
// must not fire the trigger.
sessionManager.end();
expect(callback).toHaveBeenCalledTimes(1);
completeInitialization(sessionManager);
expect(callback).toHaveBeenCalledTimes(2);
});
});
});
@@ -167,12 +167,35 @@ describe('IssueManager', () => {
const hass = createHASS();
conditionStateManager.setState({ hass });
conditionStateManager.setState({ initialized: true });
conditionStateManager.setState({ initialized: true, everInitialized: true });
await flushPromises();
expect(detectStatic).toHaveBeenCalledWith(hass);
});
it('should run static detection once regardless how often the card initializes', async () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
const manager = new IssueManager(api);
const detectStatic = vi.fn().mockResolvedValue(undefined);
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
conditionStateManager.setState({ hass: createHASS() });
conditionStateManager.setState({ initialized: true, everInitialized: true });
// The card gets disconnected/reconnected as it does on a dashboard tab
// change. `everInitialized` is unchanged by that, so detection does not
// run a second time.
conditionStateManager.setState({ initialized: false });
conditionStateManager.setState({ initialized: true, everInitialized: true });
await flushPromises();
expect(detectStatic).toHaveBeenCalledTimes(1);
});
it('should not run static detection when hass is unset', () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
@@ -183,7 +206,7 @@ describe('IssueManager', () => {
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
conditionStateManager.setState({ initialized: true });
conditionStateManager.setState({ initialized: true, everInitialized: true });
expect(detectStatic).not.toHaveBeenCalled();
});
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
import { MountedCard } from '../../../browser/mounted-card';
import {
createStillCameraHASS,
createStillImageCameraConfig,
createStillImageCardConfig,
getBlockNotificationText,
} from '../../../browser/test-utils';
const STARTED_MESSAGE = 'card-started';
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
// A camera Home Assistant has never heard of, which is what a typo in a
// configuration looks like and the earliest thing a camera can fail on.
const MISSING_CAMERA_ENTITY = 'camera.missing';
const getStartedMessages = (card: MountedCard): string[] =>
card.console.getMessages('info').filter((message) => message === STARTED_MESSAGE);
const getReportedInitializationFailures = (card: MountedCard): string[] =>
card.console
.getMessages('warn')
.filter((message) => message.includes('[issue=initialization]'));
const waitForInitializationFailures = async (card: MountedCard): Promise<void> =>
await vi.waitFor(() =>
expect(getBlockNotificationText(card.card)).toContain(INIT_FAILED_ISSUE_HEADING),
);
/**
* A card whose camera cannot be initialized. Giving Home Assistant the entity
* is what makes it initializable, which a test does with `setEntityState`.
*/
const mountBrokenCard = async (): Promise<MountedCard> =>
await MountedCard.create(
createStillImageCardConfig({
cameras: [createStillImageCameraConfig(MISSING_CAMERA_ENTITY)],
// Automatic retries switched off, so any recovery below can only be the
// retry control being used.
view: { issues: { retry_seconds: 0 } },
automations: [
{
triggers: [{ trigger: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'log',
message: STARTED_MESSAGE,
},
],
},
],
}),
createStillCameraHASS(),
);
describe('InitializationIssue', () => {
it('should report a card that could not be started, and start it on a retry', async () => {
const card = await mountBrokenCard();
await waitForInitializationFailures(card);
// A card that failed to start has not started, whatever it is showing.
expect(getStartedMessages(card)).toHaveLength(0);
// The camera the user meant now exists. Nothing recovers on its own from
// here: automatic retries are off, and a card showing a full-card issue
// refuses to start.
card.setEntityState(MISSING_CAMERA_ENTITY, 'idle');
await card.clickControl('Retry');
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
await card.events.waitForFirst('advanced-camera-card:media:loaded');
// Starting is not the same as the issue leaving the screen, since a
// full-card issue hides the views behind it.
expect(getBlockNotificationText(card.card)).not.toContain(INIT_FAILED_ISSUE_HEADING);
});
it('should keep reporting a card whose retry fails again', async () => {
const card = await mountBrokenCard();
await waitForInitializationFailures(card);
expect(getReportedInitializationFailures(card)).toHaveLength(1);
await card.clickControl('Retry');
// The camera still does not exist, so the retry fails too. A second failure
// has to be raised rather than leaving the card looking as though the retry
// had worked.
await vi.waitFor(() =>
expect(getReportedInitializationFailures(card)).toHaveLength(2),
);
await waitForInitializationFailures(card);
expect(getStartedMessages(card)).toHaveLength(0);
});
});
@@ -6,11 +6,11 @@ import type { InternalCallbackActionConfig } from '../../../../src/config/schema
import { createCardAPI } from '../../../test-utils';
describe('InitializationIssue', () => {
const createAPI = (isInitializedMandatory = false): CardController => {
const createAPI = (areMandatoryAspectsInitialized = false): CardController => {
const api = createCardAPI();
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
isInitializedMandatory,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(areMandatoryAspectsInitialized);
return api;
};
@@ -190,8 +190,14 @@ describe('InitializationIssue', () => {
expect(result).toBe(false);
expect(issue.hasIssue()).toBe(false);
expect(api.getInitializationManager().uninitializeMandatory).toHaveBeenCalled();
expect(
api.getInitializationManager().invalidateMandatoryAspects,
).toHaveBeenCalled();
expect(api.getCameraManager().destroy).toHaveBeenCalled();
// What follows is a fresh attempt at starting the card, so the previous
// session is ended here.
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
});
it('should be a no-op while a retry is already in flight', () => {
@@ -199,14 +205,14 @@ describe('InitializationIssue', () => {
const issue = new InitializationIssue(api);
issue.trigger({ error: new Error('init failed') });
issue.retry();
vi.mocked(api.getInitializationManager().uninitializeMandatory).mockClear();
vi.mocked(api.getInitializationManager().invalidateMandatoryAspects).mockClear();
vi.mocked(api.getCameraManager().destroy).mockClear();
const result = issue.retry();
expect(result).toBe(false);
expect(
api.getInitializationManager().uninitializeMandatory,
api.getInitializationManager().invalidateMandatoryAspects,
).not.toHaveBeenCalled();
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
});
@@ -16,6 +16,7 @@ import {
createUnansweredMediaURL,
deepQuery,
deepQueryAll,
getBlockNotificationText,
isLiveMediaShowing,
STILL_CAMERA_ENTITY,
} from '../../../browser/test-utils';
@@ -102,10 +103,6 @@ const mountCardDualCameras = async (): Promise<MountedCard> => {
return card;
};
const getNotificationText = (card: MountedCard): string =>
deepQuery(card.card, 'advanced-camera-card-notification-block')?.shadowRoot
?.textContent ?? '';
beforeEach(() => {
vi.useFakeTimers();
});
@@ -160,8 +157,8 @@ describe('MediaUnavailableIssue', () => {
// Which camera, not just that something is wrong: with several on screen a
// report that does not say which one leaves the user to guess.
expect(getNotificationText(card)).toContain('Camera entity unavailable');
expect(getNotificationText(card)).toContain(SECOND_CAMERA_ENTITY);
expect(getBlockNotificationText(card.card)).toContain('Camera entity unavailable');
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
});
it('should leave the cameras that are still working alone', async () => {
@@ -190,8 +187,8 @@ describe('MediaUnavailableIssue', () => {
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getNotificationText(card)).toContain('Could not load image');
expect(getNotificationText(card)).toContain(STILL_CAMERA_ENTITY);
expect(getBlockNotificationText(card.card)).toContain('Could not load image');
expect(getBlockNotificationText(card.card)).toContain(STILL_CAMERA_ENTITY);
});
it('should clear the report once the camera delivers media again', async () => {
@@ -369,7 +366,7 @@ describe('MediaUnavailableIssue', () => {
// Stalled rather than failed: the picture on screen is real but frozen, and
// saying so is the difference between "this is old" and "this is broken".
expect(getNotificationText(card)).toContain('Stream stalled');
expect(getBlockNotificationText(card.card)).toContain('Stream stalled');
});
it('should report a player that reports a playback error', async () => {
@@ -383,7 +380,9 @@ describe('MediaUnavailableIssue', () => {
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getNotificationText(card)).toContain('Could not get camera endpoint');
expect(getBlockNotificationText(card.card)).toContain(
'Could not get camera endpoint',
);
await card.clickControl(REPORT_TITLE);
await card.waitForSelector('advanced-camera-card-notification');