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:
@@ -295,9 +295,16 @@ triggers:
|
||||
|
||||
## `initialized`
|
||||
|
||||
Matches whether the card has finished initializing. As a **condition**, true
|
||||
once the card is initialized; as a **trigger**, fires when the card initializes
|
||||
(useful for running an [automation](./automations.md) on card start).
|
||||
Matches whether the card is up and usable.
|
||||
|
||||
A card starts more than once: returning to its dashboard tab, restarting Home
|
||||
Assistant, and recovering from an error all start it again. It stops being
|
||||
started when it is taken off the page, when the Home Assistant connection is
|
||||
lost, or when starting up fails.
|
||||
|
||||
As a **condition**, `true` while the card has finished starting up -- usable,
|
||||
not merely present. As a **trigger**, fires each time the card finishes starting
|
||||
up, useful for running an [automation](./automations.md) on card start.
|
||||
|
||||
```yaml
|
||||
# As a condition:
|
||||
@@ -308,9 +315,10 @@ triggers:
|
||||
- trigger: initialized
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| ----------------------- | ---------------------- |
|
||||
| `condition` / `trigger` | Must be `initialized`. |
|
||||
| Parameter | Default | Description |
|
||||
| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `condition` / `trigger` | | Must be `initialized`. |
|
||||
| `ever` | `false` | Match whether the card is started now (`false`), or whether it has _ever_ finished starting up (`true`). A trigger using `true` fires on a card's first start only, and not on later ones (e.g. returning to a dashboard tab previously visited). Reloading the page, a Home Assistant restart or changing the card config (excluding [overrides](./overrides.md)) will cause a brand new card to be (unavoidably) built. |
|
||||
|
||||
## `interaction`
|
||||
|
||||
@@ -785,6 +793,7 @@ conditions:
|
||||
- condition: fullscreen
|
||||
fullscreen: true
|
||||
- condition: initialized
|
||||
ever: false
|
||||
- condition: interaction
|
||||
interaction: true
|
||||
- condition: key
|
||||
@@ -863,6 +872,7 @@ triggers:
|
||||
- trigger: fullscreen
|
||||
fullscreen: true
|
||||
- trigger: initialized
|
||||
ever: false
|
||||
- trigger: interaction
|
||||
interaction: true
|
||||
- trigger: key
|
||||
|
||||
@@ -13,6 +13,14 @@ overrides:
|
||||
> [!WARNING]
|
||||
> Whilst all configuration parameters are theoretically overridable, in some instances a configuration variable may only be consulted on startup or changing its value may negatively impact behavior -- override results may vary!
|
||||
|
||||
> [!WARNING]
|
||||
> Avoid an override whose `conditions` depend on a value that the override itself
|
||||
> changes. Applying it changes what its own conditions were matching, so it may
|
||||
> turn itself on and off repeatedly, or undo the very change that applied it. Examples: a [`camera`](conditions-triggers.md?id=camera) condition that sets
|
||||
> `cameras`, an
|
||||
> [`initialized`](conditions-triggers.md?id=initialized) condition that sets
|
||||
> anything the card must start up again to use.
|
||||
|
||||
The top-level `overrides` configuration block expects a list, with each list
|
||||
item containing `conditions` and at least one of `merge`, `delete` or `set` specified.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ remote_control:
|
||||
| Option | Default | Description |
|
||||
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `camera` | | An `input_select` entity that the card will use for bidirectional control. When the selected camera on the card changes the entity will be updated to match. Likewise, when the entity state changes, the selected camera on the card will be updated to match. When the card is first started, the `input_select` entity will be updated to only have valid camera IDs from this card and the selected camera on the card will be updated to the existing entity value. Entities used for camera remote control must start with `input_select.`. |
|
||||
| `camera_priority` | `card` | Controls whether the `card` or the `entity` has priority on initial card load. If `card`, the entity state is updated to match the camera shown on load. If `entity`, the card will select the camera shown by the entity on load. |
|
||||
| `camera_priority` | `card` | Controls whether the `card` or the `entity` has priority when the card starts up. If `card`, the entity state is updated to match the camera shown, once, when the card first starts. If `entity`, the card selects the camera named by the entity every time the card starts up -- including on a return to its dashboard tab and after Home Assistant restarts -- so a change made to the entity while the card was away is picked up. |
|
||||
|
||||
> [!NOTE]
|
||||
> To create an `input_select` entity to use in this manner, in the visual card editor, under `Remote Control -> Remote Control Entities`, choose `Create a new Dropdown helper`. Give the new entity an entity name (e.g. `my_selected_camera`) and an optional icon. You must specify at least one option -- you can use any placeholder value (e.g. `camera`) then choose `Add` (the card will automatically reset the allowable options on start). Finally, click `Create`.
|
||||
|
||||
@@ -56,7 +56,7 @@ export class AutomationsManager {
|
||||
// automation once initialization completes so that the trigger evaluators
|
||||
// baseline their initial pre-trigger value against a card whose template
|
||||
// renderer has loaded.
|
||||
if (this._api.getInitializationManager().isInitializedMandatory()) {
|
||||
if (this._api.getInitializationManager().areMandatoryAspectsInitialized()) {
|
||||
triggers.subscribe();
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export class AutomationsManager {
|
||||
// Never execute automations if the card hasn't finished initializing, as
|
||||
// it could cause a view change when camera loads are not finished.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1407
|
||||
!this._api.getInitializationManager().isInitializedMandatory() ||
|
||||
!this._api.getInitializationManager().areMandatoryAspectsInitialized() ||
|
||||
// Never execute automations if there's an error (as our automation loop
|
||||
// avoidance -- which shows as an error -- would not work!).
|
||||
this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isAncestorInEventPath } from '../utils/event-ancestor';
|
||||
import type { CardMediaReviewEventTarget } from '../utils/review';
|
||||
import type { ViewItem } from '../view/item';
|
||||
import type { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { InitializationAspect } from './initialization/initialization-manager';
|
||||
import type { CardElementAPI } from './types';
|
||||
|
||||
export type ScrollCallback = () => void;
|
||||
@@ -201,10 +201,14 @@ export class CardElementManager {
|
||||
this._api.getCameraTriggersManager().reset();
|
||||
|
||||
this._api.getCallManager().uninitialize();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||
|
||||
// The view is deliberately left initialized, so the user returns to what
|
||||
// they left. Leaving the page ends the card's initialization session.
|
||||
const initializationManager = this._api.getInitializationManager();
|
||||
initializationManager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
initializationManager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
|
||||
initializationManager.getSessionManager().end();
|
||||
|
||||
void this._api.getCameraManager().destroy();
|
||||
|
||||
this._element.removeEventListener(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { computeDomain } from '../../ha/compute-domain.js';
|
||||
import type { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { getParseError } from '../../utils/zod/parse-errors.js';
|
||||
import { InitializationAspect } from '../initialization-manager.js';
|
||||
import { InitializationAspect } from '../initialization/initialization-manager.js';
|
||||
import { TemplateManager } from '../templates';
|
||||
import type { CardConfigAPI } from '../types.js';
|
||||
import { ConfigParseError } from './error.js';
|
||||
@@ -152,7 +152,7 @@ export class ConfigManager {
|
||||
});
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||
this._api.getInitializationManager().invalidateAspect(InitializationAspect.VIEW);
|
||||
this._api.getViewManager().reset();
|
||||
|
||||
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
|
||||
@@ -234,7 +234,9 @@ export class ConfigManager {
|
||||
runIfChanged(
|
||||
(config) => [config.cameras, config.cameras_global],
|
||||
() => {
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
void this._api.getCameraManager().destroy();
|
||||
},
|
||||
true,
|
||||
@@ -244,7 +246,7 @@ export class ConfigManager {
|
||||
() => {
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
|
||||
.invalidateAspect(InitializationAspect.MICROPHONE_CONNECT);
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -267,7 +269,7 @@ export class ConfigManager {
|
||||
// InitializationManager.
|
||||
if (
|
||||
this._overriddenConfig &&
|
||||
this._api.getInitializationManager().isInitializedMandatory()
|
||||
this._api.getInitializationManager().areMandatoryAspectsInitialized()
|
||||
) {
|
||||
this._api.getConditionStateManager().setState({
|
||||
config: this._overriddenConfig,
|
||||
|
||||
@@ -71,6 +71,21 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
triggers: [
|
||||
{
|
||||
trigger: 'initialized' as const,
|
||||
|
||||
// A card initializes more than once -- on return to a dashboard tab,
|
||||
// and after Home Assistant restarts -- and the two priorities want
|
||||
// different answers to whether this should run again each time.
|
||||
//
|
||||
// `entity` reads the entity and changes only what the card displays.
|
||||
// Repeating it is safe (it does nothing when the two already agree)
|
||||
// and is the only way to pick up an entity change made while the card
|
||||
// was unable to act on it.
|
||||
//
|
||||
// `card` writes the entity. Repeating it is not safe: a card that has
|
||||
// just re-initialized is showing a freshly-defaulted view, not
|
||||
// anything the user chose, so a second run would overwrite a
|
||||
// selection the card never authored.
|
||||
ever: cameraPriority === 'card',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ExpandManager } from './expand-manager';
|
||||
import { FoldersManager } from './folders/manager';
|
||||
import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import { HASSManager } from './hass/hass-manager';
|
||||
import { InitializationManager } from './initialization-manager';
|
||||
import { InitializationManager } from './initialization/initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { createIssueManager } from './issues/factory';
|
||||
import type { IssueManager } from './issues/issue-manager';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HASSListener } from '../../ha/source';
|
||||
import type { HomeAssistant } from '../../ha/types';
|
||||
import type { UnsubscribeCallback } from '../../types';
|
||||
import { log } from '../../utils/debug';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { InitializationAspect } from '../initialization/initialization-manager';
|
||||
import type { CardHASSAPI } from '../types';
|
||||
import { EventWatcher, type EventWatcherSubscriptionInterface } from './event-watcher';
|
||||
import { StateWatcher, type StateWatcherSubscriptionInterface } from './state-watcher';
|
||||
@@ -48,12 +48,27 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
||||
}
|
||||
|
||||
public setHASS(hass?: HomeAssistant | null): void {
|
||||
// No hass at all is an absence of news rather than a change, so nothing
|
||||
// below it runs and `_hass` keeps whatever it last held.
|
||||
if (!hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasReady = !!this._hass && isHassReady(this._hass);
|
||||
const isReady = isHassReady(hass);
|
||||
|
||||
// A card cannot be started without Home Assistant, so losing it ends the
|
||||
// card's initialization session. The aspects initialized during that
|
||||
// session are left in place until it returns, when they are initialized
|
||||
// again against whatever entities it comes back with.
|
||||
if (wasReady && !isReady) {
|
||||
this._api.getInitializationManager().getSessionManager().end();
|
||||
}
|
||||
|
||||
// When HA goes from "not ready" to "ready" (WebSocket reconnected AND all
|
||||
// integrations finished loading), rebuild cameras and the view from
|
||||
// scratch: the available entities may have changed while it was down.
|
||||
const becameReady = !!this._hass && !isHassReady(this._hass) && isHassReady(hass);
|
||||
|
||||
if (becameReady) {
|
||||
if (!!this._hass && !wasReady && isReady) {
|
||||
// Tear cameras down before the listeners below see the new hass,
|
||||
// otherwise they would briefly rebuild against the old entities.
|
||||
log(
|
||||
@@ -61,16 +76,14 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
||||
'Advanced Camera Card: HA fully ready, reinitializing...',
|
||||
);
|
||||
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
void this._api.getCameraManager().destroy();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||
}
|
||||
// The entities may differ from those the cameras and the view were
|
||||
// initialized against, so both are initialized again.
|
||||
const initializationManager = this._api.getInitializationManager();
|
||||
initializationManager.invalidateAspect(InitializationAspect.CAMERAS);
|
||||
initializationManager.invalidateAspect(InitializationAspect.VIEW);
|
||||
initializationManager.invalidateAspect(InitializationAspect.INITIAL_TRIGGER);
|
||||
|
||||
if (!hass) {
|
||||
return;
|
||||
void this._api.getCameraManager().destroy();
|
||||
}
|
||||
|
||||
const oldHass = this._hass;
|
||||
|
||||
+92
-54
@@ -1,12 +1,12 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
import { isHassReady } from '../ha/is-hass-ready';
|
||||
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { Initializer } from '../utils/initializer/initializer';
|
||||
import type { CardInitializerAPI } from './types';
|
||||
import { isHassReady } from '../../ha/is-hass-ready';
|
||||
import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../../localize/localize';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { Initializer } from '../../utils/initializer/initializer';
|
||||
import type { CardInitializerAPI } from '../types';
|
||||
import { SessionManager } from './session-manager';
|
||||
|
||||
export enum InitializationAspect {
|
||||
LANGUAGES = 'languages',
|
||||
@@ -40,22 +40,30 @@ export class InitializationManager {
|
||||
// initialization" (above) are followed.
|
||||
private _initializationQueue = new PQueue({ concurrency: 1 });
|
||||
private _initializer: Initializer;
|
||||
private _everInitialized = false;
|
||||
|
||||
constructor(api: CardInitializerAPI, initializer?: Initializer) {
|
||||
// Tracks an "initialization session" (the "useful" card time between full
|
||||
// readiness -> disconnection of various kinds).
|
||||
private _sessionManager: SessionManager;
|
||||
|
||||
constructor(
|
||||
api: CardInitializerAPI,
|
||||
initializer?: Initializer,
|
||||
sessionManager?: SessionManager,
|
||||
) {
|
||||
this._api = api;
|
||||
this._initializer = initializer ?? new Initializer();
|
||||
this._sessionManager = sessionManager ?? new SessionManager(api);
|
||||
}
|
||||
|
||||
public wasEverInitialized(): boolean {
|
||||
return this._everInitialized;
|
||||
public getSessionManager(): SessionManager {
|
||||
return this._sessionManager;
|
||||
}
|
||||
|
||||
public isInitialized(aspect: InitializationAspect): boolean {
|
||||
return this._initializer.isInitialized(aspect);
|
||||
}
|
||||
|
||||
public isInitializedMandatory(): boolean {
|
||||
public areMandatoryAspectsInitialized(): boolean {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
return false;
|
||||
@@ -77,10 +85,16 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
// The one place that decides whether to (re)start mandatory initialization,
|
||||
// so callers don't re-check the conditions themselves. Called on every render
|
||||
// so callers don't check the conditions themselves. Called on every render
|
||||
// (from the card's shouldUpdate) and whenever hass changes (from
|
||||
// HASSManager); a reconnect or a cleared issue reaches it by causing a
|
||||
// render.
|
||||
//
|
||||
// The check here is only to keep cost down: a card that has finished
|
||||
// initializing re-renders often, and without it each of those renders would
|
||||
// queue an attempt that does nothing. `_initializeMandatory()` checks the
|
||||
// same conditions again when it actually runs, and that is the one that
|
||||
// matters for correctness.
|
||||
public triggerInitialization(): void {
|
||||
if (!this._shouldInitializeMandatory()) {
|
||||
return;
|
||||
@@ -93,7 +107,7 @@ export class InitializationManager {
|
||||
this._api.getConfigManager().hasConfig() &&
|
||||
this._api.getCardElementManager().isConnected() &&
|
||||
isHassReady(this._api.getHASSManager().getHASS()) &&
|
||||
!this.isInitializedMandatory() &&
|
||||
!this.areMandatoryAspectsInitialized() &&
|
||||
// Don't start while a full-card issue (e.g. the "Home Assistant is
|
||||
// starting" notice) is shown: each initialization step aborts as soon as
|
||||
// it sees one, so an attempt now would be wasted. The card tries again
|
||||
@@ -112,21 +126,24 @@ export class InitializationManager {
|
||||
|
||||
private async _initializeMandatory(): Promise<void> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
if (!hass || this.isInitializedMandatory()) {
|
||||
|
||||
// The authoritative check, made when the attempt actually runs rather than
|
||||
// when it was queued: an attempt can sit in the queue behind another one,
|
||||
// and the card may be detached, lose Home Assistant, or finish initializing
|
||||
// while it waits. This is what stops a stale attempt running.
|
||||
//
|
||||
// The `isHassReady` call also narrows `hass` for the steps below. Its
|
||||
// RUNNING requirement waits out a Home Assistant that is still loading
|
||||
// integrations, against which integration-specific WS calls fail with
|
||||
// "Unknown command".
|
||||
if (!isHassReady(hass) || !this._shouldInitializeMandatory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until HA has finished loading integrations before attempting init.
|
||||
// Otherwise integration-specific WS calls (e.g. Frigate event
|
||||
// subscriptions) fail with "Unknown command" against a half-loaded HA. The
|
||||
// HASSManager will trigger another init attempt as soon as
|
||||
// hass.config.state transitions to RUNNING.
|
||||
if (hass.config?.state !== STATE_RUNNING) {
|
||||
return;
|
||||
}
|
||||
const token = this._sessionManager.startInitialization();
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeMultipleIfNecessary({
|
||||
// Caution: Ensure nothing in this set of initializers requires
|
||||
// config or languages since they will not yet have been initialized.
|
||||
@@ -142,13 +159,17 @@ export class InitializationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
// The configuration may have vanished during the await above. The CAMERAS
|
||||
// initializer returns void and quietly does nothing without a
|
||||
// configuration, which would mark the aspect initialized against nothing
|
||||
// -- so stop before it runs.
|
||||
if (!this._api.getConfigManager().hasConfig()) {
|
||||
this._sessionManager.reportInitializationDeclined(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeMultipleIfNecessary({
|
||||
[InitializationAspect.CAMERAS]: async () => {
|
||||
// Recreate the camera manager to guarantee an immediate re-render.
|
||||
@@ -185,7 +206,7 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.VIEW,
|
||||
this._api.getViewManager().initialize,
|
||||
@@ -196,7 +217,7 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
!(await this._runStep(token, () =>
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.INITIAL_TRIGGER,
|
||||
async () => {
|
||||
@@ -211,57 +232,74 @@ export class InitializationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this._everInitialized = true;
|
||||
// The config is read here, at the last moment, so the card is never
|
||||
// reported as started against a configuration that a change during the
|
||||
// awaits above has already replaced. It is written to condition state here,
|
||||
// rather than by the ConfigManager, to ensure actions (that trigger on
|
||||
// config change) are not run before hass is available and the card is
|
||||
// initialized (the first config is set in the card *before* hass is set in
|
||||
// the card).
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (
|
||||
!config ||
|
||||
!this._sessionManager.isCurrentInitialization(token) ||
|
||||
!this.areMandatoryAspectsInitialized()
|
||||
) {
|
||||
this._sessionManager.reportInitializationDeclined(token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe any automations now: the template renderer (a mandatory
|
||||
// automation trigger evaluators can baseline pre-trigger (which potentially
|
||||
// involves rendering templates). This must run before the `setState` below
|
||||
// so that triggers watching `config`/`initialized` are attached in time to
|
||||
// involves rendering templates). This must run before the report below so
|
||||
// that triggers watching `config`/`initialized` are attached in time to
|
||||
// fire on *that* very change.
|
||||
this._api.getAutomationsManager().subscribe();
|
||||
|
||||
// When the card is initialized, both the initialization state (will never
|
||||
// change again), and the config are set in the condition state. The
|
||||
// config is set here, rather than in the ConfigManager, in order to
|
||||
// ensure actions (that trigger on config change) are not run before hass
|
||||
// is available and the card is initialzied (the first config is set in
|
||||
// the card *before* hass is set in the card).
|
||||
this._api.getConditionStateManager().setState({
|
||||
config: config,
|
||||
initialized: this._everInitialized,
|
||||
});
|
||||
this._sessionManager.reportInitializationSucceeded(token, config);
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
private async _tryInitialize(fn: () => Promise<boolean>): Promise<boolean> {
|
||||
// Run one step of initialization, telling the session manager the outcome and
|
||||
// returning whether the remaining steps should run.
|
||||
//
|
||||
// A step "declines" when it raises no error of its own but yet the
|
||||
// initialization process should not continue.
|
||||
private async _runStep(token: number, fn: () => Promise<boolean>): Promise<boolean> {
|
||||
let initialized = false;
|
||||
try {
|
||||
initialized = await fn();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorToConsole(e);
|
||||
if (this._sessionManager.isCurrentInitialization(token)) {
|
||||
if (e instanceof Error) {
|
||||
errorToConsole(e);
|
||||
}
|
||||
this._api.getIssueManager().trigger('initialization', { error: e });
|
||||
this._sessionManager.reportInitializationFailed(token);
|
||||
}
|
||||
this._setInitializationIssue(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) {
|
||||
if (
|
||||
!initialized ||
|
||||
this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
) {
|
||||
this._sessionManager.reportInitializationDeclined(token);
|
||||
return false;
|
||||
}
|
||||
|
||||
return initialized;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _setInitializationIssue(error: unknown): void {
|
||||
this._api.getIssueManager().trigger('initialization', { error });
|
||||
}
|
||||
|
||||
public uninitialize(aspect: InitializationAspect): void {
|
||||
public invalidateAspect(aspect: InitializationAspect): void {
|
||||
this._initializer.uninitialize(aspect);
|
||||
}
|
||||
|
||||
public uninitializeMandatory(): void {
|
||||
// Mark every mandatory aspect uninitialized, so a fresh initialization starts
|
||||
// from nothing. Which aspects those are is this class's own question -- see
|
||||
// `areMandatoryAspectsInitialized()`.
|
||||
public invalidateMandatoryAspects(): void {
|
||||
for (const aspect of [
|
||||
InitializationAspect.CAMERAS,
|
||||
InitializationAspect.MICROPHONE_CONNECT,
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { AdvancedCameraCardConfig } from '../../config/schema/types';
|
||||
import { Generation } from '../../utils/concurrency/generation';
|
||||
import type { CardSessionAPI } from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// The card runs in "initialization sessions". A session begins when the card is
|
||||
// attached to the page with a ready Home Assistant, and ends when the card is
|
||||
// detached, when Home Assistant stops being ready, or when initialization
|
||||
// fails. Initializing an individual aspect again (e.g. because the
|
||||
// configuration changed) happens *within* a session and does not end it, akin
|
||||
// to a running application staying started while it reconnects one subsystem.
|
||||
//
|
||||
// A session therefore involves one or more *initialization runs*: the first
|
||||
// starts the card, and each later one initializes whichever aspects were
|
||||
// invalidated while the session carried on. A run ends in one of three ways:
|
||||
//
|
||||
// - it *succeeds*, and the card is started;
|
||||
// - it *fails*, meaning a step threw an error, and the "Issue" manager takes
|
||||
// over to display and handle the issue.
|
||||
// - it *declines*, meaning it stopped early without an error of its own -- a
|
||||
// step could not complete yet, or something else had already gone wrong.
|
||||
//
|
||||
// Whichever way a run ends is its outcome, reported back here with the number
|
||||
// the run was given when it started.
|
||||
//
|
||||
// This class is the session lifecycle as a state machine, and is the only
|
||||
// writer of the `initialized` and `everInitialized` condition state -- what
|
||||
// users write conditions and triggers against. Whether every mandatory aspect
|
||||
// is initialized right now is a separate question owned by the
|
||||
// InitializationManager, and the two deliberately disagree while an aspect is
|
||||
// being initialized again mid-session.
|
||||
//
|
||||
// There is no "failed" state: a card blocked after a failed run is IDLE with a
|
||||
// full-card issue showing, and the issue system is the authority on that.
|
||||
// ============================================================================
|
||||
|
||||
export enum SessionState {
|
||||
// No initialized session: the card is detached, Home Assistant is not ready,
|
||||
// or a run declined or failed and nothing has started another yet.
|
||||
IDLE = 'idle',
|
||||
|
||||
// A session has started and its first run is under way; nothing written to
|
||||
// the condition state yet.
|
||||
INITIALIZING = 'initializing',
|
||||
|
||||
// The card has started: `initialized` is true. Later runs that initialize an
|
||||
// aspect again may happen without leaving this state.
|
||||
RUNNING = 'running',
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private _api: CardSessionAPI;
|
||||
private _state = SessionState.IDLE;
|
||||
private _everInitialized = false;
|
||||
|
||||
// Numbers the "initialization run" currently in progress (not the session,
|
||||
// which may contain several runs). The number changes when a session ends and
|
||||
// when a run reports its outcome, so an outcome from a run that something has
|
||||
// since replaced is ignored, and so is a second outcome from the same run.
|
||||
private _generation = new Generation();
|
||||
|
||||
constructor(api: CardSessionAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public wasEverInitialized(): boolean {
|
||||
return this._everInitialized;
|
||||
}
|
||||
|
||||
// Start an "initialization run" and return the number identifying it, which
|
||||
// must be handed back with its outcome. From IDLE this is the session's first
|
||||
// run; from RUNNING it initializes an aspect again mid-session, and the state
|
||||
// (with the published `initialized`) stays as it is.
|
||||
public startInitialization(): number {
|
||||
if (this._state === SessionState.IDLE) {
|
||||
this._state = SessionState.INITIALIZING;
|
||||
}
|
||||
return this._generation.next();
|
||||
}
|
||||
|
||||
public isCurrentInitialization(token: number): boolean {
|
||||
return this._generation.isCurrent(token);
|
||||
}
|
||||
|
||||
// The run completed, so the card is now started. The config is written in the
|
||||
// same change as the session state, so a trigger watching either sees one
|
||||
// consistent state. When an aspect was initialized again mid-session
|
||||
// `initialized` and
|
||||
// `everInitialized` already hold these values, so the change carries only the
|
||||
// config -- which is what stops an `initialized` trigger firing again in the
|
||||
// middle of a session.
|
||||
public reportInitializationSucceeded(
|
||||
token: number,
|
||||
config: AdvancedCameraCardConfig,
|
||||
): void {
|
||||
if (!this._acceptOutcome(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set before the condition state is written, so anything that reads this
|
||||
// class while handling that change sees the new state.
|
||||
this._state = SessionState.RUNNING;
|
||||
this._everInitialized = true;
|
||||
|
||||
this._api.getConditionStateManager().setState({
|
||||
config,
|
||||
initialized: true,
|
||||
everInitialized: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The run stopped early without producing an error of its own: either a step
|
||||
// could not complete yet (e.g. the view declining while the cameras are being
|
||||
// initialized), or a full-card issue raised elsewhere makes continuing
|
||||
// pointless.
|
||||
//
|
||||
// Nothing is written -- a card that had not started has nothing to take back,
|
||||
// and one that had is still started.
|
||||
public reportInitializationDeclined(token: number): void {
|
||||
if (!this._acceptOutcome(token)) {
|
||||
return;
|
||||
}
|
||||
if (this._state === SessionState.INITIALIZING) {
|
||||
this._state = SessionState.IDLE;
|
||||
}
|
||||
}
|
||||
|
||||
// The run threw an error which ends the session: a card missing a mandatory
|
||||
// aspect is not started. The initialization issue was already raised (by the
|
||||
// Issue Manager).
|
||||
public reportInitializationFailed(token: number): void {
|
||||
if (!this._acceptOutcome(token)) {
|
||||
return;
|
||||
}
|
||||
this._toIdle();
|
||||
}
|
||||
|
||||
// The session is over -- the card left the page or Home Assistant went away.
|
||||
// Aspects are left as they are: a caller that knows which of them the next
|
||||
// session must initialize again invalidates those itself.
|
||||
public end(): void {
|
||||
this._generation.invalidate();
|
||||
this._toIdle();
|
||||
}
|
||||
|
||||
private _toIdle(): void {
|
||||
const wasRunning = this._state === SessionState.RUNNING;
|
||||
this._state = SessionState.IDLE;
|
||||
|
||||
// `initialized: false` is written only on a card that had previously
|
||||
// started.
|
||||
if (wasRunning) {
|
||||
this._api.getConditionStateManager().setState({ initialized: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Whether an outcome should be acted on: it must come from the run currently
|
||||
// in progress, and only the first outcome from that run counts. Invalidating
|
||||
// the generation number here is what makes a second run of the same
|
||||
// initialization do nothing.
|
||||
private _acceptOutcome(token: number): boolean {
|
||||
if (!this._generation.isCurrent(token)) {
|
||||
return false;
|
||||
}
|
||||
this._generation.invalidate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
|
||||
|
||||
// Wraps the passive IssueStateManager with reaction logic. A single
|
||||
// condition-state listener drives everything: it runs one-shot static detection
|
||||
// when mandatory-init completes (`initialized` transitions to true), then
|
||||
// when mandatory-init first completes (`everInitialized` becomes true), then
|
||||
// evaluates dynamic issues on every subsequent state change, schedules retries,
|
||||
// and updates the card. Full-card issues are rendered by card.ts via
|
||||
// getStateManager().getFullCardIssue(). Non-full-card issue notifications are
|
||||
@@ -183,14 +183,14 @@ export class IssueManager {
|
||||
// Drives both one-shot static detection (on mandatory-init completion) and
|
||||
// normal re-evaluation (on any condition-state change).
|
||||
//
|
||||
// `initialized: true` in the change payload means mandatory initialization
|
||||
// just finished -- see InitializationManager._initializeMandatory. That's
|
||||
// also the earliest point at which the full HASS object is guaranteed
|
||||
// `everInitialized` is set when mandatory initialization first completes --
|
||||
// see InitializationManager._initializeMandatory -- and is never cleared, so
|
||||
// this block runs exactly once however many times the card initializes. That
|
||||
// is also the earliest point at which the full HASS object is guaranteed
|
||||
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
|
||||
// fetch). Because `initialized` is latched (its comment notes it never
|
||||
// changes again), this block fires exactly once per IssueManager life.
|
||||
// fetch).
|
||||
private _onStateChange(change: ConditionStateChange): void {
|
||||
if (change.change.initialized === true && change.new.hass) {
|
||||
if (change.change.everInitialized === true && change.new.hass) {
|
||||
void this._stateManager.detectStatic(change.new.hass).then(() => this.evaluate());
|
||||
}
|
||||
this.evaluate();
|
||||
|
||||
@@ -47,7 +47,7 @@ export class InitializationIssue extends AbstractErrorIssue {
|
||||
}
|
||||
|
||||
public detectDynamic(): void {
|
||||
if (!this._api.getInitializationManager().isInitializedMandatory()) {
|
||||
if (!this._api.getInitializationManager().areMandatoryAspectsInitialized()) {
|
||||
return;
|
||||
}
|
||||
// The success settle: mandatory init completed, so there is no error to show
|
||||
@@ -88,7 +88,8 @@ export class InitializationIssue extends AbstractErrorIssue {
|
||||
// render cycle. destroy() releases the existing CameraManager's held
|
||||
// resources (WebSocket subscriptions, listeners) before the CAMERAS
|
||||
// init aspect replaces the instance via createCameraManager().
|
||||
this._api.getInitializationManager().uninitializeMandatory();
|
||||
this._api.getInitializationManager().invalidateMandatoryAspects();
|
||||
this._api.getInitializationManager().getSessionManager().end();
|
||||
void this._api.getCameraManager().destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ExpandManager } from './expand-manager';
|
||||
import type { FoldersManager } from './folders/manager';
|
||||
import type { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import type { HASSManager } from './hass/hass-manager';
|
||||
import type { InitializationManager } from './initialization-manager';
|
||||
import type { InitializationManager } from './initialization/initialization-manager';
|
||||
import type { InteractionManager } from './interaction-manager';
|
||||
import type { IssueManager } from './issues/issue-manager';
|
||||
import type { KeyboardStateManager } from './keyboard-state-manager';
|
||||
@@ -307,6 +307,10 @@ export interface CardQueryStringAPI {
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardSessionAPI {
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
}
|
||||
|
||||
export interface CardStatusBarAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { getViewTargetID } from '../../view/target-id';
|
||||
import type { View } from '../../view/view';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { InitializationAspect } from '../initialization/initialization-manager';
|
||||
import type { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
import { applyViewModifiers } from './modifiers';
|
||||
|
||||
+3
-2
@@ -360,7 +360,7 @@ export class AdvancedCameraCard extends LitElement {
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
if (this._controller.getInitializationManager().isInitializedMandatory()) {
|
||||
if (this._controller.getInitializationManager().areMandatoryAspectsInitialized()) {
|
||||
void this._controller.getQueryStringManager().executeIfNecessary();
|
||||
}
|
||||
}
|
||||
@@ -461,6 +461,7 @@ export class AdvancedCameraCard extends LitElement {
|
||||
? html`<advanced-camera-card-loading
|
||||
.loaded=${this._controller
|
||||
.getInitializationManager()
|
||||
.getSessionManager()
|
||||
.wasEverInitialized()}
|
||||
.effectsManager=${this._config?.performance?.features
|
||||
.card_loading_effects !== false
|
||||
@@ -503,7 +504,7 @@ export class AdvancedCameraCard extends LitElement {
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements &&
|
||||
this._controller.getInitializationManager().isInitializedMandatory()
|
||||
this._controller.getInitializationManager().areMandatoryAspectsInitialized()
|
||||
? // Elements need to render after the main views so it can render 'on
|
||||
// top'. They are held until the card is initialized: the template
|
||||
// renderer loads lazily as a mandatory init aspect (when the
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { InitializedBase } from '../../../config/schema/condition-trigger/common/initialized';
|
||||
import type { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||
import type { ConditionEvaluator } from './types';
|
||||
|
||||
export class InitializedConditionEvaluator implements ConditionEvaluator {
|
||||
private _condition: InitializedBase;
|
||||
|
||||
constructor(condition: InitializedBase) {
|
||||
this._condition = condition;
|
||||
}
|
||||
|
||||
public evaluate(newState?: ConditionState): ConditionsEvaluationResult {
|
||||
return { result: !!newState?.initialized };
|
||||
return {
|
||||
result: !!(this._condition.ever
|
||||
? newState?.everInitialized
|
||||
: newState?.initialized),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const createConditionEvaluator = (
|
||||
case 'user_agent':
|
||||
return new UserAgentConditionEvaluator(condition);
|
||||
case 'initialized':
|
||||
return new InitializedConditionEvaluator();
|
||||
return new InitializedConditionEvaluator(condition);
|
||||
case 'template':
|
||||
return new TemplateConditionEvaluator(condition, context);
|
||||
case 'or':
|
||||
@@ -123,6 +123,8 @@ export const createConditionEvaluatorForTrigger = (
|
||||
return new ExpandConditionEvaluator(trigger);
|
||||
case 'fullscreen':
|
||||
return new FullscreenConditionEvaluator(trigger);
|
||||
case 'initialized':
|
||||
return new InitializedConditionEvaluator(trigger);
|
||||
case 'interaction':
|
||||
return new InteractionConditionEvaluator(trigger);
|
||||
case 'key':
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface ConditionState {
|
||||
displayMode?: ViewDisplayMode;
|
||||
expand?: boolean;
|
||||
fullscreen?: boolean;
|
||||
initialized?: boolean;
|
||||
interaction?: boolean;
|
||||
keys?: KeysState;
|
||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
@@ -34,6 +33,12 @@ export interface ConditionState {
|
||||
panel?: boolean;
|
||||
hass?: HomeAssistant;
|
||||
|
||||
// Initialization:
|
||||
// - Currently initialized.
|
||||
initialized?: boolean;
|
||||
// - Ever initialized.
|
||||
everInitialized?: boolean;
|
||||
|
||||
// Generic media target identifier. See @view/target-id for details.
|
||||
targetID?: string;
|
||||
triggered?: Set<string>;
|
||||
|
||||
@@ -6,6 +6,6 @@ export class InitializedTrigger extends ConditionStateTriggerBase<
|
||||
TriggerOfType<'initialized'>
|
||||
> {
|
||||
protected _getValue(state: ConditionState): unknown {
|
||||
return state.initialized;
|
||||
return this._trigger.ever ? state.everInitialized : state.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const initializedBaseSchema = z.object({});
|
||||
export const initializedBaseSchema = z.object({
|
||||
// Matches the card having ever been initialized, rather than it being
|
||||
// initialized right now.
|
||||
//
|
||||
// Defaulted rather than optional so the trigger always carries a value. A
|
||||
// valueless trigger fires on any change of what it watches, which here would
|
||||
// include the card becoming uninitialized -- and an action fired then would
|
||||
// run against a card that has just been torn down.
|
||||
ever: z.boolean().default(false),
|
||||
});
|
||||
export type InitializedBase = z.infer<typeof initializedBaseSchema>;
|
||||
|
||||
@@ -168,6 +168,16 @@ export class FakeHASS {
|
||||
this._renew();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop or restore the WebSocket, as a Home Assistant restart does. Home
|
||||
* Assistant keeps handing the card a `hass` while it is disconnected, which
|
||||
* is why this renews rather than going silent.
|
||||
*/
|
||||
public setConnected(connected: boolean): void {
|
||||
this._connected = connected;
|
||||
this._renew();
|
||||
}
|
||||
|
||||
private _createEntity(
|
||||
entityID: string,
|
||||
options: FakeEntityOptions | string,
|
||||
|
||||
@@ -311,6 +311,36 @@ export class MountedCard {
|
||||
this.card.hass = this._hass.getHASS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop or restore the connection to Home Assistant, then hand the card the
|
||||
* resulting `hass`.
|
||||
*/
|
||||
public setConnected(connected: boolean): void {
|
||||
this._hass.setConnected(connected);
|
||||
this.card.hass = this._hass.getHASS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the card a new configuration.
|
||||
*/
|
||||
public setConfig(config: RawAdvancedCameraCardConfig): void {
|
||||
this.card.setConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the card off the page.
|
||||
*/
|
||||
public detach(): void {
|
||||
this.card.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the card back on the page.
|
||||
*/
|
||||
public attach(): void {
|
||||
this._container.append(this.card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the card itself has rendered, which says nothing about the
|
||||
* elements beneath it.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
|
||||
|
||||
export const STILL_CAMERA_ENTITY = 'camera.office';
|
||||
@@ -158,6 +159,23 @@ export const deepQueryAll = <T extends Element = Element>(
|
||||
...getImmediateShadowRoots(root).flatMap((child) => deepQueryAll<T>(child, selector)),
|
||||
];
|
||||
|
||||
export const isMediaLoadedInfoEventDetail = (
|
||||
detail: unknown,
|
||||
): detail is MediaLoadedInfoEventDetail =>
|
||||
!!detail &&
|
||||
typeof detail === 'object' &&
|
||||
'info' in detail &&
|
||||
'signal' in detail &&
|
||||
detail.signal instanceof AbortSignal;
|
||||
|
||||
/**
|
||||
* The text of a block notification rendered in place of content (e.g. full-card
|
||||
* issues).
|
||||
*/
|
||||
export const getBlockNotificationText = (root: ParentNode): string =>
|
||||
deepQuery(root, 'advanced-camera-card-notification-block')?.shadowRoot?.textContent ??
|
||||
'';
|
||||
|
||||
// Everything a provider can draw media on: an image, a video, or a canvas.
|
||||
const MEDIA_SELECTOR = 'img, video, canvas';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
isMediaLoadedInfoEventDetail,
|
||||
STILL_CAMERA_ENTITY,
|
||||
} from '../browser/test-utils';
|
||||
|
||||
@@ -25,15 +26,6 @@ const mount = async (): Promise<MountedCard> => {
|
||||
return await MountedCard.create(createStillImageCardConfig(), hass);
|
||||
};
|
||||
|
||||
const isMediaLoadedInfoEventDetail = (
|
||||
detail: unknown,
|
||||
): detail is MediaLoadedInfoEventDetail =>
|
||||
!!detail &&
|
||||
typeof detail === 'object' &&
|
||||
'info' in detail &&
|
||||
'signal' in detail &&
|
||||
detail.signal instanceof AbortSignal;
|
||||
|
||||
const getMediaLoadedInfos = (card: MountedCard): MediaLoadedInfoEventDetail[] =>
|
||||
card.events
|
||||
.getEntries('advanced-camera-card:media:loaded')
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createEvaluatorContext } from './test-utils';
|
||||
describe('initialized condition', () => {
|
||||
it('should match an initialized condition', () => {
|
||||
const evaluator = createConditionEvaluator(
|
||||
{ condition: 'initialized' as const },
|
||||
{ condition: 'initialized' as const, ever: false },
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
@@ -14,4 +14,18 @@ describe('initialized condition', () => {
|
||||
expect(evaluator.evaluate({ initialized: true }).result).toBeTruthy();
|
||||
expect(evaluator.evaluate({ initialized: false }).result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should match an ever initialized condition', () => {
|
||||
const evaluator = createConditionEvaluator(
|
||||
{ condition: 'initialized' as const, ever: true },
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(evaluator.evaluate({}).result).toBeFalsy();
|
||||
expect(evaluator.evaluate({ everInitialized: true }).result).toBeTruthy();
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({ initialized: false, everInitialized: true }).result,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('createTriggerEvaluator', () => {
|
||||
[{ trigger: 'microphone', muted: true }, MicrophoneTrigger],
|
||||
[{ trigger: 'triggered' }, TriggeredTrigger],
|
||||
[{ trigger: 'view', views: ['live'] }, ViewTrigger],
|
||||
[{ trigger: 'initialized' }, InitializedTrigger],
|
||||
[{ trigger: 'initialized', ever: false }, InitializedTrigger],
|
||||
[{ trigger: 'key', key: 'a' }, KeyTrigger],
|
||||
[{ trigger: 'screen' }, ScreenTrigger],
|
||||
])('should create the dedicated evaluator for a %o trigger', (trigger, expected) => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { InitializedTrigger } from '../../../../src/condition-trigger/triggers/triggers/initialized';
|
||||
import type { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
describe('InitializedTrigger', () => {
|
||||
const create = (
|
||||
trigger: TriggerOfType<'initialized'>,
|
||||
): {
|
||||
initializedTrigger: InitializedTrigger;
|
||||
stateManager: ConditionStateManager;
|
||||
callback: Mock;
|
||||
} => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
const initializedTrigger = new InitializedTrigger(
|
||||
trigger,
|
||||
createTriggerEvaluatorContext({ stateManager }),
|
||||
);
|
||||
return { initializedTrigger, stateManager, callback };
|
||||
};
|
||||
|
||||
it('should trigger every time the card initializes', () => {
|
||||
const { initializedTrigger, stateManager, callback } = create({
|
||||
trigger: 'initialized',
|
||||
ever: false,
|
||||
});
|
||||
initializedTrigger.subscribe(callback);
|
||||
|
||||
stateManager.setState({ initialized: true });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith({ platform: 'acc', type: 'initialized' });
|
||||
|
||||
// The card goes down, as it does when taken off the page, and comes back.
|
||||
stateManager.setState({ initialized: false });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
stateManager.setState({ initialized: true });
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should trigger only on the first time the card initializes when ever', () => {
|
||||
const { initializedTrigger, stateManager, callback } = create({
|
||||
trigger: 'initialized',
|
||||
ever: true,
|
||||
});
|
||||
initializedTrigger.subscribe(callback);
|
||||
|
||||
stateManager.setState({ initialized: true, everInitialized: true });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
stateManager.setState({ initialized: false });
|
||||
stateManager.setState({ initialized: true, everInitialized: true });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop triggering after destroy', () => {
|
||||
const { initializedTrigger, stateManager, callback } = create({
|
||||
trigger: 'initialized',
|
||||
ever: false,
|
||||
});
|
||||
initializedTrigger.subscribe(callback);
|
||||
initializedTrigger.destroy();
|
||||
|
||||
stateManager.setState({ initialized: true });
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -890,13 +890,13 @@ describe('config defaults', () => {
|
||||
|
||||
it('should include all conditions', () => {
|
||||
const conditions = [
|
||||
{ condition: 'and', conditions: [{ condition: 'initialized' }] },
|
||||
{ condition: 'and', conditions: [{ condition: 'initialized', ever: false }] },
|
||||
{ condition: 'call', call: ['ringing', 'answered'] },
|
||||
{ condition: 'camera', cameras: ['camera.office'] },
|
||||
{ condition: 'display_mode', display_mode: 'single' },
|
||||
{ condition: 'expand', expand: true },
|
||||
{ condition: 'fullscreen', fullscreen: true },
|
||||
{ condition: 'initialized' },
|
||||
{ condition: 'initialized', ever: false },
|
||||
{ condition: 'interaction', interaction: true },
|
||||
{
|
||||
condition: 'key',
|
||||
@@ -909,14 +909,14 @@ describe('config defaults', () => {
|
||||
},
|
||||
{ condition: 'media_loaded', media_loaded: true },
|
||||
{ condition: 'microphone', muted: true },
|
||||
{ condition: 'not', conditions: [{ condition: 'initialized' }] },
|
||||
{ condition: 'not', conditions: [{ condition: 'initialized', ever: false }] },
|
||||
{
|
||||
condition: 'numeric_state',
|
||||
entity_id: 'sensor.office_temperature',
|
||||
above: 10,
|
||||
below: 20,
|
||||
},
|
||||
{ condition: 'or', conditions: [{ condition: 'initialized' }] },
|
||||
{ condition: 'or', conditions: [{ condition: 'initialized', ever: false }] },
|
||||
{ condition: 'screen', media_query: '(orientation: landscape)' },
|
||||
{
|
||||
condition: 'state',
|
||||
@@ -1934,7 +1934,7 @@ describe('automations should accept Home Assistant input shorthands', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result[0].triggers).toEqual([{ trigger: 'initialized' }]);
|
||||
expect(result[0].triggers).toEqual([{ trigger: 'initialized', ever: false }]);
|
||||
});
|
||||
|
||||
it('should reject a non-object automation', () => {
|
||||
|
||||
+7
-2
@@ -35,7 +35,8 @@ import type { EventWatcherSubscriptionInterface } from '../src/card-controller/h
|
||||
import type { HASSManager } from '../src/card-controller/hass/hass-manager';
|
||||
import type { StateWatcherSubscriptionInterface } from '../src/card-controller/hass/state-watcher';
|
||||
import type { HASSManagerReadonlyInterface } from '../src/card-controller/hass/types';
|
||||
import type { InitializationManager } from '../src/card-controller/initialization-manager';
|
||||
import type { InitializationManager } from '../src/card-controller/initialization/initialization-manager';
|
||||
import type { SessionManager } from '../src/card-controller/initialization/session-manager';
|
||||
import type { InteractionManager } from '../src/card-controller/interaction-manager';
|
||||
import type { IssueManager } from '../src/card-controller/issues/issue-manager';
|
||||
import type { IssueStateManager } from '../src/card-controller/issues/state-manager';
|
||||
@@ -525,7 +526,11 @@ export const createCardAPI = (): CardController => {
|
||||
api.getFoldersManager.mockReturnValue(mock<FoldersManager>());
|
||||
api.getFullscreenManager.mockReturnValue(mock<FullscreenManager>());
|
||||
api.getHASSManager.mockReturnValue(mock<HASSManager>());
|
||||
api.getInitializationManager.mockReturnValue(mock<InitializationManager>());
|
||||
|
||||
const initializationManager = mock<InitializationManager>();
|
||||
initializationManager.getSessionManager.mockReturnValue(mock<SessionManager>());
|
||||
|
||||
api.getInitializationManager.mockReturnValue(initializationManager);
|
||||
api.getInteractionManager.mockReturnValue(mock<InteractionManager>());
|
||||
api.getKeyboardStateManager.mockReturnValue(mock<KeyboardStateManager>());
|
||||
api.getLockManager.mockReturnValue(mock<LockManager>());
|
||||
|
||||
Reference in New Issue
Block a user