diff --git a/src/conditions/conditions-manager.ts b/src/conditions/conditions-manager.ts index 61d66aab..e10d433c 100644 --- a/src/conditions/conditions-manager.ts +++ b/src/conditions/conditions-manager.ts @@ -1,13 +1,11 @@ import { TemplateRenderer } from '../card-controller/templates'; -import { getConfigValue } from '../config/management'; import { AdvancedCameraCardCondition } from '../config/schema/conditions/types'; -import { isBeingCasted } from '../utils/casting'; -import { isCompanionApp } from '../utils/companion'; +import { ConditionEvaluator } from './conditions/types'; +import { createConditionEvaluator } from './factory'; import { ConditionsEvaluationResult, ConditionsListener, ConditionsManagerReadonlyInterface, - ConditionState, ConditionStateChange, ConditionStateManagerReadonlyInterface, ConditionsTriggerData, @@ -19,29 +17,29 @@ import { * associated with a result). */ export class ConditionsManager implements ConditionsManagerReadonlyInterface { - private _conditions: AdvancedCameraCardCondition[]; private _stateManager: ConditionStateManagerReadonlyInterface | null; + private _evaluators: ConditionEvaluator[]; private _listeners: ConditionsListener[] = []; - private _mediaQueries: MediaQueryList[] = []; private _evaluation: ConditionsEvaluationResult = { result: false }; - private _templateRenderer: TemplateRenderer = new TemplateRenderer(); constructor( conditions: AdvancedCameraCardCondition[], stateManager?: ConditionStateManagerReadonlyInterface | null, ) { - this._conditions = conditions; - conditions.forEach((condition) => { - if (condition.condition === 'screen') { - const mql = window.matchMedia(condition.media_query); - mql.addEventListener('change', this._mediaQueryHandler); - this._mediaQueries.push(mql); - } - }); + const context = { templateRenderer: new TemplateRenderer() }; + this._evaluators = conditions.map((condition) => + createConditionEvaluator(condition, context), + ); this._stateManager = stateManager ?? null; + // Subscribe evaluators that have external change sources (currently + // `screen`), including nested evaluators inside composites. + this._evaluators.forEach((evaluator) => + evaluator.subscribe?.(() => this._evaluate()), + ); + // Do an initial condition evaluation, but without calling listeners. this._evaluate({ callListeners: false }); @@ -53,11 +51,8 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { this._listeners.forEach((l) => this.removeListener(l)); - this._mediaQueries.forEach((mql) => - mql.removeEventListener('change', this._mediaQueryHandler), - ); - this._mediaQueries = []; - this._conditions = []; + this._evaluators.forEach((evaluator) => evaluator.destroy?.()); + this._evaluators = []; } public addListener(listener: ConditionsListener): void { @@ -74,8 +69,6 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { return this._evaluation; } - private _mediaQueryHandler = () => this._evaluate(); - private _stateManagerHandler = (stateChange: ConditionStateChange): void => { this._evaluate({ stateChange }); }; @@ -89,12 +82,8 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { let result = true; let triggerData: ConditionsTriggerData = {}; - for (const condition of this._conditions) { - const evaluation = this._evaluateCondition( - condition, - state, - options?.stateChange?.old, - ); + for (const evaluator of this._evaluators) { + const evaluation = evaluator.evaluate(state, options?.stateChange?.old); if (!evaluation.result) { result = false; break; @@ -121,242 +110,4 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { } } } - - private _evaluateCondition( - condition: AdvancedCameraCardCondition, - newState?: ConditionState, - oldState?: ConditionState, - ): ConditionsEvaluationResult { - switch (condition.condition) { - case undefined: - case 'state': { - const fromState = oldState?.hass?.states?.[condition.entity]?.state; - const toState = newState?.hass?.states?.[condition.entity]?.state; - - return { - result: - (!condition.state && !condition.state_not && toState !== fromState) || - ((!!condition.state || !!condition.state_not) && - !!toState && - (!condition.state || - (Array.isArray(condition.state) - ? condition.state.includes(toState) - : condition.state === toState)) && - (!condition.state_not || - (Array.isArray(condition.state_not) - ? !condition.state_not.includes(toState) - : condition.state_not !== toState))), - ...(fromState !== toState && { - triggerData: { - state: { - entity: condition.entity, - ...(fromState && { from: fromState }), - ...(toState && { to: toState }), - }, - }, - }), - }; - } - case 'view': { - const oldView = oldState?.view; - const newView = newState?.view; - - return { - result: - (!!newView && condition.views?.includes(newView)) || - (newView !== oldView && !condition.views?.length), - ...(oldView !== newView && { - triggerData: { - ...((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': { - const oldCamera = oldState?.camera; - const newCamera = newState?.camera; - - return { - result: - (!!newCamera && !!condition.cameras?.includes(newCamera)) || - (newCamera !== oldCamera && !condition.cameras?.length), - ...(newCamera !== oldCamera && { - triggerData: { - ...((oldState?.camera || newState?.camera) && { - camera: { - ...(oldState?.camera && { from: oldState?.camera }), - ...(newState?.camera && { to: newState?.camera }), - }, - }), - }, - }), - }; - } - case 'numeric_state': - return { - result: - !!newState?.hass?.states && - condition.entity in newState.hass?.states && - newState.hass.states[condition.entity].state !== undefined && - (condition.above === undefined || - Number(newState.hass.states[condition.entity].state) > condition.above) && - (condition.below === undefined || - Number(newState.hass.states[condition.entity].state) < condition.below), - }; - case 'user': - return { - result: - !!newState?.hass?.user && condition.users.includes(newState.hass.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: newState?.microphone?.muted === condition.muted, - }; - case 'call': - return { - result: (condition.call ?? true) === (newState?.call ?? false), - }; - 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.casting === undefined || - condition.casting === isBeingCasted(newState.userAgent)) && - (condition.companion === undefined || - condition.companion === isCompanionApp(newState.userAgent)) && - (condition.user_agent_re === undefined || - new RegExp(condition.user_agent_re).test(newState.userAgent)), - }; - case 'config': { - const newConfig = newState?.config; - const oldConfig = oldState?.config; - - return { - result: - !!newConfig && - newConfig !== oldConfig && - (!condition.paths?.length || - condition.paths.some( - (key) => - getConfigValue(newConfig, key) !== - (oldConfig ? getConfigValue(oldConfig, key) : undefined), - )), - ...(newConfig !== oldConfig && { - triggerData: { - config: { - ...((oldState?.config || newState?.config) && { - ...(oldState?.config && { from: oldState?.config }), - ...(newState?.config && { to: newState?.config }), - }), - }, - }, - }), - }; - } - case 'initialized': - return { result: !!newState?.initialized }; - case 'or': - for (const subCondition of condition.conditions) { - const evaluation = this._evaluateCondition(subCondition, newState, oldState); - if (evaluation.result) { - return evaluation; - } - } - return { result: false }; - case 'and': { - let triggerData: ConditionsTriggerData = {}; - for (const subCondition of condition.conditions) { - const evaluation = this._evaluateCondition(subCondition, newState, oldState); - if (!evaluation.result) { - return { result: false }; - } - triggerData = { - ...triggerData, - ...evaluation.triggerData, - }; - } - return { result: true, triggerData }; - } - case 'not': { - // "Not" is an inverted `or` (NOR). There is no trigger data for "not - // triggering". - return { - result: !this._evaluateCondition( - { - ...condition, - condition: 'or', - }, - newState, - oldState, - ).result, - }; - } - case 'template': - return { - result: - !!newState?.hass && - this._templateRenderer.renderRecursively( - newState.hass, - condition.value_template, - { conditionState: newState }, - ) === true, - }; - } - } } diff --git a/src/conditions/conditions/and.ts b/src/conditions/conditions/and.ts new file mode 100644 index 00000000..f98c6f77 --- /dev/null +++ b/src/conditions/conditions/and.ts @@ -0,0 +1,26 @@ +import { + ConditionsEvaluationResult, + ConditionState, + ConditionsTriggerData, +} from '../types'; +import { CompositeConditionEvaluator } from './composite'; + +export class AndConditionEvaluator extends CompositeConditionEvaluator { + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + let triggerData: ConditionsTriggerData = {}; + for (const child of this._children) { + const evaluation = child.evaluate(newState, oldState); + if (!evaluation.result) { + return { result: false }; + } + triggerData = { + ...triggerData, + ...evaluation.triggerData, + }; + } + return { result: true, triggerData }; + } +} diff --git a/src/conditions/conditions/call.ts b/src/conditions/conditions/call.ts new file mode 100644 index 00000000..7db503a1 --- /dev/null +++ b/src/conditions/conditions/call.ts @@ -0,0 +1,16 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class CallConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'call'>; + + constructor(condition: ConditionOfType<'call'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: (this._condition.call ?? true) === (newState?.call ?? false), + }; + } +} diff --git a/src/conditions/conditions/camera.ts b/src/conditions/conditions/camera.ts new file mode 100644 index 00000000..ea86fc8c --- /dev/null +++ b/src/conditions/conditions/camera.ts @@ -0,0 +1,34 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class CameraConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'camera'>; + + constructor(condition: ConditionOfType<'camera'>) { + this._condition = condition; + } + + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + const oldCamera = oldState?.camera; + const newCamera = newState?.camera; + + return { + result: + (!!newCamera && !!this._condition.cameras?.includes(newCamera)) || + (newCamera !== oldCamera && !this._condition.cameras?.length), + ...(newCamera !== oldCamera && { + triggerData: { + ...((oldState?.camera || newState?.camera) && { + camera: { + ...(oldState?.camera && { from: oldState?.camera }), + ...(newState?.camera && { to: newState?.camera }), + }, + }), + }, + }), + }; + } +} diff --git a/src/conditions/conditions/composite.ts b/src/conditions/conditions/composite.ts new file mode 100644 index 00000000..5d4cb1f4 --- /dev/null +++ b/src/conditions/conditions/composite.ts @@ -0,0 +1,27 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionEvaluatorSubscriptionCallback } from './types'; + +/** + * Base class for the `or`/`and`/`not` composites: each holds child evaluators + * and forwards subscription/teardown to them. Subclasses provide `evaluate`. + */ +export abstract class CompositeConditionEvaluator implements ConditionEvaluator { + protected _children: ConditionEvaluator[]; + + constructor(children: ConditionEvaluator[]) { + this._children = children; + } + + public abstract evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult; + + public subscribe(onChange: ConditionEvaluatorSubscriptionCallback): void { + this._children.forEach((child) => child.subscribe?.(onChange)); + } + + public destroy(): void { + this._children.forEach((child) => child.destroy?.()); + } +} diff --git a/src/conditions/conditions/config.ts b/src/conditions/conditions/config.ts new file mode 100644 index 00000000..2cf388de --- /dev/null +++ b/src/conditions/conditions/config.ts @@ -0,0 +1,41 @@ +import { getConfigValue } from '../../config/management'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class ConfigConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'config'>; + + constructor(condition: ConditionOfType<'config'>) { + this._condition = condition; + } + + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + const newConfig = newState?.config; + const oldConfig = oldState?.config; + + return { + result: + !!newConfig && + newConfig !== oldConfig && + (!this._condition.paths?.length || + this._condition.paths.some( + (key) => + getConfigValue(newConfig, key) !== + (oldConfig ? getConfigValue(oldConfig, key) : undefined), + )), + ...(newConfig !== oldConfig && { + triggerData: { + config: { + ...((oldState?.config || newState?.config) && { + ...(oldState?.config && { from: oldState?.config }), + ...(newState?.config && { to: newState?.config }), + }), + }, + }, + }), + }; + } +} diff --git a/src/conditions/conditions/display-mode.ts b/src/conditions/conditions/display-mode.ts new file mode 100644 index 00000000..9220f0d4 --- /dev/null +++ b/src/conditions/conditions/display-mode.ts @@ -0,0 +1,17 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class DisplayModeConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'display_mode'>; + + constructor(condition: ConditionOfType<'display_mode'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + !!newState?.displayMode && this._condition.display_mode === newState.displayMode, + }; + } +} diff --git a/src/conditions/conditions/expand.ts b/src/conditions/conditions/expand.ts new file mode 100644 index 00000000..8073b22a --- /dev/null +++ b/src/conditions/conditions/expand.ts @@ -0,0 +1,17 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class ExpandConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'expand'>; + + constructor(condition: ConditionOfType<'expand'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + newState?.expand !== undefined && this._condition.expand === newState.expand, + }; + } +} diff --git a/src/conditions/conditions/fullscreen.ts b/src/conditions/conditions/fullscreen.ts new file mode 100644 index 00000000..bb0b53a5 --- /dev/null +++ b/src/conditions/conditions/fullscreen.ts @@ -0,0 +1,18 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class FullscreenConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'fullscreen'>; + + constructor(condition: ConditionOfType<'fullscreen'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + newState?.fullscreen !== undefined && + this._condition.fullscreen === newState.fullscreen, + }; + } +} diff --git a/src/conditions/conditions/initialized.ts b/src/conditions/conditions/initialized.ts new file mode 100644 index 00000000..fb81c1a0 --- /dev/null +++ b/src/conditions/conditions/initialized.ts @@ -0,0 +1,8 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator } from './types'; + +export class InitializedConditionEvaluator implements ConditionEvaluator { + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { result: !!newState?.initialized }; + } +} diff --git a/src/conditions/conditions/interaction.ts b/src/conditions/conditions/interaction.ts new file mode 100644 index 00000000..88da1342 --- /dev/null +++ b/src/conditions/conditions/interaction.ts @@ -0,0 +1,18 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class InteractionConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'interaction'>; + + constructor(condition: ConditionOfType<'interaction'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + newState?.interaction !== undefined && + this._condition.interaction === newState.interaction, + }; + } +} diff --git a/src/conditions/conditions/key.ts b/src/conditions/conditions/key.ts new file mode 100644 index 00000000..13cbfd09 --- /dev/null +++ b/src/conditions/conditions/key.ts @@ -0,0 +1,28 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class KeyConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'key'>; + + constructor(condition: ConditionOfType<'key'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const condition = this._condition; + 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), + }; + } +} diff --git a/src/conditions/conditions/media-loaded.ts b/src/conditions/conditions/media-loaded.ts new file mode 100644 index 00000000..b2718190 --- /dev/null +++ b/src/conditions/conditions/media-loaded.ts @@ -0,0 +1,18 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class MediaLoadedConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'media_loaded'>; + + constructor(condition: ConditionOfType<'media_loaded'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + newState?.mediaLoadedInfo !== undefined && + this._condition.media_loaded === !!newState.mediaLoadedInfo, + }; + } +} diff --git a/src/conditions/conditions/microphone.ts b/src/conditions/conditions/microphone.ts new file mode 100644 index 00000000..5b0485d7 --- /dev/null +++ b/src/conditions/conditions/microphone.ts @@ -0,0 +1,16 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class MicrophoneConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'microphone'>; + + constructor(condition: ConditionOfType<'microphone'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: newState?.microphone?.muted === this._condition.muted, + }; + } +} diff --git a/src/conditions/conditions/not.ts b/src/conditions/conditions/not.ts new file mode 100644 index 00000000..9aa3280d --- /dev/null +++ b/src/conditions/conditions/not.ts @@ -0,0 +1,15 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { CompositeConditionEvaluator } from './composite'; + +export class NotConditionEvaluator extends CompositeConditionEvaluator { + // "Not" is an inverted `or` (NOR). There is no trigger data for "not + // triggering". + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + return { + result: !this._children.some((child) => child.evaluate(newState, oldState).result), + }; + } +} diff --git a/src/conditions/conditions/numeric-state.ts b/src/conditions/conditions/numeric-state.ts new file mode 100644 index 00000000..7b29fd24 --- /dev/null +++ b/src/conditions/conditions/numeric-state.ts @@ -0,0 +1,24 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class NumericStateConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'numeric_state'>; + + constructor(condition: ConditionOfType<'numeric_state'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const condition = this._condition; + return { + result: + !!newState?.hass?.states && + condition.entity in newState.hass?.states && + newState.hass.states[condition.entity].state !== undefined && + (condition.above === undefined || + Number(newState.hass.states[condition.entity].state) > condition.above) && + (condition.below === undefined || + Number(newState.hass.states[condition.entity].state) < condition.below), + }; + } +} diff --git a/src/conditions/conditions/or.ts b/src/conditions/conditions/or.ts new file mode 100644 index 00000000..37172d9d --- /dev/null +++ b/src/conditions/conditions/or.ts @@ -0,0 +1,17 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { CompositeConditionEvaluator } from './composite'; + +export class OrConditionEvaluator extends CompositeConditionEvaluator { + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + for (const child of this._children) { + const evaluation = child.evaluate(newState, oldState); + if (evaluation.result) { + return evaluation; + } + } + return { result: false }; + } +} diff --git a/src/conditions/conditions/screen.ts b/src/conditions/conditions/screen.ts new file mode 100644 index 00000000..6d8ac176 --- /dev/null +++ b/src/conditions/conditions/screen.ts @@ -0,0 +1,34 @@ +import { ConditionsEvaluationResult } from '../types'; +import { + ConditionEvaluator, + ConditionEvaluatorSubscriptionCallback, + ConditionOfType, +} from './types'; + +export class ScreenConditionEvaluator implements ConditionEvaluator { + private _mediaQuery: MediaQueryList | null = null; + private _onChange: ConditionEvaluatorSubscriptionCallback | null = null; + + private _condition: ConditionOfType<'screen'>; + + constructor(condition: ConditionOfType<'screen'>) { + this._condition = condition; + } + + public evaluate(): ConditionsEvaluationResult { + return { result: window.matchMedia(this._condition.media_query).matches }; + } + + public subscribe(onChange: ConditionEvaluatorSubscriptionCallback): void { + this._onChange = onChange; + this._mediaQuery = window.matchMedia(this._condition.media_query); + this._mediaQuery.addEventListener('change', this._handler); + } + + public destroy(): void { + this._mediaQuery?.removeEventListener('change', this._handler); + this._mediaQuery = null; + } + + private _handler = (): void => this._onChange?.(); +} diff --git a/src/conditions/conditions/state.ts b/src/conditions/conditions/state.ts new file mode 100644 index 00000000..f22ab53d --- /dev/null +++ b/src/conditions/conditions/state.ts @@ -0,0 +1,43 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class StateConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'state'>; + + constructor(condition: ConditionOfType<'state'>) { + this._condition = condition; + } + + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + const condition = this._condition; + const fromState = oldState?.hass?.states?.[condition.entity]?.state; + const toState = newState?.hass?.states?.[condition.entity]?.state; + + return { + result: + (!condition.state && !condition.state_not && toState !== fromState) || + ((!!condition.state || !!condition.state_not) && + !!toState && + (!condition.state || + (Array.isArray(condition.state) + ? condition.state.includes(toState) + : condition.state === toState)) && + (!condition.state_not || + (Array.isArray(condition.state_not) + ? !condition.state_not.includes(toState) + : condition.state_not !== toState))), + ...(fromState !== toState && { + triggerData: { + state: { + entity: condition.entity, + ...(fromState && { from: fromState }), + ...(toState && { to: toState }), + }, + }, + }), + }; + } +} diff --git a/src/conditions/conditions/template.ts b/src/conditions/conditions/template.ts new file mode 100644 index 00000000..578eb1b4 --- /dev/null +++ b/src/conditions/conditions/template.ts @@ -0,0 +1,24 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './types'; + +export class TemplateConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'template'>; + private _context: EvaluatorContext; + + constructor(condition: ConditionOfType<'template'>, context: EvaluatorContext) { + this._condition = condition; + this._context = context; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + !!newState?.hass && + this._context.templateRenderer.renderRecursively( + newState.hass, + this._condition.value_template, + { conditionState: newState }, + ) === true, + }; + } +} diff --git a/src/conditions/conditions/triggered.ts b/src/conditions/conditions/triggered.ts new file mode 100644 index 00000000..6de65644 --- /dev/null +++ b/src/conditions/conditions/triggered.ts @@ -0,0 +1,18 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class TriggeredConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'triggered'>; + + constructor(condition: ConditionOfType<'triggered'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: this._condition.triggered.some((triggeredCameraID) => + newState?.triggered?.has(triggeredCameraID), + ), + }; + } +} diff --git a/src/conditions/conditions/types.ts b/src/conditions/conditions/types.ts new file mode 100644 index 00000000..821ba4f2 --- /dev/null +++ b/src/conditions/conditions/types.ts @@ -0,0 +1,32 @@ +import { TemplateRenderer } from '../../card-controller/templates'; +import { AdvancedCameraCardCondition } from '../../config/schema/conditions/types'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; + +export type ConditionEvaluatorSubscriptionCallback = () => void; + +/** + * A single condition, constructed once with its configuration and evaluated + * repeatedly against incoming state. + */ +export interface ConditionEvaluator { + evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult; + + // Optional hook for conditions with an external change source (e.g. + // `screen`). The owner passes a callback to request re-evaluation. + subscribe?(onChange: ConditionEvaluatorSubscriptionCallback): void; + + destroy?(): void; +} + +export interface EvaluatorContext { + templateRenderer: TemplateRenderer; +} + +// The condition union member(s) carrying a given discriminator literal. +export type ConditionOfType = Extract< + AdvancedCameraCardCondition, + { condition?: T } +>; diff --git a/src/conditions/conditions/user-agent.ts b/src/conditions/conditions/user-agent.ts new file mode 100644 index 00000000..a8ac6fd7 --- /dev/null +++ b/src/conditions/conditions/user-agent.ts @@ -0,0 +1,27 @@ +import { isBeingCasted } from '../../utils/casting'; +import { isCompanionApp } from '../../utils/companion'; +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class UserAgentConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'user_agent'>; + + constructor(condition: ConditionOfType<'user_agent'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + const condition = this._condition; + return { + result: + !!newState?.userAgent && + (!condition.user_agent || condition.user_agent === newState.userAgent) && + (condition.casting === undefined || + condition.casting === isBeingCasted(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/conditions/user.ts b/src/conditions/conditions/user.ts new file mode 100644 index 00000000..7266045a --- /dev/null +++ b/src/conditions/conditions/user.ts @@ -0,0 +1,17 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class UserConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'user'>; + + constructor(condition: ConditionOfType<'user'>) { + this._condition = condition; + } + + public evaluate(newState?: ConditionState): ConditionsEvaluationResult { + return { + result: + !!newState?.hass?.user && this._condition.users.includes(newState.hass.user.id), + }; + } +} diff --git a/src/conditions/conditions/view.ts b/src/conditions/conditions/view.ts new file mode 100644 index 00000000..9de1746e --- /dev/null +++ b/src/conditions/conditions/view.ts @@ -0,0 +1,34 @@ +import { ConditionsEvaluationResult, ConditionState } from '../types'; +import { ConditionEvaluator, ConditionOfType } from './types'; + +export class ViewConditionEvaluator implements ConditionEvaluator { + private _condition: ConditionOfType<'view'>; + + constructor(condition: ConditionOfType<'view'>) { + this._condition = condition; + } + + public evaluate( + newState?: ConditionState, + oldState?: ConditionState, + ): ConditionsEvaluationResult { + const oldView = oldState?.view; + const newView = newState?.view; + + return { + result: + (!!newView && this._condition.views?.includes(newView)) || + (newView !== oldView && !this._condition.views?.length), + ...(oldView !== newView && { + triggerData: { + ...((oldState?.view || newState?.view) && { + view: { + ...(oldState?.view && { from: oldState.view }), + ...(newState?.view && { to: newState.view }), + }, + }), + }, + }), + }; + } +} diff --git a/src/conditions/factory.ts b/src/conditions/factory.ts new file mode 100644 index 00000000..88d10d0b --- /dev/null +++ b/src/conditions/factory.ts @@ -0,0 +1,83 @@ +import { AdvancedCameraCardCondition } from '../config/schema/conditions/types'; +import { AndConditionEvaluator } from './conditions/and'; +import { CallConditionEvaluator } from './conditions/call'; +import { CameraConditionEvaluator } from './conditions/camera'; +import { ConfigConditionEvaluator } from './conditions/config'; +import { DisplayModeConditionEvaluator } from './conditions/display-mode'; +import { ExpandConditionEvaluator } from './conditions/expand'; +import { FullscreenConditionEvaluator } from './conditions/fullscreen'; +import { InitializedConditionEvaluator } from './conditions/initialized'; +import { InteractionConditionEvaluator } from './conditions/interaction'; +import { KeyConditionEvaluator } from './conditions/key'; +import { MediaLoadedConditionEvaluator } from './conditions/media-loaded'; +import { MicrophoneConditionEvaluator } from './conditions/microphone'; +import { NotConditionEvaluator } from './conditions/not'; +import { NumericStateConditionEvaluator } from './conditions/numeric-state'; +import { OrConditionEvaluator } from './conditions/or'; +import { ScreenConditionEvaluator } from './conditions/screen'; +import { StateConditionEvaluator } from './conditions/state'; +import { TemplateConditionEvaluator } from './conditions/template'; +import { TriggeredConditionEvaluator } from './conditions/triggered'; +import { ConditionEvaluator, EvaluatorContext } from './conditions/types'; +import { UserConditionEvaluator } from './conditions/user'; +import { UserAgentConditionEvaluator } from './conditions/user-agent'; +import { ViewConditionEvaluator } from './conditions/view'; + +export const createConditionEvaluator = ( + condition: AdvancedCameraCardCondition, + context: EvaluatorContext, +): ConditionEvaluator => { + switch (condition.condition) { + case undefined: + case 'state': + return new StateConditionEvaluator(condition); + case 'view': + return new ViewConditionEvaluator(condition); + case 'fullscreen': + return new FullscreenConditionEvaluator(condition); + case 'expand': + return new ExpandConditionEvaluator(condition); + case 'camera': + return new CameraConditionEvaluator(condition); + case 'numeric_state': + return new NumericStateConditionEvaluator(condition); + case 'user': + return new UserConditionEvaluator(condition); + case 'media_loaded': + return new MediaLoadedConditionEvaluator(condition); + case 'screen': + return new ScreenConditionEvaluator(condition); + case 'display_mode': + return new DisplayModeConditionEvaluator(condition); + case 'triggered': + return new TriggeredConditionEvaluator(condition); + case 'interaction': + return new InteractionConditionEvaluator(condition); + case 'microphone': + return new MicrophoneConditionEvaluator(condition); + case 'call': + return new CallConditionEvaluator(condition); + case 'key': + return new KeyConditionEvaluator(condition); + case 'user_agent': + return new UserAgentConditionEvaluator(condition); + case 'config': + return new ConfigConditionEvaluator(condition); + case 'initialized': + return new InitializedConditionEvaluator(); + case 'template': + return new TemplateConditionEvaluator(condition, context); + case 'or': + return new OrConditionEvaluator( + condition.conditions.map((child) => createConditionEvaluator(child, context)), + ); + case 'and': + return new AndConditionEvaluator( + condition.conditions.map((child) => createConditionEvaluator(child, context)), + ); + case 'not': + return new NotConditionEvaluator( + condition.conditions.map((child) => createConditionEvaluator(child, context)), + ); + } +}; diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts index ddfc5135..251e4076 100644 --- a/tests/conditions/conditions-manager.test.ts +++ b/tests/conditions/conditions-manager.test.ts @@ -1,16 +1,11 @@ -import { HassEntities } from 'home-assistant-js-websocket'; 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 { HomeAssistant } from '../../src/ha/types'; -import { - createConfig, - createHASS, - createMediaLoadedInfo, - createStateEntity, - createUser, -} from '../test-utils'; +import { createHASS, createStateEntity } from '../test-utils'; + +// Per-condition-type evaluation is covered by tests/conditions/conditions/.test.ts. +// This file covers the manager's own orchestration: building/destroying evaluators, +// listener management, trigger-data merging, and notifying (or not) on changes. // @vitest-environment jsdom describe('ConditionsManager', () => { @@ -18,1311 +13,109 @@ describe('ConditionsManager', () => { 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: ['live'] }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ view: 'live' }); - 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, - triggerData: { - view: { - to: 'clips', - }, - }, - }); - - stateManager.setState({ view: 'timeline' }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - view: { - from: 'clips', - to: 'timeline', - }, - }, - }); - - expect(listener).toBeCalledTimes(2); - }); - - it('should not re-trigger without a real 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).toHaveBeenCalledTimes(1); - - stateManager.setState({ view: 'clips' }); - expect(listener).toHaveBeenCalledTimes(1); - }); - }); - - 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, - triggerData: { - camera: { - to: 'bar', - }, - }, - }); - - stateManager.setState({ camera: 'foo' }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - camera: { - from: 'bar', - to: 'foo', - }, - }, - }); - - expect(listener).toBeCalledTimes(2); - }); - - it('should not re-trigger without a real 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).toHaveBeenCalledTimes(1); - - stateManager.setState({ camera: 'bar' }); - expect(listener).toHaveBeenCalledTimes(1); - }); - }); - - 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({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'on' }), - }), - }); - expect(listener).toBeCalledWith({ - result: true, - triggerData: { - state: { - entity: 'binary_sensor.foo', - to: 'on', - }, - }, - }); - expect(listener).toBeCalledTimes(1); - - stateManager.setState({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'off' }), - }), - }); - expect(listener).toBeCalledWith({ - result: true, - triggerData: { - 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({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - hass: createHASS({ - '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({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'active' }), - }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - hass: createHASS({ - '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({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - hass: createHASS({ - '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({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - hass: createHASS({ - 'binary_sensor.foo': createStateEntity({ state: 'active' }), - }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - hass: createHASS({ - '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({ - hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - hass: createHASS({ - '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({ - hass: createHASS({ - 'switch.one': createStateEntity({ state: 'on' }), - 'switch.two': createStateEntity({ state: 'off' }), - }), - }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - // Only the last matching state will be included in the data. - state: { - entity: 'switch.two', - to: 'off', - }, - }, - }); - - stateManager.setState({ - hass: createHASS({ - 'switch.one': createStateEntity({ state: 'off' }), - 'switch.two': createStateEntity({ state: 'on' }), - }), - }); - - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - // Only the last matching state will be included in the data. - state: { - entity: 'switch.two', - from: 'off', - to: 'on', - }, - }, - }); - - expect(listener).toBeCalledTimes(2); - }); - - it('should not re-trigger without a real change', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'state' as const, entity: 'switch.one' }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - const hass = createHASS({ - 'switch.one': createStateEntity({ state: 'on' }), - }); - stateManager.setState({ - hass, - }); - expect(listener).toBeCalledTimes(1); - expect(manager.getEvaluation()).toEqual({ - result: true, - triggerData: { - // Only the last matching state will be included in the data. - state: { - entity: 'switch.one', - to: 'on', - }, - }, - }); - - stateManager.setState({ hass }); - expect(listener).toBeCalledTimes(1); - }); - }); - - 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({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - hass: createHASS({ '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({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - }); - }); - - describe('with template condition', () => { - const createHASSForTemplateCondition = (states: HassEntities): HomeAssistant => { - const hass = createHASS(states); - vi.mocked(hass.connection.sendMessagePromise).mockResolvedValue([]); - return hass; - }; - - it('should evaluate true when template evalutes to true', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'template' as const, - value_template: '{{ is_state("sensor.foo", "on") }}', - }, - ], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - - stateManager.setState({ - hass: createHASSForTemplateCondition({ - 'sensor.foo': createStateEntity({ state: 'on' }), - }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - - stateManager.setState({ - hass: createHASSForTemplateCondition({ - 'sensor.foo': createStateEntity({ state: 'off' }), - }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('should evaluate false when template evalutes to non-boolean', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'template' as const, - // This does not result in a boolean. - value_template: '{{ hass.states["light.office"].state }}', - }, - ], - stateManager, - ); - - stateManager.setState({ - hass: createHASSForTemplateCondition({ - 'light.office': createStateEntity({ state: 'on' }), - }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - }); - - 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({ - hass: createHASS({ '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({ - hass: createHASS({}, createUser({ id: 'user_1' })), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - hass: createHASS({}, 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, triggerData: {} }); - - // 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('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(); - }); - }); - - describe('with call condition', () => { - it('bare form defaults to call:true', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'call' as const }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ call: true }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ call: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('call is true', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'call' as const, call: true }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ call: true }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ call: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('call is false', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'call' as const, call: false }], - stateManager, - ); - - // With no state.call published, the bare condition matches `false`, - // so `call: false` is satisfied initially. - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ call: true }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ call: 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 casting', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'user_agent' as const, casting: true }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - userAgent: 'CrKey/1.0', - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - userAgent: userAgent, - }); - 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('with config condition', () => { - const config_1 = createConfig({ - // Default is: - // - // view: { - // default: live, - // }, - }); - const config_2 = createConfig({ - view: { - default: 'clips', + it('should not call listeners for HA state changes without a relevant condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'fullscreen' as const, + fullscreen: true, }, - }); - const config_3 = createConfig({ - view: { - default: 'clips', - default_cycle_camera: true, + ], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }); + + expect(listener).not.toBeCalled(); + }); + + it('should notify on each change and merge trigger data across multiple conditions', () => { + 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({ + hass: createHASS({ + 'switch.one': createStateEntity({ state: 'on' }), + 'switch.two': createStateEntity({ state: 'off' }), + }), + }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + triggerData: { + // Only the last matching condition's data is retained. + state: { + entity: 'switch.two', + to: 'off', }, - }); - const config_4 = createConfig({ - view: { - default: 'clips', - default_cycle_camera: true, - dim: true, + }, + }); + + // The result stays true but the trigger data changes, so listeners are + // still notified. + stateManager.setState({ + hass: createHASS({ + 'switch.one': createStateEntity({ state: 'off' }), + 'switch.two': createStateEntity({ state: 'on' }), + }), + }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + triggerData: { + state: { + entity: 'switch.two', + from: 'off', + to: 'on', }, - }); - - it('should match any config change', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'config' as const }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ config: config_1 }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - config: { - to: config_1, - }, - }, - }); - - stateManager.setState({ config: config_2 }); - expect(listener).toHaveBeenLastCalledWith({ - result: true, - triggerData: { - config: { - from: config_1, - to: config_2, - }, - }, - }); - - expect(listener).toBeCalledTimes(2); - }); - - it('should match specific config change', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'config' as const, paths: ['view.default'] }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ config: config_1 }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ config: config_2 }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ config: config_3 }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('should match multiple config change', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'config' as const, - paths: ['view.default', 'view.default_cycle_camera'], - }, - ], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ config: config_1 }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ config: config_2 }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ config: config_3 }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ config: config_4 }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('should not match unrelated changes', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'config' as const }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ config: config_1 }); - expect(listener).toBeCalledTimes(1); - - // On the next state set, the condition won't match anymore (as the - // config is the same), the listener will still be called to indicate - // the condition evaluation has changed. - stateManager.setState({ expand: true }); - expect(listener).toBeCalledTimes(2); - expect(listener).toHaveBeenLastCalledWith({ result: false }); - - // Future unrelated state changes won't call the listener. - stateManager.setState({ fullscreen: true }); - expect(listener).toBeCalledTimes(2); - }); - - it('should not re-trigger without a real change', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'config' as const }], - stateManager, - ); - - const listener = vi.fn(); - manager.addListener(listener); - - stateManager.setState({ config: config_1 }); - expect(listener).toHaveBeenCalledTimes(1); - - stateManager.setState({ config: config_1 }); - expect(listener).toHaveBeenCalledTimes(1); - }); + }, }); - it('with initialized condition', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'initialized' as const }], - stateManager, - ); + expect(listener).toBeCalledTimes(2); + }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ initialized: true }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ initialized: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); + it('should re-evaluate and notify when a subscribed condition source changes', () => { + 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); - it('with simple OR condition', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'or' as const, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - { - condition: 'expand' as const, - expand: true, - }, - ], - }, - ], - stateManager, - ); + const manager = new ConditionsManager([ + { condition: 'screen' as const, media_query: 'whatever' }, + ]); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ fullscreen: true }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ expand: true }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ fullscreen: false }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ expand: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); + const listener = vi.fn(); + manager.addListener(listener); - it('with triggered OR condition', () => { - const stateManager = new ConditionStateManager(); + // Fire the media-query change; the manager re-evaluates and notifies. + addEventListener.mock.calls[0][1](); + expect(listener).toBeCalledWith({ result: true, triggerData: {} }); - // This is not a terribly realistic example, but chosen so that trigger - // data for both camera and view should be returned. - const manager = new ConditionsManager( - [ - { - condition: 'or' as const, - conditions: [ - { condition: 'camera' as const }, - { condition: 'view' as const }, - ], - }, - ], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - - stateManager.setState({ camera: 'camera-1' }); - expect(manager.getEvaluation().result).toBeTruthy(); - expect(manager.getEvaluation().triggerData).toEqual({ - camera: { to: 'camera-1' }, - }); - - stateManager.setState({ view: 'live' }); - expect(manager.getEvaluation().result).toBeTruthy(); - expect(manager.getEvaluation().triggerData).toEqual({ - view: { to: 'live' }, - }); - - stateManager.setState({ camera: 'camera-2', view: 'clip' }); - expect(manager.getEvaluation().result).toBeTruthy(); - expect(manager.getEvaluation().triggerData).toEqual({ - camera: { to: 'camera-2', from: 'camera-1' }, - - // View data will not be here as the view condition is not evaluated, - // since the camera one will evaluate to true first. - }); - }); - - it('with simple AND condition', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'and' as const, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - { - condition: 'expand' as const, - expand: true, - }, - ], - }, - ], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ fullscreen: true }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ expand: true }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ fullscreen: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ expand: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('with triggered AND condition', () => { - const stateManager = new ConditionStateManager(); - - // This is not a terribly realistic example, but chosen so that trigger - // data for both camera and view should be returned. - const manager = new ConditionsManager( - [ - { - condition: 'and' as const, - conditions: [ - { condition: 'camera' as const }, - { condition: 'view' as const }, - ], - }, - ], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - - stateManager.setState({ camera: 'camera-1' }); - expect(manager.getEvaluation().result).toBeFalsy(); - - stateManager.setState({ view: 'live' }); - expect(manager.getEvaluation().result).toBeFalsy(); - - stateManager.setState({ camera: 'camera-2', view: 'clip' }); - expect(manager.getEvaluation().result).toBeTruthy(); - expect(manager.getEvaluation().triggerData).toEqual({ - camera: { from: 'camera-1', to: 'camera-2' }, - view: { from: 'live', to: 'clip' }, - }); - - stateManager.setState({ view: 'snapshot' }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('with not condition', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [ - { - condition: 'not' as const, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - { - condition: 'expand' as const, - expand: true, - }, - ], - }, - ], - stateManager, - ); - - // Neither sub-condition is true initially, so `not` passes. - expect(manager.getEvaluation().result).toBeTruthy(); - - // fullscreen becomes true — any sub-condition being true means `not` fails. - stateManager.setState({ fullscreen: true }); - expect(manager.getEvaluation().result).toBeFalsy(); - - stateManager.setState({ expand: true }); - expect(manager.getEvaluation().result).toBeFalsy(); - - // fullscreen reverts, but expand is still true, so `not` still fails. - stateManager.setState({ fullscreen: false }); - expect(manager.getEvaluation().result).toBeFalsy(); - - // Both sub-conditions are false again, so `not` passes. - stateManager.setState({ expand: false }); - expect(manager.getEvaluation().result).toBeTruthy(); - - // `not` conditions never have trigger data (as nothing is triggering). - expect(manager.getEvaluation().triggerData).toEqual({}); - }); + // Destroy tears the subscription down via the evaluator. + manager.destroy(); + expect(removeEventListener).toBeCalled(); }); describe('should handle listeners correctly', () => { @@ -1386,7 +179,7 @@ describe('ConditionsManager', () => { expect(listener).not.toBeCalled(); }); - it('with not call listeners when condition result does not change', () => { + it('should not call listeners when the condition result does not change', () => { const stateManager = new ConditionStateManager(); const manager = new ConditionsManager( [{ condition: 'view' as const, views: ['live'] }], diff --git a/tests/conditions/conditions/and.test.ts b/tests/conditions/conditions/and.test.ts new file mode 100644 index 00000000..7782d693 --- /dev/null +++ b/tests/conditions/conditions/and.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('and condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate a simple and condition', () => { + const evaluator = createConditionEvaluator( + { + condition: 'and' as const, + conditions: [ + { condition: 'fullscreen' as const, fullscreen: true }, + { condition: 'expand' as const, expand: true }, + ], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true }).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true, expand: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ fullscreen: false, expand: true }).result).toBeFalsy(); + }); + + it('should report combined trigger data for an and condition', () => { + // Not a terribly realistic example, but chosen so that trigger data for + // both camera and view should be returned. + const evaluator = createConditionEvaluator( + { + condition: 'and' as const, + conditions: [{ condition: 'camera' as const }, { condition: 'view' as const }], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ camera: 'camera-1' }, {}).result).toBeFalsy(); + + expect( + evaluator.evaluate( + { camera: 'camera-2', view: 'clip' }, + { camera: 'camera-1', view: 'live' }, + ), + ).toEqual({ + result: true, + triggerData: { + camera: { from: 'camera-1', to: 'camera-2' }, + view: { from: 'live', to: 'clip' }, + }, + }); + }); + + it('should forward subscribe and destroy to its children', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList); + + // The `screen` child supports subscribe/destroy; the `fullscreen` child does + // not — forwarding must handle both. + const evaluator = createConditionEvaluator( + { + condition: 'and' as const, + conditions: [ + { condition: 'screen' as const, media_query: 'whatever' }, + { condition: 'fullscreen' as const, fullscreen: true }, + ], + }, + createEvaluatorContext(), + ); + + const onChange = vi.fn(); + evaluator.subscribe?.(onChange); + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + addEventListener.mock.calls[0][1](); + expect(onChange).toHaveBeenCalled(); + + evaluator.destroy?.(); + expect(removeEventListener).toHaveBeenCalled(); + }); +}); diff --git a/tests/conditions/conditions/call.test.ts b/tests/conditions/conditions/call.test.ts new file mode 100644 index 00000000..ec8dbbb9 --- /dev/null +++ b/tests/conditions/conditions/call.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('call condition', () => { + it('should default to call true in its bare form', () => { + const evaluator = createConditionEvaluator( + { condition: 'call' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ call: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ call: false }).result).toBeFalsy(); + }); + + it('should match when call is true', () => { + const evaluator = createConditionEvaluator( + { condition: 'call' as const, call: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ call: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ call: false }).result).toBeFalsy(); + }); + + it('should match when call is false', () => { + const evaluator = createConditionEvaluator( + { condition: 'call' as const, call: false }, + createEvaluatorContext(), + ); + + // With no state.call published, the bare condition matches `false`, + // so `call: false` is satisfied. + expect(evaluator.evaluate({}).result).toBeTruthy(); + expect(evaluator.evaluate({ call: true }).result).toBeFalsy(); + expect(evaluator.evaluate({ call: false }).result).toBeTruthy(); + }); +}); diff --git a/tests/conditions/conditions/camera.test.ts b/tests/conditions/conditions/camera.test.ts new file mode 100644 index 00000000..5817a1cb --- /dev/null +++ b/tests/conditions/conditions/camera.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('camera condition', () => { + it('should match a named camera', () => { + const evaluator = createConditionEvaluator( + { condition: 'camera' as const, cameras: ['bar'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ camera: 'bar' }).result).toBeTruthy(); + expect(evaluator.evaluate({ camera: 'will-not-match' }).result).toBeFalsy(); + }); + + it('should report trigger data for any camera change', () => { + const evaluator = createConditionEvaluator( + { condition: 'camera' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ camera: 'bar' }, {})).toEqual({ + result: true, + triggerData: { + camera: { + to: 'bar', + }, + }, + }); + + expect(evaluator.evaluate({ camera: 'foo' }, { camera: 'bar' })).toEqual({ + result: true, + triggerData: { + camera: { + from: 'bar', + to: 'foo', + }, + }, + }); + }); +}); diff --git a/tests/conditions/conditions/config.test.ts b/tests/conditions/conditions/config.test.ts new file mode 100644 index 00000000..07462648 --- /dev/null +++ b/tests/conditions/conditions/config.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createConfig } from '../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('config condition', () => { + const config_1 = createConfig({ + // Default is: + // + // view: { + // default: live, + // }, + }); + const config_2 = createConfig({ + view: { + default: 'clips', + }, + }); + const config_3 = createConfig({ + view: { + default: 'clips', + default_cycle_camera: true, + }, + }); + const config_4 = createConfig({ + view: { + default: 'clips', + default_cycle_camera: true, + dim: true, + }, + }); + + it('should report trigger data for any config change', () => { + const evaluator = createConditionEvaluator( + { condition: 'config' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ config: config_1 }, {})).toEqual({ + result: true, + triggerData: { + config: { + to: config_1, + }, + }, + }); + + expect(evaluator.evaluate({ config: config_2 }, { config: config_1 })).toEqual({ + result: true, + triggerData: { + config: { + from: config_1, + to: config_2, + }, + }, + }); + }); + + it('should not match when the config is unchanged', () => { + const evaluator = createConditionEvaluator( + { condition: 'config' as const }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ config: config_1 }, { config: config_1 }).result, + ).toBeFalsy(); + }); + + it('should match a specific config change', () => { + const evaluator = createConditionEvaluator( + { condition: 'config' as const, paths: ['view.default'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ config: config_1 }, {}).result).toBeTruthy(); + expect( + evaluator.evaluate({ config: config_2 }, { config: config_1 }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ config: config_3 }, { config: config_2 }).result, + ).toBeFalsy(); + }); + + it('should match multiple specific config changes', () => { + const evaluator = createConditionEvaluator( + { + condition: 'config' as const, + paths: ['view.default', 'view.default_cycle_camera'], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ config: config_1 }, {}).result).toBeTruthy(); + expect( + evaluator.evaluate({ config: config_2 }, { config: config_1 }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ config: config_3 }, { config: config_2 }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ config: config_4 }, { config: config_3 }).result, + ).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/display-mode.test.ts b/tests/conditions/conditions/display-mode.test.ts new file mode 100644 index 00000000..24af7c5f --- /dev/null +++ b/tests/conditions/conditions/display-mode.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('display mode condition', () => { + it('should match a display mode condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'display_mode' as const, display_mode: 'grid' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ displayMode: 'grid' }).result).toBeTruthy(); + expect(evaluator.evaluate({ displayMode: 'single' }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/expand.test.ts b/tests/conditions/conditions/expand.test.ts new file mode 100644 index 00000000..42e9a8ca --- /dev/null +++ b/tests/conditions/conditions/expand.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('expand condition', () => { + it('should match an expand condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'expand' as const, expand: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ expand: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ expand: false }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/fullscreen.test.ts b/tests/conditions/conditions/fullscreen.test.ts new file mode 100644 index 00000000..90ff16b7 --- /dev/null +++ b/tests/conditions/conditions/fullscreen.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('fullscreen condition', () => { + it('should match a fullscreen condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'fullscreen' as const, fullscreen: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ fullscreen: false }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/initialized.test.ts b/tests/conditions/conditions/initialized.test.ts new file mode 100644 index 00000000..eda4f0a4 --- /dev/null +++ b/tests/conditions/conditions/initialized.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('initialized condition', () => { + it('should match an initialized condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'initialized' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ initialized: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ initialized: false }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/interaction.test.ts b/tests/conditions/conditions/interaction.test.ts new file mode 100644 index 00000000..60a512a0 --- /dev/null +++ b/tests/conditions/conditions/interaction.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('interaction condition', () => { + it('should match an interaction condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'interaction' as const, interaction: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ interaction: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ interaction: false }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/key.test.ts b/tests/conditions/conditions/key.test.ts new file mode 100644 index 00000000..0c75f053 --- /dev/null +++ b/tests/conditions/conditions/key.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('key condition', () => { + it('should match a simple keypress', () => { + const evaluator = createConditionEvaluator( + { condition: 'key' as const, key: 'a' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + keys: { + a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, + }, + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + keys: { + a: { state: 'up', ctrl: false, shift: false, alt: false, meta: false }, + }, + }).result, + ).toBeFalsy(); + }); + + it('should match a keypress with modifiers', () => { + const evaluator = createConditionEvaluator( + { + condition: 'key' as const, + key: 'a', + state: 'down' as const, + ctrl: true, + shift: true, + alt: true, + meta: true, + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + keys: { + a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false }, + }, + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + keys: { + a: { state: 'down', ctrl: true, shift: true, alt: true, meta: false }, + }, + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + keys: { + a: { state: 'down', ctrl: true, shift: true, alt: true, meta: true }, + }, + }).result, + ).toBeTruthy(); + }); +}); diff --git a/tests/conditions/conditions/media-loaded.test.ts b/tests/conditions/conditions/media-loaded.test.ts new file mode 100644 index 00000000..53c4b22c --- /dev/null +++ b/tests/conditions/conditions/media-loaded.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createMediaLoadedInfo } from '../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('media loaded condition', () => { + it('should match a media loaded condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'media_loaded' as const, media_loaded: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ mediaLoadedInfo: createMediaLoadedInfo() }).result, + ).toBeTruthy(); + expect(evaluator.evaluate({ mediaLoadedInfo: null }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/microphone.test.ts b/tests/conditions/conditions/microphone.test.ts new file mode 100644 index 00000000..2418be47 --- /dev/null +++ b/tests/conditions/conditions/microphone.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { MicrophoneState } from '../../../src/card-controller/types'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('microphone condition', () => { + const createMicrophoneState = (state: Partial): MicrophoneState => { + return { + connected: false, + muted: false, + forbidden: false, + ...state, + }; + }; + + it('should match when muted is true', () => { + const evaluator = createConditionEvaluator( + { condition: 'microphone' as const, muted: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ microphone: createMicrophoneState({ muted: true }) }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ microphone: createMicrophoneState({ muted: false }) }).result, + ).toBeFalsy(); + }); + + it('should match when muted is false', () => { + const evaluator = createConditionEvaluator( + { condition: 'microphone' as const, muted: false }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ microphone: createMicrophoneState({ muted: true }) }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ microphone: createMicrophoneState({ muted: false }) }).result, + ).toBeTruthy(); + }); +}); diff --git a/tests/conditions/conditions/not.test.ts b/tests/conditions/conditions/not.test.ts new file mode 100644 index 00000000..0fa2e735 --- /dev/null +++ b/tests/conditions/conditions/not.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('not condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate a not condition', () => { + const evaluator = createConditionEvaluator( + { + condition: 'not' as const, + conditions: [ + { condition: 'fullscreen' as const, fullscreen: true }, + { condition: 'expand' as const, expand: true }, + ], + }, + createEvaluatorContext(), + ); + + // Neither sub-condition is true, so `not` passes — and reports no trigger + // data (nothing is "triggering"). + expect(evaluator.evaluate({})).toEqual({ result: true }); + + // Any sub-condition being true means `not` fails. + expect(evaluator.evaluate({ fullscreen: true }).result).toBeFalsy(); + expect(evaluator.evaluate({ expand: true }).result).toBeFalsy(); + + // Both sub-conditions false again — `not` passes. + expect(evaluator.evaluate({ fullscreen: false, expand: false }).result).toBeTruthy(); + }); + + it('should forward subscribe and destroy to its children', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList); + + // The `screen` child supports subscribe/destroy; the `fullscreen` child does + // not — forwarding must handle both. + const evaluator = createConditionEvaluator( + { + condition: 'not' as const, + conditions: [ + { condition: 'screen' as const, media_query: 'whatever' }, + { condition: 'fullscreen' as const, fullscreen: true }, + ], + }, + createEvaluatorContext(), + ); + + const onChange = vi.fn(); + evaluator.subscribe?.(onChange); + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + addEventListener.mock.calls[0][1](); + expect(onChange).toHaveBeenCalled(); + + evaluator.destroy?.(); + expect(removeEventListener).toHaveBeenCalled(); + }); +}); diff --git a/tests/conditions/conditions/numeric-state.test.ts b/tests/conditions/conditions/numeric-state.test.ts new file mode 100644 index 00000000..9206170e --- /dev/null +++ b/tests/conditions/conditions/numeric-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createHASS, createStateEntity } from '../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('numeric state condition', () => { + it('should match above a threshold', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity: 'sensor.foo', above: 10 }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: '9' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match below a threshold', () => { + const evaluator = createConditionEvaluator( + { condition: 'numeric_state' as const, entity: 'sensor.foo', below: 10 }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }), + }).result, + ).toBeTruthy(); + }); +}); diff --git a/tests/conditions/conditions/or.test.ts b/tests/conditions/conditions/or.test.ts new file mode 100644 index 00000000..a04a4318 --- /dev/null +++ b/tests/conditions/conditions/or.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('or condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate a simple or condition', () => { + const evaluator = createConditionEvaluator( + { + condition: 'or' as const, + conditions: [ + { condition: 'fullscreen' as const, fullscreen: true }, + { condition: 'expand' as const, expand: true }, + ], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ fullscreen: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ expand: true }).result).toBeTruthy(); + expect(evaluator.evaluate({ fullscreen: false, expand: false }).result).toBeFalsy(); + }); + + it('should report trigger data for the first matching sub-condition', () => { + // Not a terribly realistic example, but chosen so that trigger data for + // both camera and view could be returned. + const evaluator = createConditionEvaluator( + { + condition: 'or' as const, + conditions: [{ condition: 'camera' as const }, { condition: 'view' as const }], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + + expect(evaluator.evaluate({ camera: 'camera-1' }, {})).toEqual({ + result: true, + triggerData: { camera: { to: 'camera-1' } }, + }); + + expect(evaluator.evaluate({ view: 'live' }, {})).toEqual({ + result: true, + triggerData: { view: { to: 'live' } }, + }); + + // When both change, the camera sub-condition matches first, so only its + // trigger data is returned. + expect( + evaluator.evaluate({ camera: 'camera-2', view: 'clip' }, { camera: 'camera-1' }), + ).toEqual({ + result: true, + triggerData: { camera: { from: 'camera-1', to: 'camera-2' } }, + }); + }); + + it('should forward subscribe and destroy to its children', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList); + + // The `screen` child supports subscribe/destroy; the `fullscreen` child does + // not — forwarding must handle both. + const evaluator = createConditionEvaluator( + { + condition: 'or' as const, + conditions: [ + { condition: 'screen' as const, media_query: 'whatever' }, + { condition: 'fullscreen' as const, fullscreen: true }, + ], + }, + createEvaluatorContext(), + ); + + const onChange = vi.fn(); + evaluator.subscribe?.(onChange); + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + addEventListener.mock.calls[0][1](); + expect(onChange).toHaveBeenCalled(); + + evaluator.destroy?.(); + expect(removeEventListener).toHaveBeenCalled(); + }); +}); diff --git a/tests/conditions/conditions/screen.test.ts b/tests/conditions/conditions/screen.test.ts new file mode 100644 index 00000000..3578022d --- /dev/null +++ b/tests/conditions/conditions/screen.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('screen condition', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should evaluate the media query', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + } as unknown as MediaQueryList); + + const evaluator = createConditionEvaluator( + { condition: 'screen' as const, media_query: 'whatever' }, + createEvaluatorContext(), + ); + expect(evaluator.evaluate().result).toBeTruthy(); + }); + + it('should register and invoke a media-query listener on subscribe', () => { + const addEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener: addEventListener, + removeEventListener: vi.fn(), + } as unknown as MediaQueryList); + + const evaluator = createConditionEvaluator( + { condition: 'screen' as const, media_query: 'whatever' }, + createEvaluatorContext(), + ); + + const onChange = vi.fn(); + evaluator.subscribe?.(onChange); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + + // Invoke the registered handler and confirm it triggers the callback. + addEventListener.mock.calls[0][1](); + expect(onChange).toHaveBeenCalled(); + }); + + it('should remove the media-query listener on destroy', () => { + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + addEventListener: vi.fn(), + removeEventListener: removeEventListener, + } as unknown as MediaQueryList); + + const evaluator = createConditionEvaluator( + { condition: 'screen' as const, media_query: 'whatever' }, + createEvaluatorContext(), + ); + + evaluator.subscribe?.(vi.fn()); + evaluator.destroy?.(); + expect(removeEventListener).toHaveBeenCalledWith('change', expect.anything()); + }); +}); diff --git a/tests/conditions/conditions/state.test.ts b/tests/conditions/conditions/state.test.ts new file mode 100644 index 00000000..11e3259e --- /dev/null +++ b/tests/conditions/conditions/state.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createHASS, createStateEntity } from '../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('state condition', () => { + it('should report trigger data for any change when neither state nor state_not is set', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity: 'binary_sensor.foo' }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate( + { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), + }, + {}, + ), + ).toEqual({ + result: true, + triggerData: { + state: { + entity: 'binary_sensor.foo', + to: 'on', + }, + }, + }); + + expect( + evaluator.evaluate( + { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }, + { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'on' }) }), + }, + ), + ).toEqual({ + result: true, + triggerData: { + state: { + entity: 'binary_sensor.foo', + from: 'on', + to: 'off', + }, + }, + }); + }); + + it('should match a single positive state', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity: 'binary_sensor.foo', state: 'on' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match multiple positive states', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state: ['active', 'on'], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'active' }), + }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should match a single negative state', () => { + const evaluator = createConditionEvaluator( + { condition: 'state' as const, entity: 'binary_sensor.foo', state_not: 'on' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should match multiple negative states', () => { + const evaluator = createConditionEvaluator( + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state_not: ['active', 'on'], + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'active' }), + }), + }).result, + ).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeTruthy(); + }); + + it('should match an implicit state condition', () => { + const evaluator = createConditionEvaluator( + { entity: 'binary_sensor.foo', state: 'on' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/template.test.ts b/tests/conditions/conditions/template.test.ts new file mode 100644 index 00000000..7150cbd1 --- /dev/null +++ b/tests/conditions/conditions/template.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createHASS, createStateEntity } from '../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('template condition', () => { + it('should evaluate true when template evalutes to true', () => { + const evaluator = createConditionEvaluator( + { + condition: 'template' as const, + value_template: '{{ is_state("sensor.foo", "on") }}', + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: 'on' }) }), + }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ + hass: createHASS({ 'sensor.foo': createStateEntity({ state: 'off' }) }), + }).result, + ).toBeFalsy(); + }); + + it('should evaluate false when template evalutes to non-boolean', () => { + const evaluator = createConditionEvaluator( + { + condition: 'template' as const, + // This does not result in a boolean. + value_template: '{{ hass.states["light.office"].state }}', + }, + createEvaluatorContext(), + ); + + expect( + evaluator.evaluate({ + hass: createHASS({ 'light.office': createStateEntity({ state: 'on' }) }), + }).result, + ).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/test-utils.ts b/tests/conditions/conditions/test-utils.ts new file mode 100644 index 00000000..6c813514 --- /dev/null +++ b/tests/conditions/conditions/test-utils.ts @@ -0,0 +1,6 @@ +import { TemplateRenderer } from '../../../src/card-controller/templates'; +import { EvaluatorContext } from '../../../src/conditions/conditions/types'; + +export const createEvaluatorContext = (): EvaluatorContext => ({ + templateRenderer: new TemplateRenderer(), +}); diff --git a/tests/conditions/conditions/triggered.test.ts b/tests/conditions/conditions/triggered.test.ts new file mode 100644 index 00000000..1ccf5199 --- /dev/null +++ b/tests/conditions/conditions/triggered.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('triggered condition', () => { + it('should match a triggered condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'triggered' as const, triggered: ['camera_1', 'camera_2'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ triggered: new Set(['camera_1']) }).result).toBeTruthy(); + expect( + evaluator.evaluate({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }) + .result, + ).toBeTruthy(); + expect(evaluator.evaluate({ triggered: new Set(['camera_3']) }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/user-agent.test.ts b/tests/conditions/conditions/user-agent.test.ts new file mode 100644 index 00000000..d6b9fbbb --- /dev/null +++ b/tests/conditions/conditions/user-agent.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('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 evaluator = createConditionEvaluator( + { condition: 'user_agent' as const, user_agent: userAgent }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ userAgent: userAgent }).result).toBeTruthy(); + expect(evaluator.evaluate({ userAgent: 'Something else' }).result).toBeFalsy(); + }); + + it('should match user agent regex', () => { + const evaluator = createConditionEvaluator( + { condition: 'user_agent' as const, user_agent_re: 'Chrome/' }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ userAgent: userAgent }).result).toBeTruthy(); + expect(evaluator.evaluate({ userAgent: 'Something else' }).result).toBeFalsy(); + }); + + it('should match casting', () => { + const evaluator = createConditionEvaluator( + { condition: 'user_agent' as const, casting: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ userAgent: 'CrKey/1.0' }).result).toBeTruthy(); + expect(evaluator.evaluate({ userAgent: userAgent }).result).toBeFalsy(); + }); + + it('should match companion app', () => { + const evaluator = createConditionEvaluator( + { condition: 'user_agent' as const, companion: true }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ userAgent: 'Home Assistant/' }).result).toBeTruthy(); + expect(evaluator.evaluate({ userAgent: userAgent }).result).toBeFalsy(); + }); + + it('should match multiple parameters', () => { + const evaluator = createConditionEvaluator( + { + condition: 'user_agent' as const, + companion: true, + user_agent: 'Home Assistant/', + user_agent_re: 'Home.Assistant', + }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ userAgent: 'Home Assistant/' }).result).toBeTruthy(); + expect(evaluator.evaluate({ userAgent: 'Something else' }).result).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/user.test.ts b/tests/conditions/conditions/user.test.ts new file mode 100644 index 00000000..dc7bb248 --- /dev/null +++ b/tests/conditions/conditions/user.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createHASS, createUser } from '../../test-utils'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('user condition', () => { + it('should match a user condition', () => { + const evaluator = createConditionEvaluator( + { condition: 'user' as const, users: ['user_1', 'user_2'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect( + evaluator.evaluate({ hass: createHASS({}, createUser({ id: 'user_1' })) }).result, + ).toBeTruthy(); + expect( + evaluator.evaluate({ hass: createHASS({}, createUser({ id: 'user_WRONG' })) }) + .result, + ).toBeFalsy(); + }); +}); diff --git a/tests/conditions/conditions/view.test.ts b/tests/conditions/conditions/view.test.ts new file mode 100644 index 00000000..7c7128ad --- /dev/null +++ b/tests/conditions/conditions/view.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createConditionEvaluator } from '../../../src/conditions/factory'; +import { createEvaluatorContext } from './test-utils'; + +// @vitest-environment jsdom +describe('view condition', () => { + it('should match a named view', () => { + const evaluator = createConditionEvaluator( + { condition: 'view' as const, views: ['live'] }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({}).result).toBeFalsy(); + expect(evaluator.evaluate({ view: 'live' }).result).toBeTruthy(); + expect(evaluator.evaluate({ view: 'clips' }).result).toBeFalsy(); + }); + + it('should report trigger data for any view change', () => { + const evaluator = createConditionEvaluator( + { condition: 'view' as const }, + createEvaluatorContext(), + ); + + expect(evaluator.evaluate({ view: 'clips' }, {})).toEqual({ + result: true, + triggerData: { + view: { + to: 'clips', + }, + }, + }); + + expect(evaluator.evaluate({ view: 'timeline' }, { view: 'clips' })).toEqual({ + result: true, + triggerData: { + view: { + from: 'clips', + to: 'timeline', + }, + }, + }); + }); +});