From 8d3cf07b436fec22b9aa4c69f6c15736c2ad15ce Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 11 Feb 2025 20:07:55 -0800 Subject: [PATCH] refactor: Significantly refactor how conditions work internally (#1886) - Much improved API cleanliness and testability to allow further extensibility in future - Simplified code in `live` view This technically contains a small change in how overrides work in the `live` view. Since that change is _closer_ to the documentation, and since this is likely to be rarely used, this is not considered a breaking change. Previously, overrides for a given live camera would always render _as if_ that camera was selected, vs was actually selected. Now, overrides will only apply in the live view when the camera is _actually_ selected. If this is an issue for you in practice, lets discuss. --- docs/configuration/conditions.md | 31 +- docs/index.html | 2 +- src/card-controller/automations-manager.ts | 47 +- src/card-controller/card-element-manager.ts | 2 +- src/card-controller/conditions-manager.ts | 385 ------ src/card-controller/config/config-manager.ts | 55 +- .../config/get-overridden-config.ts | 76 ++ .../config/load-keyboard-shortcuts.ts | 6 +- src/card-controller/controller.ts | 11 +- src/card-controller/expand-manager.ts | 2 +- .../fullscreen/fullscreen-manager.ts | 2 +- .../fullscreen/webkit/index.ts | 19 +- src/card-controller/hass/hass-manager.ts | 10 +- src/card-controller/interaction-manager.ts | 2 +- src/card-controller/keyboard-state-manager.ts | 2 +- src/card-controller/media-info-manager.ts | 4 +- src/card-controller/microphone-manager.ts | 2 +- src/card-controller/triggers-manager.ts | 11 +- src/card-controller/types.ts | 33 +- src/card-controller/view/view-manager.ts | 2 +- src/card.ts | 37 +- src/components/elements.ts | 40 +- src/components/live/carousel.ts | 98 +- src/components/live/grid.ts | 25 +- src/components/live/index.ts | 22 +- src/components/live/provider.ts | 3 +- src/components/views.ts | 68 +- src/conditions/conditions-manager.ts | 286 +++++ src/conditions/state-manager-via-event.ts | 38 + src/conditions/state-manager.ts | 60 + src/conditions/types.ts | 69 + src/config/types.ts | 16 +- .../automations-manager.test.ts | 159 ++- .../conditions-manager.test.ts | 1136 ----------------- .../config/config-manager.test.ts | 240 ++-- .../config/get-overridden-config.test.ts | 305 +++++ tests/card-controller/controller.test.ts | 21 +- tests/card-controller/expand-manager.test.ts | 4 +- .../fullscreen/fullscreen-manager.test.ts | 6 +- .../fullscreen/webkit/index.test.ts | 38 +- .../card-controller/hass/hass-manager.test.ts | 39 +- .../interaction-manager.test.ts | 10 +- .../keyboard-state-manager.test.ts | 18 +- .../media-info-manager.test.ts | 8 +- .../microphone-manager.test.ts | 12 +- .../card-controller/triggers-manager.test.ts | 8 +- .../card-controller/view/view-manager.test.ts | 2 +- tests/conditions/conditions-manager.test.ts | 933 ++++++++++++++ .../state-manager-via-event.test.ts | 37 + tests/conditions/state-manager.test.ts | 109 ++ tests/test-utils.ts | 10 +- 51 files changed, 2443 insertions(+), 2118 deletions(-) delete mode 100644 src/card-controller/conditions-manager.ts create mode 100644 src/card-controller/config/get-overridden-config.ts create mode 100644 src/conditions/conditions-manager.ts create mode 100644 src/conditions/state-manager-via-event.ts create mode 100644 src/conditions/state-manager.ts create mode 100644 src/conditions/types.ts delete mode 100644 tests/card-controller/conditions-manager.test.ts create mode 100644 tests/card-controller/config/get-overridden-config.test.ts create mode 100644 tests/conditions/conditions-manager.test.ts create mode 100644 tests/conditions/state-manager-via-event.test.ts create mode 100644 tests/conditions/state-manager.test.ts diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index 5b36f48a..ecb8d7ce 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -16,7 +16,8 @@ certain configurations (in `overrides`) or to display "picture elements" (in ## `camera` -Matches based on the selected camera. +Matches based on the selected camera. Does not match other cameras (whether +visible or not). ```yaml conditions: @@ -24,10 +25,10 @@ conditions: # [...] ``` -| Parameter | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------ | -| `condition` | Must be `camera`. | -| `cameras` | A list of camera IDs in which this condition is satisfied. See the camera [id](cameras/README.md) parameter. | +| Parameter | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `camera`. | +| `cameras` | An optional list of camera IDs in which this condition is satisfied. If not specified, any camera change will satisy the condition. See the camera [id](cameras/README.md) parameter. | ## `expand` @@ -167,6 +168,18 @@ conditions: # [...] ``` +| Parameter | Description | +| ----------- | ------------------------------------------------------------------------------------------------------ | +| `condition` | Must be `state`. | +| `entity` | The entity to check the state of. | +| `state` | A single entity state, or list of entity states, against which the entity state is compared. | +| `state_not` | A single entity state, or list of entity states, against which the entity state is inversely compared. | + +!> If multiple state conditions are used together with neither `state` nor +`state_not` specified, this effectively means the state for multiple entities +needs to _change_ simultaneously. This is unlikely to happen in reality, and +almost certainly not useful / reliable as a condition. + See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#state). ## `triggered` @@ -226,10 +239,10 @@ conditions: # [...] ``` -| Parameter | Description | -| ----------- | ------------------------------------------------------------------------------------------------- | -| `condition` | Must be `view`. | -| `views` | A list of [views](view.md?id=supported-views) in which this condition is satified (e.g. `clips`). | +| Parameter | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `condition` | Must be `view`. | +| `views` | An optional list of [views](view.md?id=supported-views) in which this condition is satified (e.g. `clips`). If not specified, any view change will satisy the condition. | ?> Internally, views associated with the media viewer (e.g. `clip`, `snapshot`, `recording`) are translated to a special view called `media` after the relevant diff --git a/docs/index.html b/docs/index.html index 0f9bce14..bdfd5134 100644 --- a/docs/index.html +++ b/docs/index.html @@ -36,7 +36,7 @@ 4px 4px; } #__sidebar img { - max-width: 100px; + max-width: 48px; } diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts index 98d840b1..cf62a228 100644 --- a/src/card-controller/automations-manager.ts +++ b/src/card-controller/automations-manager.ts @@ -1,14 +1,15 @@ +import { ConditionsManager } from '../conditions/conditions-manager.js'; +import { ConditionsEvaluationResult } from '../conditions/types.js'; import { Automation, AutomationActions } from '../config/types.js'; import { localize } from '../localize/localize.js'; -import { CardAutomationsAPI, TaggedAutomations } from './types.js'; +import { CardAutomationsAPI, TaggedAutomation } from './types.js'; const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; export class AutomationsManager { protected _api: CardAutomationsAPI; - protected _automations: TaggedAutomations = []; - protected _priorEvaluations: Map = new Map(); + protected _automations = new Map(); // A counter to avoid infinite loops, increases every time actions are run, // decreases every time actions are complete. @@ -19,14 +20,28 @@ export class AutomationsManager { } public deleteAutomations(tag?: unknown) { - this._automations = this._automations.filter((automation) => automation.tag !== tag); + for (const [automation, conditionManager] of this._automations) { + if (automation.tag === tag) { + this._automations.delete(automation); + conditionManager.destroy(); + } + } } - public addAutomations(automations: TaggedAutomations): void { - this._automations.push(...automations); + public addAutomations(automations: TaggedAutomation[]): void { + for (const automation of automations) { + const conditionManager = new ConditionsManager( + automation.conditions, + this._api.getConditionStateManager(), + ); + conditionManager.addListener((result: ConditionsEvaluationResult) => + this._execute(automation, result), + ); + this._automations.set(automation, conditionManager); + } } - public execute(): void { + protected _execute(automation: Automation, result: ConditionsEvaluationResult): void { if ( !this._api.getHASSManager().hasHASS() || // Never execute automations if the card hasn't finished initializing, as @@ -40,20 +55,10 @@ export class AutomationsManager { return; } - const actionsToRun: AutomationActions = []; - for (const automation of this._automations) { - const shouldExecute = this._api - .getConditionsManager() - .evaluateConditions(automation.conditions); - const actions = shouldExecute ? automation.actions : automation.actions_not; - const priorEvaluation = this._priorEvaluations.get(automation); - this._priorEvaluations.set(automation, shouldExecute); - if (shouldExecute !== priorEvaluation && actions) { - actionsToRun.push(...actions); - } - } + const shouldExecute = result.result; + const actions = shouldExecute ? automation.actions : automation.actions_not; - if (!actionsToRun.length) { + if (!actions?.length) { return; } @@ -70,6 +75,6 @@ export class AutomationsManager { await this._api.getActionsManager().executeActions(actions); --this._nestedAutomationExecutions; }; - runActions(actionsToRun); + runActions(actions); } } diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index b11c6af0..4c55c552 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -127,7 +127,7 @@ export class CardElementManager { this._api.getQueryStringManager().requestExecution, ); - this._api.getConditionsManager()?.setState({ + this._api.getConditionStateManager()?.setState({ userAgent: navigator.userAgent, }); diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts deleted file mode 100644 index 35337b46..00000000 --- a/src/card-controller/conditions-manager.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { CurrentUser } from '@dermotduffy/custom-card-helpers'; -import { HassEntities } from 'home-assistant-js-websocket'; -import isEqual from 'lodash-es/isEqual'; -import merge from 'lodash-es/merge'; -import { ZodSchema } from 'zod'; -import { - copyConfig, - deleteConfigValue, - getConfigValue, - setConfigValue, -} from '../config/management'; -import { - AdvancedCameraCardCondition, - advancedCameraCardConditionalSchema, - Overrides, - RawAdvancedCameraCardConfig, - ViewDisplayMode, -} from '../config/types'; -import { localize } from '../localize/localize'; -import { AdvancedCameraCardError, MediaLoadedInfo } from '../types'; -import { desparsifyArrays } from '../utils/basic'; -import { isCompanionApp } from '../utils/companion'; -import { CardConditionAPI, KeysState, MicrophoneState } from './types'; - -export interface ConditionState { - camera?: string; - displayMode?: ViewDisplayMode; - expand?: boolean; - fullscreen?: boolean; - interaction?: boolean; - keys?: KeysState; - mediaLoadedInfo?: MediaLoadedInfo | null; - microphone?: MicrophoneState; - state?: HassEntities; - triggered?: Set; - user?: CurrentUser; - userAgent?: string; - view?: string; -} - -class OverrideConfigurationError extends AdvancedCameraCardError {} - -export class ConditionsEvaluateRequestEvent extends Event { - public conditions: AdvancedCameraCardCondition[]; - public evaluation?: boolean; - - constructor(conditions: AdvancedCameraCardCondition[], eventInitDict?: EventInit) { - super('advanced-camera-card:conditions:evaluate', eventInitDict); - this.conditions = conditions; - } -} - -/** - * Evaluate whether an AdvancedCameraCardCondition is met using an event to - * evaluate. - * @returns A boolean indicating whether the condition is met. - */ -export function evaluateConditionViaEvent( - element: HTMLElement, - conditions?: AdvancedCameraCardCondition[], -): boolean { - if (!conditions) { - return true; - } - - const evaluateEvent = new ConditionsEvaluateRequestEvent(conditions, { - bubbles: true, - composed: true, - }); - - /* Special note on what's going on here: - * - * Some parts of the card (e.g. ) may have arbitrary - * complexity and layers (that this card doesn't control) between that master - * element and the element that needs to evaluate the condition. In these - * cases there's no clean way to pass state from the rest of card down through - * these layers. Instead, an event is dispatched as a "request for evaluation" - * (ConditionEvaluateRequestEvent) upwards which is caught by the outer card - * and the evaluation result is added to the event object. Because event - * propagation is handled synchronously, the result will be added to the event - * before the flow proceeds. - */ - element.dispatchEvent(evaluateEvent); - return evaluateEvent.evaluation ?? false; -} - -export function getOverriddenConfig( - manager: Readonly, - config: Readonly, - options?: { - configOverrides?: Readonly; - stateOverrides?: Partial; - schema?: ZodSchema; - }, -): RT { - let output = copyConfig(config); - let overridden = false; - if (options?.configOverrides) { - for (const override of options.configOverrides) { - if (manager.evaluateConditions(override.conditions, options?.stateOverrides)) { - override.delete?.forEach((deletionKey) => { - deleteConfigValue(output, deletionKey); - }); - - Object.keys(override.set ?? {}).forEach((setKey) => { - setConfigValue(output, setKey, override.set?.[setKey]); - }); - - Object.keys(override.merge ?? {}).forEach((mergeKey) => { - setConfigValue( - output, - mergeKey, - merge({}, getConfigValue(output, mergeKey), override.merge?.[mergeKey]), - ); - }); - - overridden = true; - } - } - } - - if (!overridden) { - // Attempt to return the same configuration object if it has not been - // overridden (to reduce re-renders for a configuration that has not changed). - return config; - } - - if (options?.configOverrides?.some((override) => override.delete?.length)) { - // If anything was deleted during this override, empty undefined slots may - // be left in arrays where values were unset. Desparsify them. - output = desparsifyArrays(output); - } - - if (options?.schema) { - const parseResult = options.schema.safeParse(output); - if (!parseResult.success) { - throw new OverrideConfigurationError( - localize('error.invalid_configuration_override'), - [parseResult.error.errors, output], - ); - } - return parseResult.data; - } - return output; -} - -// A tiny wrapper interface to allow the same manager to be passed around -// immutably within objects that will not be equal (===). Every state change -// generates a new epoch. This is used for Lit rendering to ensure changes to -// condition state are recognized as changes even though the manager is the -// same. -export interface ConditionsManagerEpoch { - manager: Readonly; -} - -export type ConditionsManagerListener = ( - newState: ConditionState, - oldState?: ConditionState, -) => void; - -export class ConditionsManager { - protected _api: CardConditionAPI; - - protected _state: ConditionState = {}; - protected _epoch: ConditionsManagerEpoch = this._createEpoch(); - protected _listeners: ConditionsManagerListener[]; - - // Whether or not to include HA state in ConditionState. Doing so increases - // CPU usage as HA state is pumped out very fast, so this is only enabled if - // the configuration needs to consume it. - protected _hasHAStateConditions = false; - protected _mediaQueries: MediaQueryList[] = []; - protected _mediaQueryTrigger = () => this._triggerChange(this._state); - - constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) { - this._api = api; - this._listeners = [ - () => this._api.getConfigManager().computeOverrideConfig(), - () => this._api.getAutomationsManager().execute(), - ...(listener ? [listener] : []), - ]; - } - - public addListener(listener: ConditionsManagerListener): void { - this._listeners.push(listener); - } - - public removeListener(listener: ConditionsManagerListener): void { - this._listeners = this._listeners.filter((l) => l !== listener); - } - - public removeConditions(): void { - this._mediaQueries.forEach((mql) => - mql.removeEventListener('change', this._mediaQueryTrigger), - ); - this._mediaQueries = []; - } - - public setConditionsFromConfig(): void { - this.removeConditions(); - - const getAllConditions = (): AdvancedCameraCardCondition[] => { - const config = this._api.getConfigManager().getConfig(); - const conditions: AdvancedCameraCardCondition[] = []; - config?.overrides?.forEach((override) => conditions.push(...override.conditions)); - config?.automations?.forEach((automation) => - conditions.push(...automation.conditions), - ); - - // Element conditions can be arbitrarily nested underneath conditionals and - // custom elements that this card may not known. Here we recursively parse - // down the elements tree, parsing as we go to find valid conditions. - const getElementsConditions = (data: unknown): void => { - const parseResult = advancedCameraCardConditionalSchema.safeParse(data); - if (parseResult.success) { - conditions.push(...parseResult.data.conditions); - parseResult.data.elements?.forEach(getElementsConditions); - } else if (data && typeof data === 'object') { - Object.keys(data).forEach((key) => getElementsConditions(data[key])); - } - }; - config?.elements?.forEach(getElementsConditions); - return conditions; - }; - - const conditions = getAllConditions(); - this._hasHAStateConditions = conditions.some( - (conditionObj) => - !conditionObj.condition || - ['state', 'numeric_state', 'user'].includes(conditionObj.condition), - ); - conditions.forEach((conditionObj) => { - if (conditionObj.condition === 'screen') { - const mql = window.matchMedia(conditionObj.media_query); - mql.addEventListener('change', this._mediaQueryTrigger); - this._mediaQueries.push(mql); - } - }); - } - - public setState(state: Partial): void { - // Performance: Compare the new state with the existing state and only - // trigger a change if the new state is different. Only the new keys are - // compared, since some of the values (e.g. 'state') will be large. - if (Object.keys(state).every((key) => isEqual(state[key], this._state[key]))) { - return; - } - const oldState = this._state; - this._state = { - ...oldState, - ...state, - }; - this._triggerChange(oldState); - } - - public getState(): ConditionState { - return this._state; - } - - public hasHAStateConditions(): boolean { - return this._hasHAStateConditions; - } - - public getEpoch(): ConditionsManagerEpoch { - return this._epoch; - } - - public evaluateConditions( - conditions: Readonly[], - stateOverrides?: Partial, - ): boolean { - return conditions.every((conditionObj) => - this._evaluateCondition(conditionObj, stateOverrides), - ); - } - - protected _evaluateCondition( - conditionObj: Readonly, - stateOverrides?: Partial, - ): boolean { - const state = { - ...this._state, - ...stateOverrides, - }; - - switch (conditionObj.condition) { - case undefined: - case 'state': - return ( - !!state.state && - ((!conditionObj.state && !conditionObj.state_not) || - (conditionObj.entity in state.state && - (!conditionObj.state || - (Array.isArray(conditionObj.state) - ? conditionObj.state.includes(state.state[conditionObj.entity].state) - : conditionObj.state === state.state[conditionObj.entity].state)) && - (!conditionObj.state_not || - (Array.isArray(conditionObj.state_not) - ? !conditionObj.state_not.includes( - state.state[conditionObj.entity].state, - ) - : conditionObj.state_not !== state.state[conditionObj.entity].state)))) - ); - case 'view': - return !!state?.view && conditionObj.views.includes(state.view); - case 'fullscreen': - return ( - state.fullscreen !== undefined && conditionObj.fullscreen === state.fullscreen - ); - case 'expand': - return state.expand !== undefined && conditionObj.expand === state.expand; - case 'camera': - return !!state.camera && conditionObj.cameras.includes(state.camera); - case 'numeric_state': - return ( - !!state.state && - conditionObj.entity in state.state && - state.state[conditionObj.entity].state !== undefined && - (conditionObj.above === undefined || - Number(state.state[conditionObj.entity].state) > conditionObj.above) && - (conditionObj.below === undefined || - Number(state.state[conditionObj.entity].state) < conditionObj.below) - ); - case 'user': - return !!state.user && conditionObj.users.includes(state.user.id); - case 'media_loaded': - return ( - state.mediaLoadedInfo !== undefined && - conditionObj.media_loaded === !!state.mediaLoadedInfo - ); - case 'screen': - return window.matchMedia(conditionObj.media_query).matches; - case 'display_mode': - return !!state.displayMode && conditionObj.display_mode === state.displayMode; - case 'triggered': - return conditionObj.triggered.some((triggeredCameraID) => - state.triggered?.has(triggeredCameraID), - ); - case 'interaction': - return ( - state.interaction !== undefined && - conditionObj.interaction === state.interaction - ); - case 'microphone': - return ( - (conditionObj.connected === undefined || - state.microphone?.connected === conditionObj.connected) && - (conditionObj.muted === undefined || - state.microphone?.muted === conditionObj.muted) - ); - case 'key': - return ( - !!state.keys && - conditionObj.key in state.keys && - (conditionObj.state ?? 'down') === state.keys[conditionObj.key].state && - (conditionObj.ctrl === undefined || - conditionObj.ctrl === !!state.keys[conditionObj.key].ctrl) && - (conditionObj.alt === undefined || - conditionObj.alt === !!state.keys[conditionObj.key].alt) && - (conditionObj.meta === undefined || - conditionObj.meta === !!state.keys[conditionObj.key].meta) && - (conditionObj.shift === undefined || - conditionObj.shift === !!state.keys[conditionObj.key].shift) - ); - case 'user_agent': - return ( - !!state.userAgent && - (!conditionObj.user_agent || conditionObj.user_agent === state.userAgent) && - (conditionObj.companion === undefined || - conditionObj.companion === isCompanionApp(state.userAgent)) && - (conditionObj.user_agent_re === undefined || - new RegExp(conditionObj.user_agent_re).test(state.userAgent)) - ); - } - } - - protected _createEpoch(): ConditionsManagerEpoch { - return { manager: this }; - } - - protected _triggerChange(oldState?: ConditionState): void { - this._epoch = this._createEpoch(); - this._listeners.forEach((listener) => listener(this._state, oldState)); - } -} diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 9b177dc6..69da0e49 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -1,4 +1,5 @@ import isEqual from 'lodash-es/isEqual'; +import { ConditionsManager } from '../../conditions/conditions-manager.js'; import { isConfigUpgradeable } from '../../config/management.js'; import { setProfiles } from '../../config/profiles/index.js'; import { @@ -9,9 +10,9 @@ import { } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; import { getParseErrorPaths } from '../../utils/zod.js'; -import { getOverriddenConfig } from '../conditions-manager.js'; import { InitializationAspect } from '../initialization-manager.js'; import { CardConfigAPI } from '../types.js'; +import { getOverriddenConfig } from './get-overridden-config.js'; import { setAutomationsFromConfig } from './load-automations.js'; import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js'; @@ -26,6 +27,7 @@ export class ConfigManager { protected _overriddenConfig: AdvancedCameraCardConfig | null = null; protected _rawConfig: RawAdvancedCameraCardConfig | null = null; protected _cardWideConfig: CardWideConfig | null = null; + protected _overridesConditionsManager: ConditionsManager | null = null; constructor(api: CardConfigAPI) { this._api = api; @@ -85,8 +87,16 @@ export class ConfigManager { debug: config.debug, }; - this._api.getConditionsManager().setConditionsFromConfig(); - this._api.getConditionsManager().setState({ + this._overridesConditionsManager?.destroy(); + this._overridesConditionsManager = this._config.overrides?.length + ? new ConditionsManager( + this._config.overrides.map((override) => override.conditions).flat(), + this._api.getConditionStateManager(), + ) + : null; + this._overridesConditionsManager?.addListener(() => this._processOverrideConfig()); + + this._api.getConditionStateManager().setState({ view: undefined, displayMode: undefined, camera: undefined, @@ -102,31 +112,22 @@ export class ConfigManager { setKeyboardShortcutsFromConfig(this._api, this); setAutomationsFromConfig(this._api); - this.computeOverrideConfig(); + this._processOverrideConfig(); this._api.getCardElementManager().update(); } - public computeOverrideConfig(): void { - const conditionsManager = this._api.getConditionsManager(); + protected _processOverrideConfig(): void { + /* istanbul ignore if: No (current) way to reach this code -- @preserve */ if (!this._config) { return; } - let overriddenConfig: AdvancedCameraCardConfig | null = null; - try { - overriddenConfig = getOverriddenConfig(conditionsManager, this._config, { - configOverrides: this._config.overrides, - schema: advancedCameraCardConfigSchema, - }); - } catch (ev) { - this._api.getMessageManager().setErrorIfHigherPriority(ev); - return; - } + const overriddenConfig = this._getOverriddenConfig(); // Save on Lit re-rendering costs by only updating the configuration if it // actually changes. - if (isEqual(overriddenConfig, this._overriddenConfig)) { + if (!overriddenConfig || isEqual(overriddenConfig, this._overriddenConfig)) { return; } @@ -154,14 +155,30 @@ export class ConfigManager { .uninitialize(InitializationAspect.MICROPHONE_CONNECT); } - /* async */ this._initializeBackground(previousConfig); + /* async */ this._initializeBackgroundAndUpdate(previousConfig); + } + + protected _getOverriddenConfig(): AdvancedCameraCardConfig | null { + if (!this._overridesConditionsManager || !this._config) { + return this._config; + } + + try { + return getOverriddenConfig(this._overridesConditionsManager, this._config, { + configOverrides: this._config.overrides, + schema: advancedCameraCardConfigSchema, + }); + } catch (ev) { + this._api.getMessageManager().setErrorIfHigherPriority(ev); + return null; + } } /** * Initialize config dependent items in the background. For items that the * card hard requires, use InitializationManager instead. */ - protected async _initializeBackground( + protected async _initializeBackgroundAndUpdate( previousConfig: AdvancedCameraCardConfig | null, ): Promise { await this._api.getDefaultManager().initializeIfNecessary(previousConfig); diff --git a/src/card-controller/config/get-overridden-config.ts b/src/card-controller/config/get-overridden-config.ts new file mode 100644 index 00000000..5ad7a0fe --- /dev/null +++ b/src/card-controller/config/get-overridden-config.ts @@ -0,0 +1,76 @@ +import { merge } from 'lodash-es'; +import { ZodType as ZodSchema } from 'zod'; +import { ConditionsManagerReadonlyInterface } from '../../conditions/types'; +import { + copyConfig, + deleteConfigValue, + getConfigValue, + setConfigValue, +} from '../../config/management'; +import { Overrides, RawAdvancedCameraCardConfig } from '../../config/types'; +import { localize } from '../../localize/localize'; +import { AdvancedCameraCardError } from '../../types'; +import { desparsifyArrays } from '../../utils/basic'; + +class OverrideConfigurationError extends AdvancedCameraCardError {} + +export function getOverriddenConfig( + manager: ConditionsManagerReadonlyInterface, + config: Readonly, + options?: { + configOverrides?: Readonly; + schema?: ZodSchema; + }, +): RT { + if (!options?.configOverrides) { + return config; + } + + let output = copyConfig(config); + let overridden = false; + for (const override of options.configOverrides) { + if (manager.getEvaluation()?.result) { + override.delete?.forEach((deletionKey) => { + deleteConfigValue(output, deletionKey); + }); + + Object.keys(override.set ?? {}).forEach((setKey) => { + setConfigValue(output, setKey, override.set?.[setKey]); + }); + + Object.keys(override.merge ?? {}).forEach((mergeKey) => { + setConfigValue( + output, + mergeKey, + merge({}, getConfigValue(output, mergeKey), override.merge?.[mergeKey]), + ); + }); + + overridden = true; + } + } + + if (!overridden) { + // Return the same configuration object if it has not been overridden (to + // reduce re-renders for a configuration that has not changed). + return config; + } + + if (options?.configOverrides?.some((override) => override.delete?.length)) { + // If anything was deleted during this override, empty undefined slots may + // be left in arrays where values were unset. Desparsify them. + output = desparsifyArrays(output); + } + + if (options?.schema) { + const parseResult = options.schema.safeParse(output); + if (!parseResult.success) { + throw new OverrideConfigurationError( + localize('error.invalid_configuration_override'), + [parseResult.error.errors, output], + ); + } + return parseResult.data; + } + return output; +} diff --git a/src/card-controller/config/load-keyboard-shortcuts.ts b/src/card-controller/config/load-keyboard-shortcuts.ts index e8f8f895..42589674 100644 --- a/src/card-controller/config/load-keyboard-shortcuts.ts +++ b/src/card-controller/config/load-keyboard-shortcuts.ts @@ -3,8 +3,8 @@ import { PTZKeyboardShortcutName, } from '../../config/keyboard-shortcuts'; import { PTZAction } from '../../config/ptz'; -import { CardConfigLoaderAPI, TaggedAutomations } from '../types'; import { createPTZMultiAction } from '../../utils/action'; +import { CardConfigLoaderAPI, TaggedAutomation } from '../types'; export const setKeyboardShortcutsFromConfig = ( api: CardConfigLoaderAPI, @@ -47,12 +47,12 @@ const ptzKeyboardShortcutToPTZAction = ( const convertKeyboardShortcutsToAutomations = ( tag: unknown, shortcuts: KeyboardShortcuts, -): TaggedAutomations => { +): TaggedAutomation[] => { if (!shortcuts.enabled) { return []; } - const automations: TaggedAutomations = []; + const automations: TaggedAutomation[] = []; for (const name of [ 'ptz_down', diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 06b893be..5d7466bb 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -1,6 +1,7 @@ import { LovelaceCardEditor } from '@dermotduffy/custom-card-helpers'; import { ReactiveController } from 'lit'; import { CameraManager } from '../camera-manager/manager'; +import { ConditionStateManager } from '../conditions/state-manager'; import { AdvancedCameraCardConfig } from '../config/types'; import { createDeviceRegistryCache, @@ -20,7 +21,6 @@ import { MenuToggleCallback, ScrollCallback, } from './card-element-manager'; -import { ConditionsManager, ConditionsManagerListener } from './conditions-manager'; import { ConfigManager } from './config/config-manager'; import { DefaultManager } from './default-manager'; import { DownloadManager } from './download-manager'; @@ -92,6 +92,8 @@ export class CardController CardViewAPI, ReactiveController { + protected _conditionStateManager = new ConditionStateManager(); + // These properties may be used in the construction of 'managers' (and should // be created first). protected _deviceRegistryManager = new DeviceRegistryManager( @@ -107,7 +109,6 @@ export class CardController protected _cameraManager = new CameraManager(this); protected _cameraURLManager = new CameraURLManager(this); protected _cardElementManager: CardElementManager; - protected _conditionsManager: ConditionsManager; protected _configManager = new ConfigManager(this); protected _defaultManager = new DefaultManager(this); protected _downloadManager = new DownloadManager(this); @@ -131,11 +132,9 @@ export class CardController host: CardHTMLElement, scrollCallback: ScrollCallback, menuToggleCallback: MenuToggleCallback, - conditionListener: ConditionsManagerListener, ) { host.addController(this); - this._conditionsManager = new ConditionsManager(this, conditionListener); this._cardElementManager = new CardElementManager( this, host, @@ -171,8 +170,8 @@ export class CardController return this._cardElementManager; } - public getConditionsManager(): ConditionsManager { - return this._conditionsManager; + public getConditionStateManager(): ConditionStateManager { + return this._conditionStateManager; } public static async getConfigElement(): Promise { diff --git a/src/card-controller/expand-manager.ts b/src/card-controller/expand-manager.ts index 70d8f42b..77db3416 100644 --- a/src/card-controller/expand-manager.ts +++ b/src/card-controller/expand-manager.ts @@ -32,7 +32,7 @@ export class ExpandManager { } protected _setConditionState(): void { - this._api.getConditionsManager()?.setState({ + this._api.getConditionStateManager()?.setState({ expand: this._expanded, }); } diff --git a/src/card-controller/fullscreen/fullscreen-manager.ts b/src/card-controller/fullscreen/fullscreen-manager.ts index 4faafa9a..25ca3843 100644 --- a/src/card-controller/fullscreen/fullscreen-manager.ts +++ b/src/card-controller/fullscreen/fullscreen-manager.ts @@ -60,7 +60,7 @@ export class FullscreenManager { }; protected _setConditionState(): void { - this._api.getConditionsManager()?.setState({ + this._api.getConditionStateManager()?.setState({ fullscreen: this.isInFullscreen(), }); } diff --git a/src/card-controller/fullscreen/webkit/index.ts b/src/card-controller/fullscreen/webkit/index.ts index ccbb78dc..665f8680 100644 --- a/src/card-controller/fullscreen/webkit/index.ts +++ b/src/card-controller/fullscreen/webkit/index.ts @@ -1,6 +1,6 @@ +import { ConditionStateChange } from '../../../conditions/types'; import { WebkitHTMLVideoElement } from '../../../types'; import { Timer } from '../../../utils/timer'; -import { ConditionState } from '../../conditions-manager'; import { FullscreenProviderBase } from '../provider'; import { FullscreenProvider } from '../types'; @@ -18,26 +18,23 @@ export class WebkitFullScreenProvider protected _playTimer = new Timer(); public connect(): void { - this._api.getConditionsManager().addListener(this._conditionChangeHandler); + this._api.getConditionStateManager().addListener(this._stateChangeHandler); } public disconnect(): void { - this._api.getConditionsManager().removeListener(this._conditionChangeHandler); + this._api.getConditionStateManager().removeListener(this._stateChangeHandler); } - protected _conditionChangeHandler = ( - newState: ConditionState, - oldState?: ConditionState, - ): void => { + protected _stateChangeHandler = (change: ConditionStateChange): void => { if ( - newState.mediaLoadedInfo?.player?.getFullscreenElement() !== - oldState?.mediaLoadedInfo?.player?.getFullscreenElement() + change.old.mediaLoadedInfo?.player?.getFullscreenElement() !== + change.new.mediaLoadedInfo?.player?.getFullscreenElement() ) { - const oldElement = oldState?.mediaLoadedInfo?.player?.getFullscreenElement(); + const oldElement = change.old.mediaLoadedInfo?.player?.getFullscreenElement(); oldElement?.removeEventListener('webkitbeginfullscreen', this._handler); oldElement?.removeEventListener('webkitendfullscreen', this._endHandler); - const newElement = newState.mediaLoadedInfo?.player?.getFullscreenElement(); + const newElement = change.new.mediaLoadedInfo?.player?.getFullscreenElement(); newElement?.addEventListener('webkitbeginfullscreen', this._handler); newElement?.addEventListener('webkitendfullscreen', this._endHandler); } diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index fe967abf..7d2286d4 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -46,12 +46,10 @@ export class HASSManager { const oldHass = this._hass; this._hass = hass; - if (this._api.getConditionsManager().hasHAStateConditions()) { - this._api.getConditionsManager().setState({ - state: this._hass.states, - user: this._hass.user, - }); - } + this._api.getConditionStateManager().setState({ + state: this._hass.states, + user: this._hass.user, + }); // Theme may depend on HASS. this._api.getStyleManager().applyTheme(); diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts index 546ee633..16eb9e12 100644 --- a/src/card-controller/interaction-manager.ts +++ b/src/card-controller/interaction-manager.ts @@ -33,7 +33,7 @@ export class InteractionManager { val, 'interaction', ); - this._api.getConditionsManager().setState({ interaction: val }); + this._api.getConditionStateManager().setState({ interaction: val }); } protected _reportInteraction(): void { diff --git a/src/card-controller/keyboard-state-manager.ts b/src/card-controller/keyboard-state-manager.ts index b2ad3626..f78f4929 100644 --- a/src/card-controller/keyboard-state-manager.ts +++ b/src/card-controller/keyboard-state-manager.ts @@ -54,6 +54,6 @@ export class KeyboardStateManager { }; protected _processStateChange(): void { - this._api.getConditionsManager().setState({ keys: this._state }); + this._api.getConditionStateManager().setState({ keys: this._state }); } } diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts index c9c82359..e7c04b75 100644 --- a/src/card-controller/media-info-manager.ts +++ b/src/card-controller/media-info-manager.ts @@ -30,7 +30,7 @@ export class MediaLoadedInfoManager { this._current = mediaLoadedInfo; this._lastKnown = mediaLoadedInfo; - this._api.getConditionsManager().setState({ mediaLoadedInfo: mediaLoadedInfo }); + this._api.getConditionStateManager().setState({ mediaLoadedInfo: mediaLoadedInfo }); // Fresh media information may change how the card is rendered. this._api.getStyleManager().setExpandedMode(); @@ -47,7 +47,7 @@ export class MediaLoadedInfoManager { public clear(): void { this._current = null; - this._api.getConditionsManager().setState({ mediaLoadedInfo: null }); + this._api.getConditionStateManager().setState({ mediaLoadedInfo: null }); } public has(): boolean { diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 28557cbd..80667d3f 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -146,7 +146,7 @@ export class MicrophoneManager { muted: this.isMuted(), forbidden: this.isForbidden(), }; - this._api.getConditionsManager().setState({ + this._api.getConditionStateManager().setState({ microphone: this._state, }); this._api.getCardElementManager().update(); diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index b4371e68..a4c14d35 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -1,4 +1,3 @@ -import isEqual from 'lodash-es/isEqual'; import orderBy from 'lodash-es/orderBy'; import throttle from 'lodash-es/throttle'; import { CameraEvent } from '../camera-manager/types'; @@ -132,13 +131,9 @@ export class TriggersManager { const triggeredCameraIDs = new Set(this._triggeredCameras.keys()); const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined; - if ( - !isEqual(triggeredState, this._api.getConditionsManager().getState().triggered) - ) { - this._api.getConditionsManager().setState({ - triggered: triggeredState, - }); - } + this._api.getConditionStateManager().setState({ + triggered: triggeredState, + }); } protected async _untriggerAction(cameraID: string): Promise { diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index ce7190ce..06525b05 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -1,4 +1,5 @@ import type { CameraManager } from '../camera-manager/manager'; +import type { ConditionStateManager } from '../conditions/state-manager'; import type { Automation } from '../config/types'; import type { EntityRegistryManager } from '../utils/ha/registry/entity'; import type { ResolvedMediaCache } from '../utils/ha/resolved-media'; @@ -6,7 +7,6 @@ import type { ActionsManager } from './actions/actions-manager'; import type { AutomationsManager } from './automations-manager'; import type { CameraURLManager } from './camera-url-manager'; import type { CardElementManager } from './card-element-manager'; -import type { ConditionsManager } from './conditions-manager'; import type { ConfigManager } from './config/config-manager'; import type { DefaultManager } from './default-manager'; import type { DownloadManager } from './download-manager'; @@ -38,7 +38,7 @@ export interface CardActionsAPI { getCameraManager(): CameraManager; getCameraURLManager(): CameraURLManager; getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getDownloadManager(): DownloadManager; getExpandManager(): ExpandManager; @@ -57,7 +57,7 @@ export type CardActionsManagerAPI = CardActionsAPI; export interface CardAutomationsAPI { getActionsManager(): ActionsManager; getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getHASSManager(): HASSManager; getInitializationManager(): InitializationManager; getMessageManager(): MessageManager; @@ -87,7 +87,7 @@ export interface CardConfigAPI { getAutomationsManager(): AutomationsManager; getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; getInitializationManager(): InitializationManager; @@ -125,7 +125,7 @@ export interface CardDownloadAPI { export interface CardElementAPI { getActionsManager(): ActionsManager; getCameraManager(): CameraManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; getExpandManager(): ExpandManager; @@ -142,13 +142,13 @@ export interface CardElementAPI { export interface CardExpandAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getFullscreenManager(): FullscreenManager; } export interface CardFullscreenAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getExpandManager(): ExpandManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaPlayerManager(): MediaPlayerManager; @@ -157,7 +157,7 @@ export interface CardFullscreenAPI { export interface CardHASSAPI { getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; getInteractionManager(): InteractionManager; @@ -189,7 +189,7 @@ export interface CardInitializerAPI { export interface CardInteractionAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getStyleManager(): StyleManager; getTriggersManager(): TriggersManager; @@ -198,13 +198,13 @@ export interface CardInteractionAPI { export interface CardKeyboardStateAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; } export interface CardMediaLoadedAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getFullscreenManager(): FullscreenManager; getStyleManager(): StyleManager; @@ -221,13 +221,13 @@ export interface CardMediaPlayerAPI { export interface CardMessageAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; } export interface CardMicrophoneAPI { getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; } @@ -254,7 +254,7 @@ export interface CardStyleAPI { export interface CardTriggersAPI { getCameraManager(): CameraManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getCardElementManager(): CardElementManager; getConfigManager(): ConfigManager; getInteractionManager(): InteractionManager; @@ -264,7 +264,7 @@ export interface CardTriggersAPI { export interface CardViewAPI { getCameraManager(): CameraManager; getCardElementManager(): CardElementManager; - getConditionsManager(): ConditionsManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getHASSManager(): HASSManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager; @@ -295,7 +295,6 @@ export interface MicrophoneState { forbidden: boolean; } -interface TaggedAutomation extends Automation { +export interface TaggedAutomation extends Automation { tag?: unknown; } -export type TaggedAutomations = TaggedAutomation[]; diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index e9671b7f..cfc8c881 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -301,7 +301,7 @@ export class ViewManager implements ViewManagerInterface { this._api.getMessageManager().reset(); this._api.getStyleManager().setExpandedMode(); - this._api.getConditionsManager()?.setState({ + this._api.getConditionStateManager()?.setState({ view: view?.view, camera: view?.camera, displayMode: view?.displayMode ?? undefined, diff --git a/src/card.ts b/src/card.ts index 944a6b12..c7a7b71f 100644 --- a/src/card.ts +++ b/src/card.ts @@ -7,7 +7,6 @@ import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import 'web-dialog'; import { actionHandler } from './action-handler-directive.js'; -import { ConditionsEvaluateRequestEvent } from './card-controller/conditions-manager.js'; import { CardController } from './card-controller/controller'; import { MenuButtonController } from './components-lib/menu-button-controller'; import './components/elements.js'; @@ -23,6 +22,7 @@ import './components/status-bar'; import './components/thumbnail-carousel.js'; import './components/views.js'; import { AdvancedCameraCardViews } from './components/views.js'; +import { ConditionStateManagerGetEvent } from './conditions/state-manager-via-event.js'; import { AdvancedCameraCardConfig, MenuItem, @@ -95,7 +95,6 @@ class AdvancedCameraCard extends LitElement { // diagnostics starting at the top). () => this._refMain.value?.scroll({ top: 0 }), () => this._refMenu.value?.toggleMenu(), - this._requestUpdateForComponentsThatUseConditions.bind(this), ); protected _menuButtonController = new MenuButtonController(); @@ -143,21 +142,6 @@ class AdvancedCameraCard extends LitElement { return CardController.getStubConfig(entities); } - protected _requestUpdateForComponentsThatUseConditions(): void { - // Update the components that need to know about condition changes. Trigger - // updates directly on them to them to avoid the performance hit of a entire - // card re-render (esp. when using card-mod). - // https://github.com/dermotduffy/advanced-camera-card/issues/678 - if (this._refViews.value) { - this._refViews.value.conditionsManagerEpoch = - this._controller.getConditionsManager().getEpoch() ?? undefined; - } - if (this._refElements.value) { - this._refElements.value.conditionsManagerEpoch = - this._controller.getConditionsManager().getEpoch() ?? undefined; - } - } - public setConfig(config: RawAdvancedCameraCardConfig): void { this._controller.getConfigManager().setConfig(config); } @@ -390,16 +374,10 @@ class AdvancedCameraCard extends LitElement { .viewManagerEpoch=${this._controller.getViewManager().getEpoch()} .cameraManager=${cameraManager} .resolvedMediaCache=${this._controller.getResolvedMediaCache()} - .nonOverriddenConfig=${this._controller - .getConfigManager() - .getNonOverriddenConfig()} - .overriddenConfig=${this._controller.getConfigManager().getConfig()} + .config=${this._controller.getConfigManager().getConfig()} .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()} .rawConfig=${this._controller.getConfigManager().getRawConfig()} .configManager=${this._controller.getConfigManager()} - .conditionsManagerEpoch=${this._controller - .getConditionsManager() - ?.getEpoch()} .hide=${!!this._controller.getMessageManager().hasMessage()} .microphoneState=${this._controller.getMicrophoneManager().getState()} .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status @@ -421,9 +399,6 @@ class AdvancedCameraCard extends LitElement { ${ref(this._refElements)} .hass=${this._hass} .elements=${this._config?.elements} - .conditionsManagerEpoch=${this._controller - .getConditionsManager() - ?.getEpoch()} @advanced-camera-card:menu:add=${(ev: CustomEvent) => { this._menuButtonController.addDynamicMenuButton(ev.detail); this.requestUpdate(); @@ -446,12 +421,10 @@ class AdvancedCameraCard extends LitElement { .getStatusBarItemManager() .removeDynamicStatusBarItem(ev.detail); }} - @advanced-camera-card:conditions:evaluate=${( - ev: ConditionsEvaluateRequestEvent, + @advanced-camera-card:condition-state-manager:get=${( + ev: ConditionStateManagerGetEvent, ) => { - ev.evaluation = this._controller - .getConditionsManager() - ?.evaluateConditions(ev.conditions); + ev.conditionStateManager = this._controller.getConditionStateManager(); }} > ` diff --git a/src/components/elements.ts b/src/components/elements.ts index e78fd68a..3f361c18 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -8,10 +8,8 @@ import { unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { - ConditionsManagerEpoch, - evaluateConditionViaEvent, -} from '../card-controller/conditions-manager.js'; +import { ConditionsManager } from '../conditions/conditions-manager.js'; +import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js'; import { dispatchAdvancedCameraCardErrorEvent } from '../components-lib/message/dispatch.js'; import { AdvancedCameraCardConditional, @@ -76,13 +74,6 @@ export class AdvancedCameraCardElementsCore extends LitElement { @property({ attribute: false }) public elements?: PictureElements; - /** - * Need to ensure card re-renders when conditions change, hence having it as a - * property even though it is not currently directly used by this class. - */ - @property({ attribute: false }) - public conditionsManagerEpoch?: ConditionsManagerEpoch; - protected _root: HuiConditionalElement | null = null; @property({ attribute: false }) @@ -162,9 +153,6 @@ export class AdvancedCameraCardElements extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; - @property({ attribute: false }) - public conditionsManagerEpoch?: ConditionsManagerEpoch; - @property({ attribute: false }) public elements: PictureElements; @@ -248,7 +236,6 @@ export class AdvancedCameraCardElements extends LitElement { protected render(): TemplateResult { return html` `; @@ -267,6 +254,7 @@ export class AdvancedCameraCardElements extends LitElement { @customElement('advanced-camera-card-conditional') export class AdvancedCameraCardElementsConditional extends LitElement { protected _config?: AdvancedCameraCardConditional; + protected _conditionManager: ConditionsManager | null = null; // A note on hass as an update mechanism: // @@ -283,6 +271,7 @@ export class AdvancedCameraCardElementsConditional extends LitElement { */ public setConfig(config: AdvancedCameraCardConditional): void { this._config = config; + this._createConditionManager(); } /** @@ -303,13 +292,32 @@ export class AdvancedCameraCardElementsConditional extends LitElement { // this is a transparent 'conditional' element (just like the stock HA // 'conditional' element), it should not have positioning. this.className = ''; + + this._createConditionManager(); + } + + disconnectedCallback(): void { + this._conditionManager?.destroy(); + } + + protected _createConditionManager(): void { + const conditionStateManager = getConditionStateManagerViaEvent(this); + if (!this._config || !conditionStateManager) { + return; + } + this._conditionManager?.destroy(); + this._conditionManager = new ConditionsManager( + this._config.conditions, + conditionStateManager, + ); + this._conditionManager.addListener(() => this.requestUpdate()); } /** * Render the card. */ protected render(): TemplateResult | void { - if (evaluateConditionViaEvent(this, this._config?.conditions)) { + if (this._conditionManager?.getEvaluation()?.result) { return html` this._lazyloadOrUnloadSlide('load', index, slide), }), - lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload, + lazyUnloadConditions: this.liveConfig?.lazy_unload, lazyUnloadCallback: (index, slide) => this._lazyloadOrUnloadSlide('unload', index, slide), }), @@ -191,7 +167,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { */ protected _getLazyLoadCount(): number | null { // Defaults to fully-lazy loading. - return this.overriddenLiveConfig?.lazy_load === false ? null : 0; + return this.liveConfig?.lazy_load === false ? null : 0; } protected _getSlides(): [TemplateResult[], Record] { @@ -265,43 +241,17 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { cameraID: string, cameraConfig: CameraConfig, ): TemplateResult | void { - if ( - !this.overriddenLiveConfig || - !this.nonOverriddenLiveConfig || - !this.hass || - !this.cameraManager || - !this.conditionsManagerEpoch - ) { + if (!this.liveConfig || !this.hass || !this.cameraManager) { return; } - let liveConfig: LiveConfig | null = null; - - try { - // The condition controller object contains the currently live camera, which - // (in the carousel for example) is not necessarily the live camera *this* - // is rendering right now, so we provide a - // stateOverride to evaluate the condition in that context. - liveConfig = getOverriddenConfig( - this.conditionsManagerEpoch.manager, - { live: this.nonOverriddenLiveConfig }, - { - configOverrides: this.overrides, - stateOverrides: { camera: cameraID }, - schema: liveConfigAbsoluteRootSchema, - }, - ).live; - } catch (ev) { - return dispatchAdvancedCameraCardErrorEvent(this, ev); - } - const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID); const view = this.viewManagerEpoch?.manager.getView(); return html`
this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, )} .label=${cameraMetadata?.title ?? ''} - .liveConfig=${liveConfig} + .liveConfig=${this.liveConfig} .hass=${this.hass} .cardWideConfig=${this.cardWideConfig} .zoomSettings=${view?.context?.zoom?.[cameraID]?.requested} @@ -385,7 +335,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { slot=${side} .hass=${this.hass} .side=${side} - .controlConfig=${this.overriddenLiveConfig?.controls.next_previous} + .controlConfig=${this.liveConfig?.controls.next_previous} .label=${neighbor?.metadata?.title ?? ''} .icon=${neighbor?.metadata?.icon} ?disabled=${!neighbor} @@ -399,7 +349,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { protected render(): TemplateResult | void { const view = this.viewManagerEpoch?.manager.getView(); - if (!this.overriddenLiveConfig || !this.hass || !view || !this.cameraManager) { + if (!this.liveConfig || !this.hass || !view || !this.cameraManager) { return; } @@ -427,9 +377,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { , ) => this._gridSelectCamera(ev.detail.selected)} diff --git a/src/components/live/index.ts b/src/components/live/index.ts index 200097a7..1639450a 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -1,21 +1,16 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraManager } from '../../camera-manager/manager.js'; -import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { LiveController } from '../../components-lib/live/live-controller.js'; -import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js'; +import { CardWideConfig, LiveConfig } from '../../config/types.js'; import basicBlockStyle from '../../scss/basic-block.scss'; import { ExtendedHomeAssistant } from '../../types.js'; -import { contentsChanged } from '../../utils/basic.js'; import './grid.js'; @customElement('advanced-camera-card-live') export class AdvancedCameraCardLive extends LitElement { - @property({ attribute: false }) - public conditionsManagerEpoch?: ConditionsManagerEpoch; - @property({ attribute: false }) public hass?: ExtendedHomeAssistant; @@ -23,13 +18,7 @@ export class AdvancedCameraCardLive extends LitElement { public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false }) - public nonOverriddenLiveConfig?: LiveConfig; - - @property({ attribute: false }) - public overriddenLiveConfig?: LiveConfig; - - @property({ attribute: false, hasChanged: contentsChanged }) - public overrides?: Overrides; + public liveConfig?: LiveConfig; @property({ attribute: false }) public cameraManager?: CameraManager; @@ -46,7 +35,7 @@ export class AdvancedCameraCardLive extends LitElement { protected _controller = new LiveController(this); protected render(): TemplateResult | void { - if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) { + if (!this.hass || !this.cameraManager) { return; } @@ -60,11 +49,8 @@ export class AdvancedCameraCardLive extends LitElement { v5.0.0-beta1 . - // - // These updates are necessary in these cases: - // - conditionState: Required to let `advanced-camera-card-live` calculate its own - // overrides. - // - hass: Required for anything that needs to sign URLs. Of note is - // anything that renders an image (e.g. a thumbnail -- almost everything, - // or the main `advanced-camera-card-image` view). - // - // It should instead be possible to pass conditionState to live only (every - // update required), and pass hass only once / 5 minutes (see - // HASS_REJECTION_CUTOFF_MS). - return true; - } - protected _shouldLivePreload(): boolean { const view = this.viewManagerEpoch?.manager.getView(); return ( // Special case: Never preload for diagnostics -- we want that to be as // minimal as possible. - !!this.overriddenConfig?.live.preload && !view?.is('diagnostics') + !!this.config?.live.preload && !view?.is('diagnostics') ); } @@ -127,12 +101,7 @@ export class AdvancedCameraCardViews extends LitElement { // overall views pane to render in ~almost all cases (e.g. for a camera // initialization error to display, `view` and `cameraConfig` may both be // undefined, but we still want to render). - if ( - !this.hass || - !this.overriddenConfig || - !this.nonOverriddenConfig || - !this.cardWideConfig - ) { + if (!this.hass || !this.config || !this.cardWideConfig) { return html``; } @@ -148,17 +117,17 @@ export class AdvancedCameraCardViews extends LitElement { }; const thumbnailConfig = view?.is('live') - ? this.overriddenConfig.live.controls.thumbnails + ? this.config.live.controls.thumbnails : view?.isViewerView() - ? this.overriddenConfig.media_viewer.controls.thumbnails + ? this.config.media_viewer.controls.thumbnails : view?.is('timeline') - ? this.overriddenConfig.timeline.controls.thumbnails + ? this.config.timeline.controls.thumbnails : undefined; const miniTimelineConfig = view?.is('live') - ? this.overriddenConfig.live.controls.timeline + ? this.config.live.controls.timeline : view?.isViewerView() - ? this.overriddenConfig.media_viewer.controls.timeline + ? this.config.media_viewer.controls.timeline : undefined; const cameraConfig = view @@ -176,7 +145,7 @@ export class AdvancedCameraCardViews extends LitElement { > ${!this.hide && view?.is('image') && cameraConfig ? html` @@ -199,7 +168,7 @@ export class AdvancedCameraCardViews extends LitElement { @@ -226,21 +195,14 @@ export class AdvancedCameraCardViews extends LitElement { ` : ``} ${ - // Note: Subtle difference in condition below vs the other views in order - // to always render the live view for live.preload mode. - - // Note: uses nonOverriddenConfig rather than the - // overriden config as it does it's own overriding as part of the camera - // carousel. + // Note: Subtle difference in condition below vs the other views in + // order to always render the live view for live.preload mode. this._shouldLivePreload() || (!this.hide && view?.is('live')) ? html` + !condition.condition || + ['state', 'numeric_state', 'user'].includes(condition.condition), + ); + + conditions.forEach((condition) => { + if (condition.condition === 'screen') { + const mql = window.matchMedia(condition.media_query); + mql.addEventListener('change', this._mediaQueryHandler); + this._mediaQueries.push(mql); + } + }); + + this._stateManager = stateManager ?? null; + + // Do an initial condition evaluation, but without calling listeners. + this._evaluate({ callListeners: false }); + + this._stateManager?.addListener(this._stateManagerHandler); + } + + public destroy(): void { + this._stateManager?.removeListener(this._stateManagerHandler); + + this._listeners.forEach((l) => this.removeListener(l)); + + this._mediaQueries.forEach((mql) => + mql.removeEventListener('change', this._mediaQueryHandler), + ); + this._mediaQueries = []; + this._conditions = []; + } + + public addListener(listener: ConditionsListener): void { + if (!this._listeners.includes(listener)) { + this._listeners.push(listener); + } + } + + public removeListener(listener: ConditionsListener): void { + this._listeners = this._listeners.filter((l) => l !== listener); + } + + public getEvaluation(): ConditionsEvaluationResult { + return this._evaluation; + } + + protected _mediaQueryHandler = () => this._evaluate(); + + protected _stateManagerHandler = (stateChange: ConditionStateChange): void => { + // As a performance optmization, if only Home Assistant state has changed + // (very frequent), and there aren't any related conditions, don't bother + // calling for the evealuation / listeners. + if ( + Object.keys(stateChange.change).length === 1 && + 'state' in stateChange.change && + !this._hasHAStateConditions + ) { + return; + } + + this._evaluate({ stateChange }); + }; + + protected _evaluate(options?: { + stateChange?: ConditionStateChange; + callListeners?: boolean; + }): void { + const state = options?.stateChange?.new ?? this._stateManager?.getState(); + + let result = true; + let data: ConditionsEvaluationData = {}; + + for (const condition of this._conditions) { + const evaluation = this._evaluateCondition( + condition, + state, + options?.stateChange?.old, + ); + if (!evaluation.result) { + result = false; + break; + } + data = { + ...data, + ...evaluation.data, + }; + } + + const evaluation: ConditionsEvaluationResult = result + ? { result, data } + : { result }; + + if (!isEqual(evaluation, this._evaluation)) { + this._evaluation = evaluation; + if (options?.callListeners ?? true) { + this._listeners.forEach( + (listener) => this._evaluation && listener(this._evaluation), + ); + } + } + } + + protected _evaluateCondition( + condition: AdvancedCameraCardCondition, + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + switch (condition.condition) { + case undefined: + case 'state': + return { + result: + !!newState?.state && + ((!condition.state && !condition.state_not) || + (condition.entity in newState.state && + (!condition.state || + (Array.isArray(condition.state) + ? condition.state.includes(newState.state[condition.entity].state) + : condition.state === newState.state[condition.entity].state)) && + (!condition.state_not || + (Array.isArray(condition.state_not) + ? !condition.state_not.includes( + newState.state[condition.entity].state, + ) + : condition.state_not !== newState.state[condition.entity].state)))), + data: { + state: { + entity: condition.entity, + ...(oldState?.state?.[condition.entity]?.state && { + from: oldState?.state?.[condition.entity]?.state, + }), + ...(newState?.state?.[condition.entity]?.state && { + to: newState?.state?.[condition.entity]?.state, + }), + }, + }, + }; + case 'view': + return { + result: + (!!newState?.view && condition.views?.includes(newState.view)) || + (newState?.view !== oldState?.view && !condition.views?.length), + data: { + ...((oldState?.view || newState?.view) && { + view: { + ...(oldState?.view && { from: oldState.view }), + ...(newState?.view && { to: newState.view }), + }, + }), + }, + }; + case 'fullscreen': + return { + result: + newState?.fullscreen !== undefined && + condition.fullscreen === newState.fullscreen, + }; + case 'expand': + return { + result: newState?.expand !== undefined && condition.expand === newState.expand, + }; + case 'camera': + return { + result: + (!!newState?.camera && !!condition.cameras?.includes(newState.camera)) || + (newState?.camera !== oldState?.camera && !condition.cameras?.length), + data: { + ...((oldState?.camera || newState?.camera) && { + camera: { + ...(oldState?.camera && { from: oldState?.camera }), + ...(newState?.camera && { to: newState?.camera }), + }, + }), + }, + }; + case 'numeric_state': + return { + result: + !!newState?.state && + condition.entity in newState.state && + newState.state[condition.entity].state !== undefined && + (condition.above === undefined || + Number(newState.state[condition.entity].state) > condition.above) && + (condition.below === undefined || + Number(newState.state[condition.entity].state) < condition.below), + }; + case 'user': + return { + result: !!newState?.user && condition.users.includes(newState.user.id), + }; + case 'media_loaded': + return { + result: + newState?.mediaLoadedInfo !== undefined && + condition.media_loaded === !!newState.mediaLoadedInfo, + }; + case 'screen': + return { result: window.matchMedia(condition.media_query).matches }; + case 'display_mode': + return { + result: + !!newState?.displayMode && condition.display_mode === newState.displayMode, + }; + case 'triggered': + return { + result: condition.triggered.some((triggeredCameraID) => + newState?.triggered?.has(triggeredCameraID), + ), + }; + case 'interaction': + return { + result: + newState?.interaction !== undefined && + condition.interaction === newState.interaction, + }; + case 'microphone': + return { + result: + (condition.connected === undefined || + newState?.microphone?.connected === condition.connected) && + (condition.muted === undefined || + newState?.microphone?.muted === condition.muted), + }; + case 'key': + return { + result: + !!newState?.keys && + condition.key in newState.keys && + (condition.state ?? 'down') === newState.keys[condition.key].state && + (condition.ctrl === undefined || + condition.ctrl === !!newState.keys[condition.key].ctrl) && + (condition.alt === undefined || + condition.alt === !!newState.keys[condition.key].alt) && + (condition.meta === undefined || + condition.meta === !!newState.keys[condition.key].meta) && + (condition.shift === undefined || + condition.shift === !!newState.keys[condition.key].shift), + }; + case 'user_agent': + return { + result: + !!newState?.userAgent && + (!condition.user_agent || condition.user_agent === newState.userAgent) && + (condition.companion === undefined || + condition.companion === isCompanionApp(newState.userAgent)) && + (condition.user_agent_re === undefined || + new RegExp(condition.user_agent_re).test(newState.userAgent)), + }; + } + } +} diff --git a/src/conditions/state-manager-via-event.ts b/src/conditions/state-manager-via-event.ts new file mode 100644 index 00000000..4d3ed63a --- /dev/null +++ b/src/conditions/state-manager-via-event.ts @@ -0,0 +1,38 @@ +import { ConditionStateManager } from './state-manager'; + +export class ConditionStateManagerGetEvent extends Event { + public conditionStateManager?: ConditionStateManager; + + constructor(eventInitDict?: EventInit) { + super('advanced-camera-card:condition-state-manager:get', eventInitDict); + } +} + +/** + * Fetch the main ConditionStateManager via an event. + * @returns The ConditionStateManager or null if not found. + */ + +export function getConditionStateManagerViaEvent( + element: HTMLElement, +): ConditionStateManager | null { + const getEvent = new ConditionStateManagerGetEvent({ + bubbles: true, + composed: true, + }); + + /* Special note on what's going on here: + * + * Some parts of the card (e.g. ) may have arbitrary + * complexity and layers (that this card doesn't control) between that master + * element and the element that needs to evaluate the condition. In these + * cases there's no clean way to pass state from the rest of card down through + * these layers. Instead, an event is dispatched as a "request for evaluation" + * (ConditionEvaluateRequestEvent) upwards which is caught by the outer card + * and the evaluation result is added to the event object. Because event + * propagation is handled synchronously, the result will be added to the event + * before the flow proceeds. + */ + element.dispatchEvent(getEvent); + return getEvent.conditionStateManager ?? null; +} diff --git a/src/conditions/state-manager.ts b/src/conditions/state-manager.ts new file mode 100644 index 00000000..df9132a4 --- /dev/null +++ b/src/conditions/state-manager.ts @@ -0,0 +1,60 @@ +import { isEqual } from 'lodash-es'; +import { + ConditionState, + ConditionStateChange, + ConditionStateListener, + ConditionStateManagerReadonlyInterface, +} from './types'; + +/** + * A class to manage state used in the evaluation of conditions. + */ +export class ConditionStateManager implements ConditionStateManagerReadonlyInterface { + protected _listeners: ConditionStateListener[] = []; + protected _state: ConditionState = {}; + + public addListener(listener: ConditionStateListener): void { + this._listeners.push(listener); + } + + public removeListener(listener: ConditionStateListener): void { + this._listeners = this._listeners.filter((l) => l !== listener); + } + + public getState(): ConditionState { + return this._state; + } + + public setState(state: ConditionState): void { + this._processStateChange(this._calculateTrueChange(state)); + } + + protected _processStateChange(changeState: ConditionState): void { + if (!Object.keys(changeState).length) { + return; + } + + const oldState = this._state; + this._state = { + ...oldState, + ...changeState, + }; + this._callListeners({ old: oldState, change: changeState, new: this._state }); + } + + protected _calculateTrueChange(change: ConditionState): ConditionState { + const changeState: ConditionState = {}; + + for (const key of Object.keys(change)) { + if (!isEqual(change[key], this._state[key])) { + changeState[key] = change[key]; + } + } + + return changeState; + } + + protected _callListeners = (stateChange: ConditionStateChange): void => { + this._listeners.forEach((listener) => listener(stateChange)); + }; +} diff --git a/src/conditions/types.ts b/src/conditions/types.ts new file mode 100644 index 00000000..088fe0a2 --- /dev/null +++ b/src/conditions/types.ts @@ -0,0 +1,69 @@ +import { CurrentUser } from '@dermotduffy/custom-card-helpers'; +import { HassEntities } from 'home-assistant-js-websocket'; +import { ViewDisplayMode } from '../config/types'; +import { MediaLoadedInfo } from '../types'; +import { KeysState, MicrophoneState } from '../card-controller/types'; + +export interface ConditionState { + camera?: string; + displayMode?: ViewDisplayMode; + expand?: boolean; + fullscreen?: boolean; + interaction?: boolean; + keys?: KeysState; + mediaLoadedInfo?: MediaLoadedInfo | null; + microphone?: MicrophoneState; + state?: HassEntities; + triggered?: Set; + user?: CurrentUser; + userAgent?: string; + view?: string; +} + +export interface ConditionStateChange { + old: ConditionState; + change: ConditionState; + new: ConditionState; +} + +export type ConditionStateListener = (change: ConditionStateChange) => void; + +export interface ConditionStateManagerReadonlyInterface { + addListener(listener: ConditionStateListener): void; + removeListener(listener: ConditionStateListener): void; + getState(): ConditionState; +} + +interface ConditionsEvaluationDataFromTo { + from?: string; + to?: string; +} + +interface ConditionsEvaluationDataState extends ConditionsEvaluationDataFromTo { + entity: string; +} + +export interface ConditionsEvaluationData { + camera?: ConditionsEvaluationDataFromTo; + view?: ConditionsEvaluationDataFromTo; + state?: ConditionsEvaluationDataState; +} +interface ConditionsEvaluationResultTrue { + result: true; + data?: ConditionsEvaluationData; +} +interface ConditionsEvaluationResultFalse { + result: false; +} + +export type ConditionsEvaluationResult = + | ConditionsEvaluationResultTrue + | ConditionsEvaluationResultFalse; + +export type ConditionsListener = (result: ConditionsEvaluationResult) => void; + +export interface ConditionsManagerReadonlyInterface { + addListener(listener: ConditionsListener): void; + removeListener(listener: ConditionsListener): void; + getEvaluation(): ConditionsEvaluationResult | null; +} diff --git a/src/config/types.ts b/src/config/types.ts index b178fea4..b02a4ec9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -685,7 +685,7 @@ export type StatusBarItem = z.infer; const viewConditionSchema = z.object({ condition: z.literal('view'), - views: z.string().array(), + views: z.string().array().optional(), }); const fullscreenConditionSchema = z.object({ condition: z.literal('fullscreen'), @@ -697,7 +697,7 @@ const expandConditionSchema = z.object({ }); const cameraConditionSchema = z.object({ condition: z.literal('camera'), - cameras: z.string().array(), + cameras: z.string().array().optional(), }); const mediaLoadedConditionSchema = z.object({ condition: z.literal('media_loaded'), @@ -773,7 +773,7 @@ export type AdvancedCameraCardCondition = z.infer< typeof advancedCameraCardConditionSchema >; -export const advancedCameraCardConditionalSchema = z.object({ +const advancedCameraCardConditionalSchema = z.object({ type: z.literal('custom:advanced-camera-card-conditional'), conditions: advancedCameraCardConditionSchema.array(), elements: z.lazy(() => pictureElementsSchema), @@ -1328,16 +1328,6 @@ const liveConfigSchema = z .default(liveConfigDefault); export type LiveConfig = z.infer; -// This schema is used when the live config needs to be overridden (see -// `live.ts`). Overrides will always be "relative" to the config root, so this -// schema maintains that 'depth' from the root but without the other -// requirements that advancedCameraCardConfigSchema has. Without this, overrides -// calculated in `live.ts` would fail since cameras/type are not provided (as -// these are mandatory parameters in the full config). -export const liveConfigAbsoluteRootSchema = z.object({ - live: liveConfigSchema, -}); - // ************************************************************************* // Cast Configuration // ************************************************************************* diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index f90d904a..1c69b91d 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; -import { createCardAPI } from '../test-utils.js'; -import { ActionType } from '../../src/config/types.js'; import { AuxillaryActionConfig } from '../../src/card-controller/actions/types.js'; +import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; +import { ConditionStateManager } from '../../src/conditions/state-manager.js'; +import { ActionType } from '../../src/config/types.js'; +import { createCardAPI } from '../test-utils.js'; describe('AutomationsManager', () => { const actions = [ @@ -25,39 +26,55 @@ describe('AutomationsManager', () => { vi.clearAllMocks(); }); - it('should do nothing without hass', () => { - const api = createCardAPI(); + describe('should not execute actions', () => { + it('should do nothing without hass', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - const automationsManager = new AutomationsManager(api); - automationsManager.execute(); + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([automation]); - expect(api.getActionsManager().executeActions).not.toBeCalled(); - }); + stateManager.setState({ fullscreen: true }); - it('should do nothing without being initialized', () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); - vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( - false, - ); + expect(api.getActionsManager().executeActions).not.toBeCalled(); + }); - const automationsManager = new AutomationsManager(api); - automationsManager.execute(); + it('should do nothing without being initialized', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + false, + ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - expect(api.getActionsManager().executeActions).not.toBeCalled(); - }); + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([automation]); - it('should do nothing without automations', () => { - const api = createCardAPI(); - vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); - vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( - true, - ); + stateManager.setState({ fullscreen: true }); - const automationsManager = new AutomationsManager(api); - automationsManager.execute(); + expect(api.getActionsManager().executeActions).not.toBeCalled(); + }); - expect(api.getActionsManager().executeActions).not.toBeCalled(); + it('should do nothing with an error message present', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + vi.mocked(api.getMessageManager().hasErrorMessage).mockReturnValue(true); + + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const automationsManager = new AutomationsManager(api); + automationsManager.addAutomations([automation]); + + stateManager.setState({ fullscreen: true }); + + expect(api.getActionsManager().executeActions).not.toBeCalled(); + }); }); it('should execute actions', () => { @@ -66,31 +83,25 @@ describe('AutomationsManager', () => { vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( true, ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); const automationsManager = new AutomationsManager(api); automationsManager.addAutomations([automation]); - automationsManager.execute(); - expect(api.getActionsManager().executeActions).not.toBeCalled(); + stateManager.setState({ fullscreen: true }); - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(true); - - automationsManager.execute(); expect(api.getActionsManager().executeActions).toBeCalledTimes(1); // Automation will not re-fire when condition continues to evaluate the // same. - automationsManager.execute(); + stateManager.setState({ fullscreen: true }); expect(api.getActionsManager().executeActions).toBeCalledTimes(1); - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(false); - - automationsManager.execute(); + stateManager.setState({ fullscreen: false }); expect(api.getActionsManager().executeActions).toBeCalledTimes(1); - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(true); - - automationsManager.execute(); + stateManager.setState({ fullscreen: true }); expect(api.getActionsManager().executeActions).toBeCalledTimes(2); }); @@ -100,14 +111,16 @@ describe('AutomationsManager', () => { vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( true, ); - - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(false); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); const automationsManager = new AutomationsManager(api); automationsManager.addAutomations([not_automation]); - automationsManager.execute(); + stateManager.setState({ fullscreen: true }); + expect(api.getActionsManager().executeActions).not.toBeCalled(); + stateManager.setState({ fullscreen: false }); expect(api.getActionsManager().executeActions).toBeCalled(); }); @@ -117,12 +130,23 @@ describe('AutomationsManager', () => { vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( true, ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); const automationsManager = new AutomationsManager(api); - automationsManager.addAutomations([automation, not_automation]); + automationsManager.addAutomations([ + { + conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], + actions: actions, + }, + { + conditions: [{ condition: 'fullscreen' as const, fullscreen: false }], + actions_not: actions, + }, + ]); // Create a setup where one automation action causes another... - let evaluation = true; + let fullscreen = true; vi.mocked(api.getActionsManager().executeActions).mockImplementation( async ( @@ -131,17 +155,12 @@ describe('AutomationsManager', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars _config?: AuxillaryActionConfig, ): Promise => { - evaluation = !evaluation; - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue( - evaluation, - ); - automationsManager.execute(); + fullscreen = !fullscreen; + stateManager.setState({ fullscreen: fullscreen }); }, ); - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(evaluation); - - automationsManager.execute(); + stateManager.setState({ fullscreen: fullscreen }); expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( expect.objectContaining({ @@ -160,18 +179,40 @@ describe('AutomationsManager', () => { vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( true, ); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); const automationsManager = new AutomationsManager(api); - automationsManager.addAutomations([automation]); + automationsManager.addAutomations([ + { + conditions: [{ condition: 'expand' as const, expand: true }], + actions: actions, + }, + { + conditions: [{ condition: 'fullscreen' as const, fullscreen: true }], + actions: actions, + tag: 'fullscreen', + }, + ]); - vi.mocked(api.getConditionsManager().evaluateConditions).mockReturnValue(true); - - automationsManager.execute(); + stateManager.setState({ fullscreen: true }); expect(api.getActionsManager().executeActions).toBeCalledTimes(1); + // Delete the fullscreen automation. + automationsManager.deleteAutomations('fullscreen'); + + stateManager.setState({ fullscreen: false }); + stateManager.setState({ fullscreen: true }); + expect(api.getActionsManager().executeActions).toBeCalledTimes(1); + + stateManager.setState({ expand: true }); + expect(api.getActionsManager().executeActions).toBeCalledTimes(2); + + // Delete all automations. automationsManager.deleteAutomations(); - automationsManager.execute(); - expect(api.getActionsManager().executeActions).toBeCalledTimes(1); + stateManager.setState({ fullscreen: false }); + stateManager.setState({ fullscreen: true }); + expect(api.getActionsManager().executeActions).toBeCalledTimes(2); }); }); diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts deleted file mode 100644 index f32d44b0..00000000 --- a/tests/card-controller/conditions-manager.test.ts +++ /dev/null @@ -1,1136 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod'; -import { - ConditionsEvaluateRequestEvent, - ConditionsManager, - evaluateConditionViaEvent, - getOverriddenConfig, -} from '../../src/card-controller/conditions-manager'; -import { MicrophoneState } from '../../src/card-controller/types'; -import { AdvancedCameraCardCondition } from '../../src/config/types'; -import { - createCardAPI, - createCondition, - createConfig, - createMediaLoadedInfo, - createStateEntity, - createUser, -} from '../test-utils'; - -// @vitest-environment jsdom -describe('ConditionEvaluateRequestEvent', () => { - it('should construct', () => { - const conditions = [createCondition({ condition: 'fullscreen', fullscreen: true })]; - const event = new ConditionsEvaluateRequestEvent(conditions, { - bubbles: true, - composed: true, - }); - - expect(event.type).toBe('advanced-camera-card:conditions:evaluate'); - expect(event.conditions).toBe(conditions); - expect(event.bubbles).toBeTruthy(); - expect(event.composed).toBeTruthy(); - }); -}); - -describe('evaluateConditionViaEvent', () => { - it('should evaluate true without condition', () => { - const element = document.createElement('div'); - expect(evaluateConditionViaEvent(element)).toBeTruthy(); - }); - it('should dispatch event with condition and evaluate true', () => { - const element = document.createElement('div'); - const conditions = [createCondition({ condition: 'fullscreen', fullscreen: true })]; - const handler = vi.fn().mockImplementation((ev: ConditionsEvaluateRequestEvent) => { - expect(ev.conditions).toBe(conditions); - ev.evaluation = true; - }); - element.addEventListener('advanced-camera-card:conditions:evaluate', handler); - - expect(evaluateConditionViaEvent(element, conditions)).toBeTruthy(); - expect(handler).toBeCalled(); - }); - it('should dispatch event with condition and evaluate false', () => { - const element = document.createElement('div'); - const conditions = [createCondition({ condition: 'fullscreen', fullscreen: true })]; - const handler = vi.fn().mockImplementation((ev: ConditionsEvaluateRequestEvent) => { - expect(ev.conditions).toBe(conditions); - ev.evaluation = false; - }); - element.addEventListener('advanced-camera-card:conditions:evaluate', handler); - - expect(evaluateConditionViaEvent(element, conditions)).toBeFalsy(); - expect(handler).toBeCalled(); - }); - it('should dispatch event evaluate false if no evaluation', () => { - const element = document.createElement('div'); - const conditions = [createCondition({ condition: 'fullscreen', fullscreen: true })]; - const handler = vi.fn(); - element.addEventListener('advanced-camera-card:conditions:evaluate', handler); - - expect(evaluateConditionViaEvent(element, conditions)).toBeFalsy(); - expect(handler).toBeCalled(); - }); -}); - -describe('getOverriddenConfig', () => { - const config = { - menu: { - style: 'none', - }, - }; - - it('should not override without overrides', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect(getOverriddenConfig(manager, config)).toBe(config); - }); - - it('should not override when condition does not match', () => { - const manager = new ConditionsManager(createCardAPI()); - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: { - menu: { - style: 'hidden', - }, - }, - delete: ['menu.style'], - set: { - 'menu.style': 'overlay', - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toBe(config); - }); - - describe('should merge', () => { - it('with path', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: { - 'live.controls.thumbnails': { - mode: 'none', - }, - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'none', - }, - live: { - controls: { - thumbnails: { - mode: 'none', - }, - }, - }, - }); - }); - - it('without path', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: { - menu: { - style: 'hidden', - }, - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - - it('with invalid merge', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: 6 as unknown as Record, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'none', - }, - }); - }); - }); - - describe('should set', () => { - it('leaf node', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - set: { - 'menu.style': 'hidden', - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - - it('root node', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - set: { - menu: { - style: 'hidden', - }, - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - }); - - describe('should delete', () => { - it('leaf node', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - delete: ['menu.style' as const], - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: {}, - }); - }); - - it('root node', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - delete: ['menu' as const], - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({}); - }); - }); - - describe('should validate schema', () => { - const testSchema = z.object({ - menu: z.object({ - style: z.enum(['none', 'hidden']), - }), - }); - - it('passing', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - set: { - 'menu.style': 'hidden', - }, - }, - ], - schema: testSchema, - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - - it('failing', () => { - const manager = new ConditionsManager(createCardAPI()); - manager.setState({ fullscreen: true }); - - expect(() => - getOverriddenConfig(manager, config, { - configOverrides: [ - { - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - set: { - 'menu.style': 'NOT_A_STYLE', - }, - }, - ], - schema: testSchema, - }), - ).toThrowError(/Invalid override configuration/); - }); - }); -}); - -describe('ConditionsManager', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should get epoch', () => { - const manager = new ConditionsManager(createCardAPI()); - const epoch_1 = manager.getEpoch(); - expect(epoch_1).toEqual({ manager: manager }); - - manager.setState({ fullscreen: true }); - - const epoch_2 = manager.getEpoch(); - expect(epoch_2).toEqual({ manager: manager }); - - // Since the state was set the wrappers should be different. - expect(epoch_1).not.toBe(epoch_2); - }); - - it('should get state', () => { - const state = { fullscreen: true }; - - const manager = new ConditionsManager(createCardAPI()); - manager.setState(state); - expect(manager.getState()).toEqual(state); - }); - - describe('should set state', () => { - it('should set and be able to get it again', () => { - const state = { - fullscreen: true, - }; - - const manager = new ConditionsManager(createCardAPI()); - - manager.setState(state); - expect(manager.getState()).toEqual(state); - }); - - it('should set but only trigger when necessary', () => { - const state_1 = { - fullscreen: true, - state: { - 'binary_sensor.foo': createStateEntity(), - }, - }; - - const listener = vi.fn(); - const manager = new ConditionsManager(createCardAPI(), listener); - - manager.setState(state_1); - expect(listener).toBeCalledTimes(1); - - manager.setState(state_1); - expect(listener).toBeCalledTimes(1); - - manager.setState({ fullscreen: true }); - expect(listener).toBeCalledTimes(1); - - manager.setState({ - state: { - 'binary_sensor.foo': createStateEntity(), - }, - }); - expect(listener).toBeCalledTimes(1); - - manager.setState({ fullscreen: false }); - expect(listener).toBeCalledTimes(2); - - manager.setState({ fullscreen: false }); - expect(listener).toBeCalledTimes(2); - - manager.setState({ - state: { - 'binary_sensor.foo': createStateEntity({ state: 'off' }), - }, - }); - expect(listener).toBeCalledTimes(3); - }); - }); - - describe('should handle hasHAStateConditions', () => { - beforeEach(() => { - vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ - matches: false, - addEventListener: vi.fn(), - } as unknown as MediaQueryList); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const createSuitableConfig = (conditions: AdvancedCameraCardCondition[]) => { - return createConfig({ - overrides: [ - { - merge: {}, - conditions: conditions, - }, - ], - }); - }; - - it('without HA state conditions', () => { - const manager = new ConditionsManager(createCardAPI()); - expect(manager.hasHAStateConditions()).toBeFalsy(); - }); - - describe('with HA state conditions', () => { - it('explicitly stated', () => { - const api = createCardAPI(); - const numericConfig = createSuitableConfig([ - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state: 'on', - }, - ]); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(numericConfig); - const manager = new ConditionsManager(api); - manager.setConditionsFromConfig(); - - expect(manager.hasHAStateConditions()).toBeTruthy(); - }); - - it('implicitly assumed', () => { - const api = createCardAPI(); - const numericConfig = createSuitableConfig([ - { - entity: 'binary_sensor.foo', - state: 'on', - }, - ]); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(numericConfig); - const manager = new ConditionsManager(api); - manager.setConditionsFromConfig(); - - expect(manager.hasHAStateConditions()).toBeTruthy(); - }); - }); - - it('with HA numeric_state conditions', () => { - const api = createCardAPI(); - const numericStateConfig = createSuitableConfig([ - { - condition: 'numeric_state' as const, - entity: 'sensor.foo', - above: 10, - }, - ]); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(numericStateConfig); - const manager = new ConditionsManager(api); - manager.setConditionsFromConfig(); - - expect(manager.hasHAStateConditions()).toBeTruthy(); - }); - - it('with HA user conditions', () => { - const api = createCardAPI(); - const userConfig = createSuitableConfig([ - { - condition: 'user' as const, - users: ['user_1'], - }, - ]); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(userConfig); - const manager = new ConditionsManager(api); - manager.setConditionsFromConfig(); - - expect(manager.hasHAStateConditions()).toBeTruthy(); - }); - - it('with automations', () => { - const api = createCardAPI(); - const userConfig = createConfig({ - automations: [ - { - conditions: [ - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state: 'on', - }, - ], - actions: [ - { - action: 'fire-dom-event', - }, - ], - }, - ], - }); - - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(userConfig); - const manager = new ConditionsManager(api); - manager.setConditionsFromConfig(); - - expect(manager.hasHAStateConditions()).toBeTruthy(); - }); - }); - - describe('should evaluate conditions', () => { - it('with a view condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'view' as const, views: ['foo'] }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ view: 'foo' }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - - it('with fullscreen condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'fullscreen' as const, fullscreen: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ fullscreen: true }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ fullscreen: false }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('with expand condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'expand' as const, expand: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ expand: true }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ expand: false }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('with camera condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'camera' as const, cameras: ['bar'] }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ camera: 'bar' }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ camera: 'will-not-match' }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - describe('with stock HA conditions', () => { - describe('with state condition', () => { - describe('positive', () => { - it('single', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state: 'on', - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('multiple', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state: ['active', 'on'], - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - }); - - describe('negative', () => { - it('single', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state_not: 'on', - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - }); - - it('multiple', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'state' as const, - entity: 'binary_sensor.foo', - state_not: ['active', 'on'], - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - - it('implicit state condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - entity: 'binary_sensor.foo', - state: 'on', - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - }); - - describe('with numeric state condition', () => { - it('above', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'numeric_state' as const, - entity: 'sensor.foo', - above: 10, - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '11' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: '9' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('below', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'numeric_state' as const, - entity: 'sensor.foo', - below: 10, - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '11' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '9' }) }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - }); - }); - - it('with user condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'user' as const, - users: ['user_1', 'user_2'], - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - user: createUser({ id: 'user_1' }), - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - user: createUser({ id: 'user_WRONG' }), - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('with media loaded condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'media_loaded' as const, media_loaded: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ mediaLoadedInfo: createMediaLoadedInfo() }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ mediaLoadedInfo: null }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - describe('with screen condition', () => { - const mediaQueryConfig = { - type: 'custom:advanced-camera-card', - cameras: [{}], - elements: [ - { - type: 'custom:advanced-camera-card-conditional', - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - elements: [ - { - type: 'custom:nested-unknown-object', - unknown_key: { - type: 'custom:advanced-camera-card-conditional', - conditions: [ - { - condition: 'screen' as const, - media_query: 'media query goes here', - }, - ], - elements: [], - }, - }, - ], - }, - ], - }; - - it('on evaluation', () => { - vi.spyOn(window, 'matchMedia') - .mockReturnValueOnce({ matches: true }) - .mockReturnValueOnce({ matches: false }); - - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'screen' as const, media_query: 'whatever' }]; - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('on trigger', () => { - const addEventListener = vi.fn(); - const removeEventListener = vi.fn(); - vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ - matches: true, - addEventListener: addEventListener, - removeEventListener: removeEventListener, - } as unknown as MediaQueryList); - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig(mediaQueryConfig), - ); - const callback = vi.fn(); - const manager = new ConditionsManager(api, callback); - - manager.setConditionsFromConfig(); - - expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); - - // Call the media query callback and use it to pretend a match happened. The - // callback is the 0th mock innvocation and the 1st argument. - addEventListener.mock.calls[0][1](); - - // This should result in a callback to our state listener. - expect(callback).toBeCalled(); - - // Remove the conditions, which should remove the media query listener. - manager.removeConditions(); - expect(removeEventListener).toBeCalled(); - }); - }); - - it('with display mode condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { condition: 'display_mode' as const, display_mode: 'grid' as const }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ displayMode: 'grid' }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ displayMode: 'single' }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('with triggered condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { condition: 'triggered' as const, triggered: ['camera_1', 'camera_2'] }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ triggered: new Set(['camera_1']) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ triggered: new Set(['camera_3']) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('with interaction condition', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'interaction' as const, interaction: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ interaction: true }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ interaction: false }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - describe('with microphone condition', () => { - const createMicrophoneState = ( - state: Partial, - ): MicrophoneState => { - return { - connected: false, - muted: false, - forbidden: false, - ...state, - }; - }; - it('empty', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'microphone' as const }]; - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ microphone: createMicrophoneState({ connected: true }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ microphone: createMicrophoneState({ connected: false }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ microphone: createMicrophoneState({ muted: true }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ microphone: createMicrophoneState({ muted: false }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - - it('connected is true', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'microphone' as const, connected: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ connected: true }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ microphone: createMicrophoneState({ connected: false }) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('connected is false', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'microphone' as const, connected: false }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ connected: true }) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ connected: false }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - - it('muted is true', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'microphone' as const, muted: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ muted: true }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ microphone: createMicrophoneState({ muted: false }) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('muted is false', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'microphone' as const, muted: false }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ muted: true }) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ muted: false }) }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - - it('connected and muted', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { condition: 'microphone' as const, muted: false, connected: true }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ muted: true }) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ microphone: createMicrophoneState({ muted: false }) }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - microphone: createMicrophoneState({ connected: false, muted: false }), - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - microphone: createMicrophoneState({ connected: true, muted: false }), - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - }); - - describe('with key condition', () => { - it('simple keypress', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'key' as const, key: 'a' }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - keys: { - a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, - }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - keys: { - a: { state: 'up', ctrl: false, shift: false, alt: false, meta: false }, - }, - }); - - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('keypress with modifiers', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'key' as const, - key: 'a', - state: 'down' as const, - ctrl: true, - shift: true, - alt: true, - meta: true, - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - keys: { - a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, - }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - keys: { - a: { state: 'down', ctrl: true, shift: true, alt: true, meta: false }, - }, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - keys: { - a: { state: 'down', ctrl: true, shift: true, alt: true, meta: true }, - }, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - }); - }); - - describe('with user agent condition', () => { - const userAgent = - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; - - it('should match exact user agent', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'user_agent' as const, user_agent: userAgent }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - userAgent: userAgent, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - userAgent: 'Something else', - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('should match user agent regex', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { condition: 'user_agent' as const, user_agent_re: 'Chrome/' }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - userAgent: userAgent, - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - userAgent: 'Something else', - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('should match companion app', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [{ condition: 'user_agent' as const, companion: true }]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - userAgent: 'Home Assistant/', - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - userAgent: userAgent, - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - - it('should match multiple parameters', () => { - const manager = new ConditionsManager(createCardAPI()); - const conditions = [ - { - condition: 'user_agent' as const, - companion: true, - user_agent: 'Home Assistant/', - user_agent_re: 'Home.Assistant', - }, - ]; - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - manager.setState({ - userAgent: 'Home Assistant/', - }); - expect(manager.evaluateConditions(conditions)).toBeTruthy(); - manager.setState({ - userAgent: 'Something else', - }); - expect(manager.evaluateConditions(conditions)).toBeFalsy(); - }); - }); - }); - - it('should add listener', () => { - const listener = vi.fn(); - const manager = new ConditionsManager(createCardAPI()); - manager.addListener(listener); - - const state = { fullscreen: true }; - manager.setState(state); - - expect(listener).toBeCalledWith(state, {}); - }); - - it('should remove listener', () => { - const listener = vi.fn(); - const manager = new ConditionsManager(createCardAPI()); - manager.addListener(listener); - manager.removeListener(listener); - - const state = { fullscreen: true }; - manager.setState(state); - - expect(listener).not.toBeCalled(); - }); -}); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 8254f415..f435fffb 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -1,12 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; -import { getOverriddenConfig } from '../../../src/card-controller/conditions-manager'; import { ConfigManager } from '../../../src/card-controller/config/config-manager'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; +import { ConditionStateManager } from '../../../src/conditions/state-manager'; import { advancedCameraCardConfigSchema } from '../../../src/config/types'; -import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; - -vi.mock('../../../src/card-controller/conditions-manager.js'); +import { createCardAPI, flushPromises } from '../../test-utils'; describe('ConfigManager', () => { beforeEach(() => { @@ -84,8 +82,7 @@ describe('ConfigManager', () => { expect(manager.getConfig()?.menu.alignment).toBe('left'); // Verify appropriate API calls are made. - expect(api.getConditionsManager().setConditionsFromConfig).toBeCalled(); - expect(api.getConditionsManager().setState).toBeCalledWith({ + expect(api.getConditionStateManager().setState).toBeCalledWith({ view: undefined, displayMode: undefined, camera: undefined, @@ -165,179 +162,222 @@ describe('ConfigManager', () => { }); }); - it('should ignore overrides without a config', () => { - const api = createCardAPI(); - const manager = new ConfigManager(api); - - manager.computeOverrideConfig(); - - expect(manager.getConfig()).toBeNull(); - expect(api.getStyleManager().updateFromConfig).not.toBeCalled(); - }); - it('should ignore overrides with same config', () => { const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = new ConfigManager(api); + const cameras = [{ camera_entity: 'camera.office' }]; const config = { type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], + cameras: cameras, + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + // Override with the same. + cameras: cameras, + }, + }, + ], }; - vi.mocked(getOverriddenConfig).mockReturnValue(config); manager.setConfig(config); - expect(api.getStyleManager().updateFromConfig).toBeCalled(); - vi.mocked(api.getStyleManager().updateFromConfig).mockClear(); - manager.computeOverrideConfig(); + expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); - expect(api.getStyleManager().updateFromConfig).not.toBeCalled(); + stateManager.setState({ fullscreen: true }); + + expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); }); it('should override', () => { const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = new ConfigManager(api); - const config_1 = { + const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], + menu: { + style: 'hidden', + }, + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { 'menu.style': 'none' }, + }, + ], }; - manager.setConfig(config_1); - vi.mocked(api.getStyleManager().updateFromConfig).mockClear(); - const config_2 = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.kitchen' }], - }; - vi.mocked(getOverriddenConfig).mockReturnValue(config_2); - manager.computeOverrideConfig(); + manager.setConfig(config); + expect(manager.getConfig()?.menu?.style).toBe('hidden'); - expect(api.getStyleManager().updateFromConfig).toBeCalled(); - expect(api.getCardElementManager().update).toBeCalled(); + stateManager.setState({ fullscreen: true }); + expect(manager.getConfig()?.menu?.style).toBe('none'); expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig()); }); it('should set error on invalid override', () => { const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = new ConfigManager(api); - manager.setConfig({ + const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], - }); + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + delete: ['cameras'], + }, + ], + }; - const error = new Error('Invalid override configuration'); - vi.mocked(getOverriddenConfig).mockImplementation(() => { - throw error; - }); + manager.setConfig(config); + expect(manager.getConfig()).not.toBeNull(); - manager.computeOverrideConfig(); - - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + stateManager.setState({ fullscreen: true }); + expect(manager.getConfig()).not.toBeNull(); + expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( + expect.objectContaining({ message: 'Invalid override configuration' }), + ); }); describe('should uninitialize on override', () => { it('cameras', () => { const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = new ConfigManager(api); - const config_1 = { + const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + cameras: [{ camera_entity: 'camera.kitchen' }], + }, + }, + ], }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); - manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( - InitializationAspect.VIEW, + manager.setConfig(config); + + expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( + InitializationAspect.CAMERAS, ); - const config_2 = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.kitchen' }], - }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); - manager.computeOverrideConfig(); + stateManager.setState({ fullscreen: true }); - expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( + expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( InitializationAspect.CAMERAS, ); }); it('cameras_global', () => { const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = new ConfigManager(api); - const config_1 = { + const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + cameras_global: { live_provider: 'jsmpeg' }, + }, + }, + ], }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); - manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( - InitializationAspect.VIEW, + manager.setConfig(config); + + expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( + InitializationAspect.CAMERAS, ); - const config_2 = { - ...config_1, - cameras_global: { - live_provider: 'jsmpeg', - }, - }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); - manager.computeOverrideConfig(); + stateManager.setState({ fullscreen: true }); - expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( + expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( InitializationAspect.CAMERAS, ); }); it('live.microphone.always_connected', () => { const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const manager = new ConfigManager(api); - const config_1 = { + const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office' }], - live: { - microphone: { - always_connected: false, + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + 'live.microphone.always_connected': true, + }, }, - }, + ], }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); - manager.setConfig(config_1); - expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( - InitializationAspect.VIEW, + manager.setConfig(config); + + expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( + InitializationAspect.MICROPHONE_CONNECT, ); - const config_2 = { - ...config_1, - live: { - microphone: { - always_connected: true, - }, - }, - }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); - manager.computeOverrideConfig(); + stateManager.setState({ fullscreen: true }); - expect(api.getInitializationManager().uninitialize).toHaveBeenLastCalledWith( + expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( InitializationAspect.MICROPHONE_CONNECT, ); }); }); - it('should initialize background items', async () => { - const api = createCardAPI(); - const manager = new ConfigManager(api); - const config = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], - }; - vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config)); + describe('should initialize on override', () => { + it('should initialize background items', async () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - manager.setConfig(config); + const manager = new ConfigManager(api); + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + cameras: [{ camera_entity: 'camera.kitchen' }], + }, + }, + ], + }; - await flushPromises(); + manager.setConfig(config); - expect(api.getDefaultManager().initializeIfNecessary).toBeCalledWith(null); - expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledWith(null); + await flushPromises(); + + expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(1); + expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(1); + + stateManager.setState({ fullscreen: true }); + + await flushPromises(); + + expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(2); + expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(2); + }); }); }); diff --git a/tests/card-controller/config/get-overridden-config.test.ts b/tests/card-controller/config/get-overridden-config.test.ts new file mode 100644 index 00000000..3d79c08a --- /dev/null +++ b/tests/card-controller/config/get-overridden-config.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { z } from 'zod'; +import { getOverriddenConfig } from '../../../src/card-controller/config/get-overridden-config'; +import { ConditionsManagerReadonlyInterface } from '../../../src/conditions/types'; + +describe('getOverriddenConfig', () => { + const config = { + menu: { + style: 'none', + }, + }; + + it('should not override without overrides', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect(getOverriddenConfig(manager, config)).toBe(config); + }); + + it('should not override when conditions do not match', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: false }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + merge: { + menu: { + style: 'hidden', + }, + }, + delete: ['menu.style'], + set: { + 'menu.style': 'overlay', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toBe(config); + }); + + describe('should merge', () => { + it('with path', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + merge: { + 'live.controls.thumbnails': { + mode: 'none', + }, + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({ + menu: { + style: 'none', + }, + live: { + controls: { + thumbnails: { + mode: 'none', + }, + }, + }, + }); + }); + + it('without path', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + merge: { + menu: { + style: 'hidden', + }, + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({ + menu: { + style: 'hidden', + }, + }); + }); + + it('with invalid merge', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + merge: 6 as unknown as Record, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({ + menu: { + style: 'none', + }, + }); + }); + }); + + describe('should set', () => { + it('leaf node', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + set: { + 'menu.style': 'hidden', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({ + menu: { + style: 'hidden', + }, + }); + }); + + it('root node', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + set: { + menu: { + style: 'hidden', + }, + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({ + menu: { + style: 'hidden', + }, + }); + }); + }); + + describe('should delete', () => { + it('leaf node', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + delete: ['menu.style' as const], + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({ + menu: {}, + }); + }); + + it('root node', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + delete: ['menu' as const], + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }), + ).toEqual({}); + }); + }); + + describe('should validate schema', () => { + const testSchema = z.object({ + menu: z.object({ + style: z.enum(['none', 'hidden']), + }), + }); + + it('passing', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect( + getOverriddenConfig(manager, config, { + configOverrides: [ + { + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + set: { + 'menu.style': 'hidden', + }, + }, + ], + schema: testSchema, + }), + ).toEqual({ + menu: { + style: 'hidden', + }, + }); + }); + + it('failing', () => { + const manager = mock(); + manager.getEvaluation.mockReturnValue({ result: true }); + + expect(() => + getOverriddenConfig(manager, config, { + configOverrides: [ + { + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + set: { + 'menu.style': 'NOT_A_STYLE', + }, + }, + ], + schema: testSchema, + }), + ).toThrowError(/Invalid override configuration/); + }); + }); +}); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 27fa84c4..788ae302 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -7,7 +7,6 @@ import { CardElementManager, CardHTMLElement, } from '../../src/card-controller/card-element-manager'; -import { ConditionsManager } from '../../src/card-controller/conditions-manager'; import { ConfigManager } from '../../src/card-controller/config/config-manager'; import { CardController } from '../../src/card-controller/controller'; import { DefaultManager } from '../../src/card-controller/default-manager'; @@ -27,6 +26,7 @@ import { StatusBarItemManager } from '../../src/card-controller/status-bar-item- import { StyleManager } from '../../src/card-controller/style-manager'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; import { ViewManager } from '../../src/card-controller/view/view-manager'; +import { ConditionStateManager } from '../../src/conditions/state-manager'; import { AdvancedCameraCardEditor } from '../../src/editor'; import { DeviceRegistryManager } from '../../src/utils/ha/registry/device'; import { EntityRegistryManager } from '../../src/utils/ha/registry/entity'; @@ -37,7 +37,6 @@ vi.mock('../../src/card-controller/actions/actions-manager'); vi.mock('../../src/card-controller/automations-manager'); vi.mock('../../src/card-controller/camera-url-manager'); vi.mock('../../src/card-controller/card-element-manager'); -vi.mock('../../src/card-controller/conditions-manager'); vi.mock('../../src/card-controller/config/config-manager'); vi.mock('../../src/card-controller/default-manager'); vi.mock('../../src/card-controller/download-manager'); @@ -56,6 +55,7 @@ vi.mock('../../src/card-controller/status-bar-item-manager'); vi.mock('../../src/card-controller/style-manager'); vi.mock('../../src/card-controller/triggers-manager'); vi.mock('../../src/card-controller/view/view-manager'); +vi.mock('../../src/conditions/state-manager'); vi.mock('../../src/utils/ha/registry/device'); vi.mock('../../src/utils/ha/registry/entity'); vi.mock('../../src/utils/ha/resolved-media'); @@ -67,7 +67,7 @@ const createCardElement = (): CardHTMLElement => { }; const createController = (): CardController => { - return new CardController(createCardElement(), vi.fn(), vi.fn(), vi.fn()); + return new CardController(createCardElement(), vi.fn(), vi.fn()); }; // @vitest-environment jsdom @@ -80,16 +80,9 @@ describe('CardController', () => { const element = createCardElement(); const scrollCallback = vi.fn(); const menuToggleCallback = vi.fn(); - const conditionListener = vi.fn(); - const manager = new CardController( - element, - scrollCallback, - menuToggleCallback, - conditionListener, - ); + const manager = new CardController(element, scrollCallback, menuToggleCallback); - expect(ConditionsManager).toBeCalledWith(manager, conditionListener); expect(CardElementManager).toBeCalledWith( manager, element, @@ -135,9 +128,9 @@ describe('CardController', () => { ); }); - it('getConditionsManager', () => { - expect(createController().getConditionsManager()).toBe( - vi.mocked(ConditionsManager).mock.instances[0], + it('ConditionStateManager', () => { + expect(createController().getConditionStateManager()).toBe( + vi.mocked(ConditionStateManager).mock.instances[0], ); }); diff --git a/tests/card-controller/expand-manager.test.ts b/tests/card-controller/expand-manager.test.ts index 76fea291..ad301c86 100644 --- a/tests/card-controller/expand-manager.test.ts +++ b/tests/card-controller/expand-manager.test.ts @@ -14,7 +14,7 @@ describe('ExpandManager', () => { const manager = new ExpandManager(api); manager.initialize(); - expect(api.getConditionsManager().setState).toBeCalledWith({ expand: false }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ expand: false }); }); it('should set expanded', () => { @@ -26,7 +26,7 @@ describe('ExpandManager', () => { expect(manager.isExpanded()).toBeTruthy(); expect(api.getFullscreenManager().setFullscreen).toBeCalledWith(false); - expect(api.getConditionsManager().setState).toBeCalledWith({ expand: true }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ expand: true }); expect(api.getCardElementManager().update).toBeCalled(); }); diff --git a/tests/card-controller/fullscreen/fullscreen-manager.test.ts b/tests/card-controller/fullscreen/fullscreen-manager.test.ts index 23888f93..dd28f1c3 100644 --- a/tests/card-controller/fullscreen/fullscreen-manager.test.ts +++ b/tests/card-controller/fullscreen/fullscreen-manager.test.ts @@ -17,7 +17,9 @@ describe('FullscreenManager', () => { const manager = new FullscreenManager(api, mock()); manager.initialize(); - expect(api.getConditionsManager().setState).toBeCalledWith({ fullscreen: false }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ + fullscreen: false, + }); }); it('should correctly determine whether in fullscreen', () => { @@ -133,7 +135,7 @@ describe('FullscreenManager', () => { handler(); - expect(api.getConditionsManager().setState).toBeCalledWith({ + expect(api.getConditionStateManager().setState).toBeCalledWith({ fullscreen: fullscreen, }); }); diff --git a/tests/card-controller/fullscreen/webkit/index.test.ts b/tests/card-controller/fullscreen/webkit/index.test.ts index f8d91897..15d52d15 100644 --- a/tests/card-controller/fullscreen/webkit/index.test.ts +++ b/tests/card-controller/fullscreen/webkit/index.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CardController } from '../../../../src/card-controller/controller'; import { WebkitFullScreenProvider } from '../../../../src/card-controller/fullscreen/webkit'; +import { ConditionStateManager } from '../../../../src/conditions/state-manager'; import { AdvancedCameraCardMediaPlayer, WebkitHTMLVideoElement, @@ -37,7 +38,7 @@ describe('WebkitFullScreenProvider', () => { provider.connect(); - expect(api.getConditionsManager().addListener).toBeCalledWith(expect.anything()); + expect(api.getConditionStateManager().addListener).toBeCalledWith(expect.anything()); }); it('should disconnect', () => { @@ -46,7 +47,9 @@ describe('WebkitFullScreenProvider', () => { provider.disconnect(); - expect(api.getConditionsManager().removeListener).toBeCalledWith(expect.anything()); + expect(api.getConditionStateManager().removeListener).toBeCalledWith( + expect.anything(), + ); }); describe('should return if in fullscreen', () => { @@ -152,20 +155,19 @@ describe('WebkitFullScreenProvider', () => { (event: string) => { const handler = vi.fn(); const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const provider = new WebkitFullScreenProvider(api, handler); provider.connect(); - const conditionChangeHandler = vi.mocked( - api.getConditionsManager().addListener, - ).mock.calls[0][0]; - const element_1 = createWebkitVideoElement(); const player_1 = mock(); player_1.getFullscreenElement.mockReturnValue(element_1); const mediaLoadedInfo_1 = createMediaLoadedInfo({ player: player_1 }); - conditionChangeHandler({ mediaLoadedInfo: mediaLoadedInfo_1 }); + stateManager.setState({ mediaLoadedInfo: mediaLoadedInfo_1 }); element_1.dispatchEvent(new Event(event)); @@ -176,10 +178,7 @@ describe('WebkitFullScreenProvider', () => { player_2.getFullscreenElement.mockReturnValue(element_2); const mediaLoadedInfo_2 = createMediaLoadedInfo({ player: player_2 }); - conditionChangeHandler( - { mediaLoadedInfo: mediaLoadedInfo_2 }, - { mediaLoadedInfo: mediaLoadedInfo_1 }, - ); + stateManager.setState({ mediaLoadedInfo: mediaLoadedInfo_2 }); element_2.dispatchEvent(new Event(event)); @@ -190,11 +189,10 @@ describe('WebkitFullScreenProvider', () => { expect(handler).toBeCalledTimes(2); - // Test the media loaded info not changing. - conditionChangeHandler( - { mediaLoadedInfo: mediaLoadedInfo_2 }, - { mediaLoadedInfo: mediaLoadedInfo_2 }, - ); + // Test the media loaded info changing, but the player not changing. + stateManager.setState({ + mediaLoadedInfo: { ...mediaLoadedInfo_2, width: 101 }, + }); // Events on the new element should still be handled. element_2.dispatchEvent(new Event(event)); @@ -210,13 +208,13 @@ describe('WebkitFullScreenProvider', () => { const handler = vi.fn(); const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + const provider = new WebkitFullScreenProvider(api, handler); provider.connect(); - const conditionChangeHandler = vi.mocked(api.getConditionsManager().addListener).mock - .calls[0][0]; - const element = createWebkitVideoElement(); element.play = vi.fn(); @@ -226,7 +224,7 @@ describe('WebkitFullScreenProvider', () => { player.getFullscreenElement.mockReturnValue(element); const mediaLoadedInfo = createMediaLoadedInfo({ player }); - conditionChangeHandler({ mediaLoadedInfo }); + stateManager.setState({ mediaLoadedInfo }); element.dispatchEvent(new Event('webkitendfullscreen')); diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index acdc63bb..34f74817 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -47,35 +47,22 @@ describe('HASSManager', () => { expect(api.getStyleManager().applyTheme).toBeCalled(); }); - describe('should set condition manager state', () => { - it('positively', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); - vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(true); + it('should set condition manager state', () => { + const api = createCardAPI(); + const manager = new HASSManager(api); - const states = { 'switch.foo': createStateEntity() }; - const user = createUser({ id: 'user_1' }); - const hass = createHASS(states, user); + const states = { 'switch.foo': createStateEntity() }; + const user = createUser({ id: 'user_1' }); + const hass = createHASS(states, user); - manager.setHASS(hass); + manager.setHASS(hass); - expect(api.getConditionsManager().setState).toBeCalledWith( - expect.objectContaining({ - state: states, - user: user, - }), - ); - }); - - it('negatively', () => { - const api = createCardAPI(); - const manager = new HASSManager(api); - vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(false); - - manager.setHASS(createHASS()); - - expect(api.getConditionsManager().setState).not.toBeCalled(); - }); + expect(api.getConditionStateManager().setState).toBeCalledWith( + expect.objectContaining({ + state: states, + user: user, + }), + ); }); describe('should handle connection state change when', () => { diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts index ec3ec81e..b162c2ad 100644 --- a/tests/card-controller/interaction-manager.test.ts +++ b/tests/card-controller/interaction-manager.test.ts @@ -23,7 +23,9 @@ describe('InteractionManager', () => { const manager = new InteractionManager(api); manager.initialize(); - expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ + interaction: false, + }); expect(element.getAttribute('interaction')).toBeNull(); }); @@ -62,11 +64,11 @@ describe('InteractionManager', () => { vi.useFakeTimers(); vi.setSystemTime(start); - expect(api.getConditionsManager().setState).not.toBeCalled(); + expect(api.getConditionStateManager().setState).not.toBeCalled(); manager.reportInteraction(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ interaction: true, }), @@ -77,7 +79,7 @@ describe('InteractionManager', () => { vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ interaction: false, }), diff --git a/tests/card-controller/keyboard-state-manager.test.ts b/tests/card-controller/keyboard-state-manager.test.ts index 36f8e1ff..392effca 100644 --- a/tests/card-controller/keyboard-state-manager.test.ts +++ b/tests/card-controller/keyboard-state-manager.test.ts @@ -17,7 +17,7 @@ describe('KeyboardStateManager', () => { element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); - expect(api.getConditionsManager().setState).toHaveBeenCalledWith({ + expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ keys: { a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false }, }, @@ -26,7 +26,7 @@ describe('KeyboardStateManager', () => { element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); // Duplicate keydown should not re-set the state. - expect(api.getConditionsManager().setState).toBeCalledTimes(1); + expect(api.getConditionStateManager().setState).toBeCalledTimes(1); }); it('should set state on keyup', () => { @@ -39,13 +39,13 @@ describe('KeyboardStateManager', () => { element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); // Key not held down in the first place should not update the state. - expect(api.getConditionsManager().setState).not.toBeCalled(); + expect(api.getConditionStateManager().setState).not.toBeCalled(); element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); - expect(api.getConditionsManager().setState).toBeCalledTimes(2); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + expect(api.getConditionStateManager().setState).toBeCalledTimes(2); + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ keys: { a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false }, }, @@ -60,13 +60,13 @@ describe('KeyboardStateManager', () => { manager.initialize(); element.dispatchEvent(new FocusEvent('blur')); - expect(api.getConditionsManager().setState).not.toBeCalled(); + expect(api.getConditionStateManager().setState).not.toBeCalled(); element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); element.dispatchEvent(new FocusEvent('blur')); - expect(api.getConditionsManager().setState).toBeCalledTimes(2); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + expect(api.getConditionStateManager().setState).toBeCalledTimes(2); + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ keys: {}, }); }); @@ -81,6 +81,6 @@ describe('KeyboardStateManager', () => { element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); - expect(api.getConditionsManager().setState).not.toBeCalled(); + expect(api.getConditionStateManager().setState).not.toBeCalled(); }); }); diff --git a/tests/card-controller/media-info-manager.test.ts b/tests/card-controller/media-info-manager.test.ts index bf8b1e18..9969b827 100644 --- a/tests/card-controller/media-info-manager.test.ts +++ b/tests/card-controller/media-info-manager.test.ts @@ -8,7 +8,7 @@ describe('MediaLoadedInfoManager', () => { const manager = new MediaLoadedInfoManager(api); manager.initialize(); - expect(api.getConditionsManager().setState).toBeCalledWith({ + expect(api.getConditionStateManager().setState).toBeCalledWith({ mediaLoadedInfo: null, }); }); @@ -22,7 +22,7 @@ describe('MediaLoadedInfoManager', () => { expect(manager.has()).toBeTruthy(); expect(manager.get()).toBe(mediaInfo); - expect(api.getConditionsManager().setState).toBeCalledWith( + expect(api.getConditionStateManager().setState).toBeCalledWith( expect.objectContaining({ mediaLoadedInfo: mediaInfo }), ); expect(api.getStyleManager().setExpandedMode).toBeCalled(); @@ -38,7 +38,7 @@ describe('MediaLoadedInfoManager', () => { expect(manager.has()).toBeFalsy(); expect(manager.get()).toBeNull(); - expect(api.getConditionsManager().setState).not.toBeCalled(); + expect(api.getConditionStateManager().setState).not.toBeCalled(); }); it('should get last known', () => { @@ -54,7 +54,7 @@ describe('MediaLoadedInfoManager', () => { expect(manager.has()).toBeFalsy(); expect(manager.getLastKnown()).toBe(mediaLoadedInfo); - expect(api.getConditionsManager().setState).toBeCalledWith( + expect(api.getConditionStateManager().setState).toBeCalledWith( expect.objectContaining({ mediaLoadedInfo }), ); }); diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index 4f8a0765..c1d23496 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -302,7 +302,7 @@ describe('MicrophoneManager', () => { const manager = new MicrophoneManager(api); manager.initialize(); - expect(api.getConditionsManager().setState).toBeCalledWith({ + expect(api.getConditionStateManager().setState).toBeCalledWith({ microphone: { connected: false, muted: true, forbidden: false, stream: undefined }, }); }); @@ -313,7 +313,7 @@ describe('MicrophoneManager', () => { const stream = createMockStream(); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); - expect(api.getConditionsManager().setState).not.toBeCalled(); + expect(api.getConditionStateManager().setState).not.toBeCalled(); await manager.connect(); @@ -325,7 +325,7 @@ describe('MicrophoneManager', () => { }; expect(manager.getState()).toEqual(expectedState); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), @@ -340,7 +340,7 @@ describe('MicrophoneManager', () => { muted: false, }; expect(manager.getState()).toEqual(expectedState); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), @@ -355,7 +355,7 @@ describe('MicrophoneManager', () => { muted: true, }; expect(manager.getState()).toEqual(expectedState); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), @@ -370,7 +370,7 @@ describe('MicrophoneManager', () => { muted: true, }; expect(manager.getState()).toEqual(expectedState); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith( + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index b6258ad8..147798dd 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -50,7 +50,7 @@ const createTriggerAPI = (options?: { }, }), ); - vi.mocked(api.getConditionsManager().getState).mockReturnValue({}); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager().getStore).mockReturnValue( createStore([ @@ -319,10 +319,10 @@ describe('TriggersManager', () => { manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ triggered: new Set(['camera_1']), }); - vi.mocked(api.getConditionsManager().getState).mockReturnValue({ + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({ triggered: new Set(['camera_1']), }); @@ -331,7 +331,7 @@ describe('TriggersManager', () => { vi.setSystemTime(add(start, { seconds: 10 })); vi.runOnlyPendingTimers(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({ triggered: undefined, }); }); diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index 3086e32f..d5c30737 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -45,7 +45,7 @@ describe('should act correctly when view is set', () => { expect(api.getCardElementManager().scrollReset).toBeCalled(); expect(api.getMessageManager().reset).toBeCalled(); expect(api.getStyleManager().setExpandedMode).toBeCalled(); - expect(api.getConditionsManager()?.setState).toBeCalledWith({ + expect(api.getConditionStateManager()?.setState).toBeCalledWith({ view: 'live', camera: 'camera', displayMode: 'grid', diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts new file mode 100644 index 00000000..4713562a --- /dev/null +++ b/tests/conditions/conditions-manager.test.ts @@ -0,0 +1,933 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MicrophoneState } from '../../src/card-controller/types'; +import { ConditionsManager } from '../../src/conditions/conditions-manager'; +import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { createMediaLoadedInfo, createStateEntity, createUser } from '../test-utils'; + +// @vitest-environment jsdom +describe('ConditionsManager', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('should evaluate conditions', () => { + describe('with a view condition', () => { + it('should match named view change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'view' as const, views: ['foo'] }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ view: 'foo' }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + + it('should match any view change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'view' as const }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ view: 'clips' }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + view: { + to: 'clips', + }, + }, + }); + + stateManager.setState({ view: 'timeline' }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + view: { + from: 'clips', + to: 'timeline', + }, + }, + }); + + expect(listener).toBeCalledTimes(2); + }); + }); + + it('with fullscreen condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ fullscreen: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ fullscreen: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with expand condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'expand' as const, expand: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ expand: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ expand: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + describe('with camera condition', () => { + it('should match named camera change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'camera' as const, cameras: ['bar'] }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ camera: 'bar' }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ camera: 'will-not-match' }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should match any camera change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'camera' as const }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ camera: 'bar' }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + camera: { + to: 'bar', + }, + }, + }); + + stateManager.setState({ camera: 'foo' }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + camera: { + from: 'bar', + to: 'foo', + }, + }, + }); + + expect(listener).toBeCalledTimes(2); + }); + }); + + describe('with stock HA conditions', () => { + describe('with state condition', () => { + it('neither positive nor negative', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + }, + ], + stateManager, + ); + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'on' }) }, + }); + expect(listener).toBeCalledWith({ + result: true, + data: { + state: { + entity: 'binary_sensor.foo', + to: 'on', + }, + }, + }); + expect(listener).toBeCalledTimes(1); + + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(listener).toBeCalledWith({ + result: true, + data: { + state: { + entity: 'binary_sensor.foo', + from: 'on', + to: 'off', + }, + }, + }); + expect(listener).toBeCalledTimes(2); + }); + + describe('positive', () => { + it('single state', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state: 'on', + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity() }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('multiple states', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state: ['active', 'on'], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity() }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + }); + + describe('negative', () => { + it('single state', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state_not: 'on', + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity() }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + }); + + it('multiple states', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state_not: ['active', 'on'], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + + it('implicit state condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + entity: 'binary_sensor.foo', + state: 'on', + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should match any state change when state and state_not omitted', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { condition: 'state' as const, entity: 'switch.one' }, + { condition: 'state' as const, entity: 'switch.two' }, + ], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ + state: { + 'switch.one': createStateEntity({ state: 'on' }), + 'switch.two': createStateEntity({ state: 'off' }), + }, + }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + // Only the last matching state will be included in the data. + state: { + entity: 'switch.two', + to: 'off', + }, + }, + }); + + stateManager.setState({ + state: { + 'switch.one': createStateEntity({ state: 'off' }), + 'switch.two': createStateEntity({ state: 'on' }), + }, + }); + + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + // Only the last matching state will be included in the data. + state: { + entity: 'switch.two', + from: 'off', + to: 'on', + }, + }, + }); + + expect(listener).toBeCalledTimes(2); + }); + }); + + describe('with numeric state condition', () => { + it('above', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'numeric_state' as const, + entity: 'sensor.foo', + above: 10, + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: '9' }) }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('below', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'numeric_state' as const, + entity: 'sensor.foo', + below: 10, + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '9' }) }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + }); + + it('should not call listeners for HA state changes without relevant condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + }); + + expect(listener).not.toBeCalled(); + }); + }); + + it('with user condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'user' as const, + users: ['user_1', 'user_2'], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + user: createUser({ id: 'user_1' }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + user: createUser({ id: 'user_WRONG' }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with media loaded condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'media_loaded' as const, media_loaded: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ mediaLoadedInfo: createMediaLoadedInfo() }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ mediaLoadedInfo: null }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + describe('with screen condition', () => { + it('on evaluation', () => { + vi.spyOn(window, 'matchMedia') + .mockReturnValueOnce({ + addEventListener: vi.fn(), + } as unknown as MediaQueryList) + .mockReturnValueOnce({ + matches: true, + } as unknown as MediaQueryList); + + const manager = new ConditionsManager([ + { condition: 'screen' as const, media_query: 'whatever' }, + ]); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + + it('on trigger', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia') + .mockReturnValueOnce({ + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList) + .mockReturnValueOnce({ + matches: false, + } as unknown as MediaQueryList) + .mockReturnValueOnce({ + matches: true, + } as unknown as MediaQueryList); + + const manager = new ConditionsManager([ + { + condition: 'screen' as const, + media_query: 'media query goes here', + }, + ]); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + + const callback = vi.fn(); + manager.addListener(callback); + + // Call the media query callback and use it to pretend a match happened. The + // callback is the 0th mock innvocation and the 1st argument. + addEventListener.mock.calls[0][1](); + + // This should result in a callback to our state listener. + expect(callback).toBeCalledWith({ result: true, data: {} }); + + // Destroy the manager and ensure the event listener is removed. + manager.destroy(); + expect(removeEventListener).toBeCalled(); + }); + }); + + it('with display mode condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'display_mode' as const, display_mode: 'grid' as const }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ displayMode: 'grid' }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ displayMode: 'single' }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with triggered condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'triggered' as const, triggered: ['camera_1', 'camera_2'] }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ triggered: new Set(['camera_1']) }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + triggered: new Set(['camera_2', 'camera_1', 'camera_3']), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ triggered: new Set(['camera_3']) }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with interaction condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'interaction' as const, interaction: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ interaction: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ interaction: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + describe('with microphone condition', () => { + const createMicrophoneState = ( + state: Partial, + ): MicrophoneState => { + return { + connected: false, + muted: false, + forbidden: false, + ...state, + }; + }; + it('empty', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'microphone' as const }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: true }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: false }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + + it('connected is true', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'microphone' as const, connected: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: true }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: false }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('connected is false', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'microphone' as const, connected: false }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: true }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: false }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + + it('muted is true', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'microphone' as const, muted: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('muted is false', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'microphone' as const, muted: false }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + + it('connected and muted', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'microphone' as const, muted: false, connected: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: false, muted: false }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + microphone: createMicrophoneState({ connected: true, muted: false }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + }); + + describe('with key condition', () => { + it('simple keypress', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'key' as const, key: 'a' }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + keys: { + a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, + }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + keys: { + a: { state: 'up', ctrl: false, shift: false, alt: false, meta: false }, + }, + }); + + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('keypress with modifiers', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'key' as const, + key: 'a', + state: 'down' as const, + ctrl: true, + shift: true, + alt: true, + meta: true, + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + keys: { + a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, + }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + keys: { + a: { state: 'down', ctrl: true, shift: true, alt: true, meta: false }, + }, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + keys: { + a: { state: 'down', ctrl: true, shift: true, alt: true, meta: true }, + }, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + }); + }); + + describe('with user agent condition', () => { + const userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + + it('should match exact user agent', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'user_agent' as const, user_agent: userAgent }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + userAgent: userAgent, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + userAgent: 'Something else', + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should match user agent regex', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'user_agent' as const, user_agent_re: 'Chrome/' }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + userAgent: userAgent, + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + userAgent: 'Something else', + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should match companion app', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'user_agent' as const, companion: true }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + userAgent: 'Home Assistant/', + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + userAgent: userAgent, + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should match multiple parameters', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'user_agent' as const, + companion: true, + user_agent: 'Home Assistant/', + user_agent_re: 'Home.Assistant', + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + userAgent: 'Home Assistant/', + }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ + userAgent: 'Something else', + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + }); + }); + + describe('should handle listeners correctly', () => { + it('should add listener', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ fullscreen: true }); + + expect(listener).toBeCalledWith({ result: true, data: {} }); + expect(listener).toBeCalledTimes(1); + + stateManager.setState({ fullscreen: false }); + expect(listener).toBeCalledWith({ result: false }); + expect(listener).toBeCalledTimes(2); + + // Re-add the same listener (will still only be called once). + manager.addListener(listener); + + stateManager.setState({ fullscreen: true }); + + expect(listener).toBeCalledWith({ result: true, data: {} }); + expect(listener).toBeCalledTimes(3); + }); + + it('should remove listener', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + manager.removeListener(listener); + + stateManager.setState({ fullscreen: true }); + + expect(listener).not.toBeCalled(); + }); + + it('should remove listener on destroy', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'fullscreen' as const, fullscreen: true }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + manager.destroy(); + + stateManager.setState({ fullscreen: true }); + + expect(listener).not.toBeCalled(); + }); + + it('with not call listeners when condition result does not change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'view' as const, views: ['foo'] }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ view: 'foo' }); + expect(listener).toBeCalledTimes(1); + + stateManager.setState({ view: 'bar' }); + expect(listener).toBeCalledTimes(2); + + stateManager.setState({ view: 'bar' }); + expect(listener).toBeCalledTimes(2); + + stateManager.setState({ view: 'foo' }); + expect(listener).toBeCalledTimes(3); + + stateManager.setState({ view: 'foo' }); + expect(listener).toBeCalledTimes(3); + }); + }); +}); diff --git a/tests/conditions/state-manager-via-event.test.ts b/tests/conditions/state-manager-via-event.test.ts new file mode 100644 index 00000000..2254f907 --- /dev/null +++ b/tests/conditions/state-manager-via-event.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { + ConditionStateManagerGetEvent, + getConditionStateManagerViaEvent, +} from '../../src/conditions/state-manager-via-event'; + +// @vitest-environment jsdom +describe('getConditionStateManagerViaEvent', () => { + it('should dispatch event and retrieve state manager', () => { + const element = document.createElement('div'); + const stateManager = mock(); + + const handler = vi.fn().mockImplementation((ev: ConditionStateManagerGetEvent) => { + ev.conditionStateManager = stateManager; + }); + element.addEventListener( + 'advanced-camera-card:condition-state-manager:get', + handler, + ); + + expect(getConditionStateManagerViaEvent(element)).toBe(stateManager); + }); + + it('should dispatch event and retrieve state manager', () => { + const element = document.createElement('div'); + + const handler = vi.fn(); + element.addEventListener( + 'advanced-camera-card:condition-state-manager:get', + handler, + ); + + expect(getConditionStateManagerViaEvent(element)).toBeNull(); + }); +}); diff --git a/tests/conditions/state-manager.test.ts b/tests/conditions/state-manager.test.ts new file mode 100644 index 00000000..ed4259a8 --- /dev/null +++ b/tests/conditions/state-manager.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ConditionStateManager } from '../../src/conditions/state-manager'; +import { createStateEntity } from '../test-utils'; + +describe('ConditionStateManager', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should get state', () => { + const state = { fullscreen: true }; + + const manager = new ConditionStateManager(); + manager.setState(state); + expect(manager.getState()).toEqual(state); + }); + + describe('should set state', () => { + it('should set and be able to get it again', () => { + const state = { + fullscreen: true, + }; + + const manager = new ConditionStateManager(); + + manager.setState(state); + expect(manager.getState()).toEqual(state); + }); + + it('should set but only trigger when necessary', () => { + const listener = vi.fn(); + const manager = new ConditionStateManager(); + manager.addListener(listener); + + const state = { + fullscreen: true, + }; + + manager.setState(state); + expect(listener).toBeCalledTimes(1); + + manager.setState(state); + expect(listener).toBeCalledTimes(1); + + manager.setState({ ...state }); + expect(listener).toBeCalledTimes(1); + + manager.setState({ + state: { + 'binary_sensor.foo': createStateEntity(), + }, + }); + expect(listener).toBeCalledTimes(2); + + manager.setState({ fullscreen: true }); + expect(listener).toBeCalledTimes(2); + + manager.setState({ + state: { + 'binary_sensor.foo': createStateEntity(), + }, + }); + expect(listener).toBeCalledTimes(2); + + manager.setState({ fullscreen: false }); + expect(listener).toBeCalledTimes(3); + + manager.setState({ fullscreen: false }); + expect(listener).toBeCalledTimes(3); + + manager.setState({ + state: { + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }, + }); + expect(listener).toBeCalledTimes(4); + }); + }); + + it('should add listener', () => { + const listener = vi.fn(); + const manager = new ConditionStateManager(); + + manager.setState({ fullscreen: true }); + + manager.addListener(listener); + + manager.setState({ expand: true }); + + expect(listener).toBeCalledWith({ + old: { fullscreen: true }, + change: { expand: true }, + new: { fullscreen: true, expand: true }, + }); + }); + + it('should remove listener', () => { + const listener = vi.fn(); + const manager = new ConditionStateManager(); + + manager.addListener(listener); + manager.removeListener(listener); + + const state = { fullscreen: true }; + manager.setState(state); + + expect(listener).not.toBeCalled(); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index c93e2cad..57797aea 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -19,7 +19,7 @@ import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; import { CardElementManager } from '../src/card-controller/card-element-manager'; -import { ConditionsManager } from '../src/card-controller/conditions-manager'; +import { ConditionStateManager } from '../src/conditions/state-manager'; import { ConfigManager } from '../src/card-controller/config/config-manager'; import { CardController } from '../src/card-controller/controller'; import { DefaultManager } from '../src/card-controller/default-manager'; @@ -77,12 +77,6 @@ export const createCameraConfig = (config?: unknown): CameraConfig => { return cameraConfigSchema.parse(config ?? {}); }; -export const createCondition = ( - condition?: Partial, -): AdvancedCameraCardCondition => { - return advancedCameraCardConditionSchema.parse(condition ?? {}); -}; - export const createRawConfig = ( config?: Partial, ): RawAdvancedCameraCardConfig => { @@ -487,7 +481,7 @@ export const createCardAPI = (): CardController => { api.getCameraManager.mockReturnValue(mock()); api.getCameraURLManager.mockReturnValue(mock()); api.getCardElementManager.mockReturnValue(mock()); - api.getConditionsManager.mockReturnValue(mock()); + api.getConditionStateManager.mockReturnValue(mock()); api.getConfigManager.mockReturnValue(mock()); api.getDownloadManager.mockReturnValue(mock()); api.getEntityRegistryManager.mockReturnValue(mock());