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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user