From ed91c87899ee249bd184f322e6ab7495e9035b9e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 Oct 2023 20:14:08 -0700 Subject: [PATCH] Move `card-controller` out of `utils`. --- src/card-controller/actions-manager.ts | 223 ++++++ src/card-controller/auto-update-manager.ts | 43 ++ src/card-controller/automations-manager.ts | 69 ++ src/card-controller/camera-url-manager.ts | 32 + src/card-controller/card-element-manager.ts | 117 +++ src/card-controller/conditions-manager.ts | 257 +++++++ src/card-controller/config-manager.ts | 147 ++++ src/card-controller/controller.ts | 259 +++++++ src/card-controller/download-manager.ts | 41 + src/card-controller/expand-manager.ts | 31 + src/card-controller/fullscreen-manager.ts | 47 ++ src/card-controller/hass-manager.ts | 89 +++ src/card-controller/initialization-manager.ts | 134 ++++ src/card-controller/interaction-manager.ts | 50 ++ src/card-controller/media-info-manager.ts | 52 ++ src/card-controller/media-player-manager.ts | 125 +++ src/card-controller/message-manager.ts | 67 ++ src/card-controller/microphone-manager.ts | 107 +++ src/card-controller/query-string-manager.ts | 160 ++++ src/card-controller/style-manager.ts | 126 +++ src/card-controller/triggers-manager.ts | 126 +++ src/card-controller/types.ts | 214 +++++ src/card-controller/view-manager.ts | 286 +++++++ tests/card-controller/actions-manager.test.ts | 728 ++++++++++++++++++ .../auto-update-manager.test.ts | 73 ++ .../automations-manager.test.ts | 150 ++++ .../camera-url-manager.test.ts | 58 ++ .../card-element-manager.test.ts | 182 +++++ .../conditions-manager.test.ts | 363 +++++++++ tests/card-controller/config-manager.test.ts | 304 ++++++++ tests/card-controller/controller.test.ts | 276 +++++++ .../card-controller/download-manager.test.ts | 94 +++ tests/card-controller/expand-manager.test.ts | 46 ++ .../fullscreen-manager.test.ts | 129 ++++ tests/card-controller/hass-manager.test.ts | 279 +++++++ .../initialization-manager.test.ts | 203 +++++ .../interaction-manager.test.ts | 85 ++ .../media-info-manager.test.ts | 51 ++ .../media-player-manager.test.ts | 278 +++++++ tests/card-controller/message-manager.test.ts | 141 ++++ .../microphone-manager.test.ts | 193 +++++ .../query-string-manager.test.ts | 298 +++++++ tests/card-controller/style-manager.test.ts | 387 ++++++++++ .../card-controller/triggers-manager.test.ts | 190 +++++ tests/card-controller/view-manager.test.ts | 698 +++++++++++++++++ 45 files changed, 8008 insertions(+) create mode 100644 src/card-controller/actions-manager.ts create mode 100644 src/card-controller/auto-update-manager.ts create mode 100644 src/card-controller/automations-manager.ts create mode 100644 src/card-controller/camera-url-manager.ts create mode 100644 src/card-controller/card-element-manager.ts create mode 100644 src/card-controller/conditions-manager.ts create mode 100644 src/card-controller/config-manager.ts create mode 100644 src/card-controller/controller.ts create mode 100644 src/card-controller/download-manager.ts create mode 100644 src/card-controller/expand-manager.ts create mode 100644 src/card-controller/fullscreen-manager.ts create mode 100644 src/card-controller/hass-manager.ts create mode 100644 src/card-controller/initialization-manager.ts create mode 100644 src/card-controller/interaction-manager.ts create mode 100644 src/card-controller/media-info-manager.ts create mode 100644 src/card-controller/media-player-manager.ts create mode 100644 src/card-controller/message-manager.ts create mode 100644 src/card-controller/microphone-manager.ts create mode 100644 src/card-controller/query-string-manager.ts create mode 100644 src/card-controller/style-manager.ts create mode 100644 src/card-controller/triggers-manager.ts create mode 100644 src/card-controller/types.ts create mode 100644 src/card-controller/view-manager.ts create mode 100644 tests/card-controller/actions-manager.test.ts create mode 100644 tests/card-controller/auto-update-manager.test.ts create mode 100644 tests/card-controller/automations-manager.test.ts create mode 100644 tests/card-controller/camera-url-manager.test.ts create mode 100644 tests/card-controller/card-element-manager.test.ts create mode 100644 tests/card-controller/conditions-manager.test.ts create mode 100644 tests/card-controller/config-manager.test.ts create mode 100644 tests/card-controller/controller.test.ts create mode 100644 tests/card-controller/download-manager.test.ts create mode 100644 tests/card-controller/expand-manager.test.ts create mode 100644 tests/card-controller/fullscreen-manager.test.ts create mode 100644 tests/card-controller/hass-manager.test.ts create mode 100644 tests/card-controller/initialization-manager.test.ts create mode 100644 tests/card-controller/interaction-manager.test.ts create mode 100644 tests/card-controller/media-info-manager.test.ts create mode 100644 tests/card-controller/media-player-manager.test.ts create mode 100644 tests/card-controller/message-manager.test.ts create mode 100644 tests/card-controller/microphone-manager.test.ts create mode 100644 tests/card-controller/query-string-manager.test.ts create mode 100644 tests/card-controller/style-manager.test.ts create mode 100644 tests/card-controller/triggers-manager.test.ts create mode 100644 tests/card-controller/view-manager.test.ts diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts new file mode 100644 index 00000000..37d50b5a --- /dev/null +++ b/src/card-controller/actions-manager.ts @@ -0,0 +1,223 @@ +import { + Actions, + ActionsConfig, + FrigateCardCustomAction, + FRIGATE_CARD_VIEW_DEFAULT, +} from '../config/types.js'; +import { + convertActionToFrigateCardCustomAction, + frigateCardHandleActionConfig, + getActionConfigGivenAction, +} from '../utils/action.js'; +import { getStreamCameraID } from '../utils/substream.js'; +import { CardActionsManagerAPI } from './types.js'; + +export class ActionsManager { + protected _api: CardActionsManagerAPI; + + constructor(api: CardActionsManagerAPI) { + this._api = api; + } + + /** + * Merge card-wide and view-specific actions. + * @returns A combined set of action. + */ + public getMergedActions(): ActionsConfig { + const view = this._api.getViewManager().getView(); + if (this._api.getMessageManager().hasMessage()) { + return {}; + } + + const config = this._api.getConfigManager().getConfig(); + let specificActions: Actions | undefined = undefined; + if (view?.is('live')) { + specificActions = config?.live.actions; + } else if (view?.isGalleryView()) { + specificActions = config?.media_gallery?.actions; + } else if (view?.isViewerView()) { + specificActions = config?.media_viewer.actions; + } else if (view?.is('image')) { + specificActions = config?.image?.actions; + } else { + return {}; + } + return { ...config?.view.actions, ...specificActions }; + } + + /** + * Handle an human interaction called on an element (e.g. 'tap'). + */ + public handleInteraction(interaction: string): void { + const hass = this._api.getHASSManager().getHASS(); + const config = this.getMergedActions(); + const actionConfig = getActionConfigGivenAction(interaction, config); + if ( + hass && + config && + interaction && + // Don't call frigateCardHandleActionConfig() unless there is explicitly an + // action defined (as it uses a default that is unhelpful for views that + // have default tap/click actions). + actionConfig + ) { + frigateCardHandleActionConfig( + this._api.getCardElementManager().getElement(), + hass, + config, + interaction, + actionConfig, + ); + } + } + + public handleActionEvent = (ev: Event): void => { + if (!('detail' in ev)) { + // The event may not actually be a CustomEvent object, but may still have a + // detail field. See: + // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70 + return; + } + + const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); + if (frigateCardAction) { + this.executeAction(frigateCardAction); + } + }; + + /** + * Execute a card action. + * @param frigateCardAction + * @returns `true` if an action is executed. + */ + public async executeAction(frigateCardAction: FrigateCardCustomAction): Promise { + const config = this._api.getConfigManager().getConfig(); + const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager(); + + if ( + // Command not intended for this card (e.g. query string command). + frigateCardAction.card_id && + config?.card_id !== frigateCardAction.card_id + ) { + return; + } + + // Note: This function needs to process (view-related) commands even when + // _view has not yet been initialized (since it may be used to set a view + // via the querystring). + const view = this._api.getViewManager().getView(); + + const action = frigateCardAction.frigate_card_action; + + switch (action) { + case 'default': + this._api.getViewManager().setViewDefault(); + break; + case 'clip': + case 'clips': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + this._api.getViewManager().setViewByParameters({ + viewName: action, + cameraID: view?.camera, + }); + break; + case 'download': + await this._api.getDownloadManager().downloadViewerMedia(); + break; + case 'camera_ui': + this._api.getCameraURLManager().openURL(); + break; + case 'expand': + this._api.getExpandManager().toggleExpanded(); + break; + case 'fullscreen': + this._api.getFullscreenManager().toggleFullscreen(); + break; + case 'menu_toggle': + // This is a rare code path: this would only be used if someone has a + // menu toggle action configured outside of the menu itself (e.g. + // picture elements). + this._api.getCardElementManager().toggleMenu(); + break; + case 'camera_select': + const selectCameraID = frigateCardAction.camera; + if (view) { + const viewOnCameraSelect = config?.view.camera_select ?? 'current'; + const targetViewName = + viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect; + const verifiedViewName = this._api + .getViewManager() + .isViewSupportedByCamera(selectCameraID, targetViewName) + ? targetViewName + : FRIGATE_CARD_VIEW_DEFAULT; + this._api.getViewManager().setViewByParameters({ + viewName: verifiedViewName, + cameraID: selectCameraID, + }); + } + break; + case 'live_substream_select': { + this._api.getViewManager().setViewWithSubstream(frigateCardAction.camera); + break; + } + case 'live_substream_off': { + this._api.getViewManager().setViewWithoutSubstream(); + break; + } + case 'live_substream_on': { + this._api.getViewManager().setViewWithSubstream(); + break; + } + case 'media_player': + const mediaPlayer = frigateCardAction.media_player; + const mediaPlayerController = this._api.getMediaPlayerManager(); + const media = view?.queryResults?.getSelectedResult() ?? null; + + if (frigateCardAction.media_player_action === 'stop') { + await mediaPlayerController.stop(mediaPlayer); + } else if (view?.is('live')) { + await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view)); + } else if (view?.isViewerView() && media) { + await mediaPlayerController.playMedia(mediaPlayer, media); + } + break; + case 'diagnostics': + this._api.getViewManager().setViewByParameters({ viewName: 'diagnostics' }); + break; + case 'microphone_mute': + this._api.getMicrophoneManager().mute(); + break; + case 'microphone_unmute': + await this._api.getMicrophoneManager().unmute(); + break; + case 'mute': + await mediaLoadedInfoManager.get()?.player?.mute(); + break; + case 'unmute': + await mediaLoadedInfoManager.get()?.player?.unmute(); + break; + case 'play': + await mediaLoadedInfoManager.get()?.player?.play(); + break; + case 'pause': + await mediaLoadedInfoManager.get()?.player?.pause(); + break; + case 'screenshot': + await this._api.getDownloadManager().downloadScreenshot(); + break; + case 'display_mode_select': + this._api + .getViewManager() + .setViewWithNewDisplayMode(frigateCardAction.display_mode); + break; + default: + console.warn(`Frigate card received unknown card action: ${action}`); + } + } +} diff --git a/src/card-controller/auto-update-manager.ts b/src/card-controller/auto-update-manager.ts new file mode 100644 index 00000000..96462c97 --- /dev/null +++ b/src/card-controller/auto-update-manager.ts @@ -0,0 +1,43 @@ +import { Timer } from '../utils/timer'; +import { CardAutoRefreshAPI } from './types'; + +export class AutoUpdateManager { + protected _timer = new Timer(); + protected _api: CardAutoRefreshAPI; + + constructor(api: CardAutoRefreshAPI) { + this._api = api; + } + + /** + * Set the update timer to trigger an update refresh every + * `view.update_seconds`. + */ + public startDefaultViewTimer(): void { + this._timer.stop(); + const updateSeconds = this._api.getConfigManager().getConfig() + ?.view.update_seconds; + if (updateSeconds) { + this._timer.start(updateSeconds, () => { + if (this._isAutomatedUpdateAllowed()) { + this._api.getViewManager().setViewDefault(); + } else { + // Not allowed to update this time around, but try again at the next + // interval. + this.startDefaultViewTimer(); + } + }); + } + } + + protected _isAutomatedUpdateAllowed(): boolean { + const triggers = this._api.getTriggersManager(); + const config = this._api.getConfigManager().getConfig(); + const interactionManager = this._api.getInteractionManager(); + + return ( + !triggers.isTriggered() && + (config?.view.update_force || !interactionManager.hasInteraction()) + ); + } +} diff --git a/src/card-controller/automations-manager.ts b/src/card-controller/automations-manager.ts new file mode 100644 index 00000000..cede9ed1 --- /dev/null +++ b/src/card-controller/automations-manager.ts @@ -0,0 +1,69 @@ +import { Automation, AutomationActions, Automations } from '../config/types.js'; +import { localize } from '../localize/localize.js'; +import { frigateCardHandleAction } from '../utils/action.js'; +import { CardAutomationsAPI } from './types.js'; + +const MAX_NESTED_AUTOMATION_EXECUTIONS = 10; + +export class AutomationsManager { + protected _api: CardAutomationsAPI; + + protected _automations: Automations; + protected _priorEvaluations: Map = new Map(); + + // A counter to avoid infinite loops, increases every time actions are run, + // decreases every time actions are complete. + protected _nestedAutomationExecutions = 0; + + constructor(api: CardAutomationsAPI) { + this._api = api; + } + + public setAutomationsFromConfig() { + this._automations = this._api + .getConfigManager() + .getNonOverriddenConfig()?.automations; + } + + public execute(): void { + const hass = this._api.getHASSManager().getHASS(); + + // Never execute automations if there's an error (as our automation loop + // avoidance -- which shows as an error -- would not work!). + if (!hass || this._api.getMessageManager().hasErrorMessage()) { + return; + } + + const actionsToRun: AutomationActions[] = []; + for (const automation of this._automations ?? []) { + const shouldExecute = this._api + .getConditionsManager() + .evaluateCondition(automation.conditions); + const actions = shouldExecute ? automation.actions : automation.actions_not; + const priorEvaluation = this._priorEvaluations.get(automation); + this._priorEvaluations.set(automation, shouldExecute); + if (shouldExecute !== priorEvaluation && actions) { + actionsToRun.push(actions); + } + } + + ++this._nestedAutomationExecutions; + if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) { + this._api.getMessageManager().setMessageIfHigherPriority({ + type: 'error', + message: localize('error.too_many_automations'), + }); + return; + } + + actionsToRun.forEach((actions) => { + frigateCardHandleAction( + this._api.getCardElementManager().getElement(), + hass, + {}, + actions, + ); + }); + --this._nestedAutomationExecutions; + } +} diff --git a/src/card-controller/camera-url-manager.ts b/src/card-controller/camera-url-manager.ts new file mode 100644 index 00000000..58d9e782 --- /dev/null +++ b/src/card-controller/camera-url-manager.ts @@ -0,0 +1,32 @@ +import { CardCameraURLAPI } from './types'; + +export class CameraURLManager { + protected _api: CardCameraURLAPI; + + constructor(api: CardCameraURLAPI) { + this._api = api; + } + + public openURL(): void { + const url = this.getCameraURL(); + if (url) { + window.open(url); + } + } + + public hasCameraURL(): boolean { + return !!this.getCameraURL(); + } + + public getCameraURL(): string | null { + const view = this._api.getViewManager().getView(); + const media = view?.queryResults?.getSelectedResult() ?? null; + const endpoints = view?.camera + ? this._api.getCameraManager().getCameraEndpoints(view.camera, { + view: view.view, + ...(media && { media: media }), + }) ?? null + : null; + return endpoints?.ui?.endpoint ?? null; + } +} diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts new file mode 100644 index 00000000..1df6edef --- /dev/null +++ b/src/card-controller/card-element-manager.ts @@ -0,0 +1,117 @@ +import { LitElement, ReactiveControllerHost } from 'lit'; +import { ActionEventTarget } from '../action-handler-directive'; +import { setOrRemoveAttribute } from '../utils/basic'; +import { isCardInPanel } from '../utils/ha'; +import { CardElementAPI } from './types'; + +export type ScrollCallback = () => void; +export type MenuToggleCallback = () => void; + +export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget; + +export class CardElementManager { + protected _api: CardElementAPI; + + protected _element: CardHTMLElement; + protected _scrollCallback: ScrollCallback; + protected _menuToggleCallback: MenuToggleCallback; + + constructor( + api: CardElementAPI, + element: CardHTMLElement, + scrollCallback: ScrollCallback, + menuToggleCallback: MenuToggleCallback, + ) { + this._api = api; + + this._element = element; + this._scrollCallback = scrollCallback; + this._menuToggleCallback = menuToggleCallback; + } + + public getElement(): HTMLElement { + return this._element; + } + + public scrollReset(): void { + this._scrollCallback(); + } + + public toggleMenu(): void { + this._menuToggleCallback(); + } + + public update(): void { + this._element.requestUpdate(); + } + + public hasUpdated(): boolean { + return this._element.hasUpdated; + } + + public getCardHeight(): number { + return this._element.getBoundingClientRect().height; + } + + public elementConnected(): void { + // Whether or not the card is in panel mode on the dashboard. + setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); + + this._api.getFullscreenManager().connect(); + + this._element.addEventListener( + 'mousemove', + this._api.getInteractionManager().reportInteraction, + ); + this._element.addEventListener( + 'll-custom', + this._api.getActionsManager().handleActionEvent, + ); + this._element.addEventListener( + '@action', + this._api.getInteractionManager().reportInteraction, + ); + + // Listen for HA `navigate` actions. + // See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43 + window.addEventListener( + 'location-changed', + this._api.getQueryStringManager().executeAll, + ); + + // Listen for history state changes (i.e. user using the browser + // back/forward controls). + window.addEventListener('popstate', this._api.getQueryStringManager().executeAll); + + // Manually call the location change handler as the card will be + // disconnected/reconnected when dashboard 'tab' changes happen within HA. + this._api.getQueryStringManager().executeAll(); + } + + public elementDisconnected(): void { + setOrRemoveAttribute(this._element, false, 'panel'); + + // When the dashboard 'tab' is changed, the media is effectively unloaded. + this._api.getMediaLoadedInfoManager().clear(); + this._api.getFullscreenManager().disconnect(); + + this._element.removeEventListener( + 'mousemove', + this._api.getInteractionManager().reportInteraction, + ); + this._element.removeEventListener( + 'll-custom', + this._api.getActionsManager().handleActionEvent, + ); + this._element.removeEventListener( + '@action', + this._api.getInteractionManager().reportInteraction, + ); + + window.removeEventListener( + 'location-changed', + this._api.getQueryStringManager().executeAll, + ); + window.removeEventListener('popstate', this._api.getQueryStringManager().executeAll); + } +} diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts new file mode 100644 index 00000000..deba399f --- /dev/null +++ b/src/card-controller/conditions-manager.ts @@ -0,0 +1,257 @@ +import { HassEntities } from 'home-assistant-js-websocket'; +import merge from 'lodash-es/merge'; +import { copyConfig } from '../config-mgmt'; +import { + FrigateCardCondition, + frigateConditionalSchema, + OverrideConfigurationKey, + RawFrigateCardConfig, + ViewDisplayMode +} from '../config/types'; +import { CardConditionAPI } from './types'; + +interface ConditionState { + view?: string; + fullscreen?: boolean; + expand?: boolean; + camera?: string; + state?: HassEntities; + media_loaded?: boolean; + displayMode?: ViewDisplayMode; +} + +export class ConditionEvaluateRequestEvent extends Event { + public condition: FrigateCardCondition; + public evaluation?: boolean; + + constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) { + super('frigate-card:condition:evaluate', eventInitDict); + this.condition = condition; + } +} + +/** + * Evaluate whether a frigateCardCondition is met using an event to evaluate. + * @returns A boolean indicating whether the condition is met. + */ +export function evaluateConditionViaEvent( + element: HTMLElement, + condition?: FrigateCardCondition, +): boolean { + if (!condition) { + return true; + } + + const evaluateEvent = new ConditionEvaluateRequestEvent(condition, { + bubbles: true, + composed: true, + }); + + /* Special note on what's going on here: + * + * Some parts of the card (e.g. ) may have arbitrary + * complexity and layers (that this card doesn't control) between that master + * element and the element that needs to evaluate the condition. In these + * cases there's no clean way to pass state from the rest of card down through + * these layers. Instead, an event is dispatched as a "request for evaluation" + * (ConditionEvaluateRequestEvent) upwards which is caught by the outer card + * and the evaluation result is added to the event object. Because event + * propagation is handled synchronously, the result will be added to the event + * before the flow proceeds. + */ + element.dispatchEvent(evaluateEvent); + return evaluateEvent.evaluation ?? false; +} + +type RawOverrides = { + conditions: FrigateCardCondition; + overrides: RawFrigateCardConfig; +}[]; + +export function getOverriddenConfig( + manager: Readonly, + config: Readonly, + configOverrides?: Readonly, + stateOverrides?: Partial, +): RawFrigateCardConfig { + const output = copyConfig(config); + let overridden = false; + if (configOverrides) { + for (const override of configOverrides) { + if (manager.evaluateCondition(override.conditions, stateOverrides)) { + merge(output, override.overrides); + overridden = true; + } + } + } + // Attempt to return the same configuration object if it has not been + // overridden (to reduce re-renders for a configuration that has not changed). + return overridden ? output : config; +} + +export function getOverridesByKey( + key: OverrideConfigurationKey, + overrides?: Readonly, +): RawOverrides { + return ( + overrides + ?.filter((o) => key in o.overrides) + .map((o) => ({ + conditions: o.conditions, + overrides: o.overrides[key] as RawFrigateCardConfig, + })) ?? [] + ); +} + +// A tiny wrapper interface to allow the same manager to be passed around +// immutably within objects that will not be equal (===). Every state change +// generates a new epoch. This is used for Lit rendering to ensure changes to +// condition state are recognized as changes even though the manager is the +// same. +export interface ConditionsManagerEpoch { + manager: Readonly; +} + +export type ConditionsManagerListener = () => void; + +export class ConditionsManager { + protected _api: CardConditionAPI; + + protected _state: ConditionState = {}; + protected _epoch: ConditionsManagerEpoch = this._createEpoch(); + protected _listeners: ConditionsManagerListener[]; + + // Whether or not to include HA state in ConditionState. Doing so increases + // CPU usage as HA state is pumped out very fast, so this is only enabled if + // the configuration needs to consume it. + protected _hasHAStateConditions = false; + protected _mediaQueries: MediaQueryList[] = []; + protected _mediaQueryTrigger = () => this._triggerChange(); + + constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) { + this._api = api; + this._listeners = [ + () => this._api.getConfigManager().computeOverrideConfig(), + () => this._api.getAutomationsManager().execute(), + ...(listener ? [listener] : []) + ]; + } + + public removeConditions(): void { + this._mediaQueries.forEach((mql) => + mql.removeEventListener('change', this._mediaQueryTrigger), + ); + this._mediaQueries = []; + } + + public setConditionsFromConfig(): void { + this.removeConditions(); + + const getAllConditions = (): FrigateCardCondition[] => { + const config = this._api.getConfigManager().getConfig(); + const conditions: FrigateCardCondition[] = []; + config?.overrides?.forEach((override) => conditions.push(override.conditions)); + + // Element conditions can be arbitrarily nested underneath conditionals and + // custom elements that this card may not known. Here we recursively parse + // down the elements tree, parsing as we go to find valid conditions. + const getElementsConditions = (data: unknown): void => { + const parseResult = frigateConditionalSchema.safeParse(data); + if (parseResult.success) { + conditions.push(parseResult.data.conditions); + parseResult.data.elements?.forEach(getElementsConditions); + } else if (data && typeof data === 'object') { + Object.keys(data).forEach((key) => getElementsConditions(data[key])); + } + }; + config?.elements?.forEach(getElementsConditions); + return conditions; + }; + + const conditions = getAllConditions(); + this._hasHAStateConditions = conditions.some( + (condition) => !!condition.state?.length, + ); + conditions.forEach((condition) => { + if (condition.media_query) { + const mql = window.matchMedia(condition.media_query); + mql.addEventListener('change', this._mediaQueryTrigger); + this._mediaQueries.push(mql); + } + }); + } + + public setState(state: Partial): void { + this._state = { + ...this._state, + ...state, + }; + this._triggerChange(); + } + + public hasHAStateConditions(): boolean { + return this._hasHAStateConditions; + } + + public getEpoch(): ConditionsManagerEpoch { + return this._epoch; + } + + public evaluateCondition( + condition: Readonly, + stateOverrides?: Partial, + ): boolean { + const state = { + ...this._state, + ...stateOverrides, + }; + + let result = true; + if (condition.view?.length) { + result &&= !!state?.view && condition.view.includes(state.view); + } + if (condition.fullscreen !== undefined) { + result &&= + state.fullscreen !== undefined && condition.fullscreen === state.fullscreen; + } + if (condition.expand !== undefined) { + result &&= state.expand !== undefined && condition.expand === state.expand; + } + if (condition.camera?.length) { + result &&= !!state.camera && condition.camera.includes(state.camera); + } + if (condition.state?.length) { + for (const stateTest of condition.state) { + result &&= + !!state.state && + ((!stateTest.state && !stateTest.state_not) || + (stateTest.entity in state.state && + (!stateTest.state || + state.state[stateTest.entity].state === stateTest.state) && + (!stateTest.state_not || + state.state[stateTest.entity].state !== stateTest.state_not))); + } + } + if (condition.media_loaded !== undefined) { + result &&= + state.media_loaded !== undefined && + condition.media_loaded === state.media_loaded; + } + if (condition.media_query) { + result &&= window.matchMedia(condition.media_query).matches; + } + if (condition.display_mode) { + result &&= !!state.displayMode && condition.display_mode === state.displayMode; + } + return result; + } + + protected _createEpoch(): ConditionsManagerEpoch { + return { manager: this }; + } + + protected _triggerChange(): void { + this._epoch = this._createEpoch(); + this._listeners.forEach((listener) => listener()); + } +} diff --git a/src/card-controller/config-manager.ts b/src/card-controller/config-manager.ts new file mode 100644 index 00000000..8684037b --- /dev/null +++ b/src/card-controller/config-manager.ts @@ -0,0 +1,147 @@ +import isEqual from 'lodash-es/isEqual'; +import { isConfigUpgradeable } from '../config-mgmt'; +import { + CardWideConfig, + FrigateCardConfig, + frigateCardConfigSchema, + RawFrigateCardConfig +} from '../config/types'; +import { localize } from '../localize/localize'; +import { setLowPerformanceProfile } from '../performance.js'; +import { getParseErrorPaths } from '../utils/zod.js'; +import { getOverriddenConfig } from './conditions-manager'; +import { InitializationAspect } from './initialization-manager'; +import { CardConfigAPI } from './types'; + +export class ConfigManager { + protected _api: CardConfigAPI; + + // The main base configuration object. For most usecases use getConfig() to + // get the correct configuration (which will return overrides as appropriate). + // This variable must be called `_config` or `config` to be compatible with + // card-mod. + protected _config: FrigateCardConfig | null = null; + protected _overriddenConfig: FrigateCardConfig | null = null; + protected _rawConfig: RawFrigateCardConfig | null = null; + protected _cardWideConfig: CardWideConfig | null = null; + + constructor(api) { + this._api = api; + } + + public hasConfig(): boolean { + return !!this.getConfig(); + } + + public getConfig(): FrigateCardConfig | null { + return this._overriddenConfig ?? this._config; + } + + public getCardWideConfig(): CardWideConfig | null { + return this._cardWideConfig; + } + + public getNonOverriddenConfig(): FrigateCardConfig | null { + return this._config; + } + + public getRawConfig(): RawFrigateCardConfig | null { + return this._rawConfig; + } + + public setConfig(inputConfig?: RawFrigateCardConfig): void { + if (!inputConfig) { + throw new Error(localize('error.invalid_configuration')); + } + + const parseResult = frigateCardConfigSchema.safeParse(inputConfig); + if (!parseResult.success) { + const configUpgradeable = isConfigUpgradeable(inputConfig); + const hint = getParseErrorPaths(parseResult.error); + let upgradeMessage = ''; + if (configUpgradeable) { + upgradeMessage = `${localize('error.upgrade_available')}. `; + } + throw new Error( + upgradeMessage + + `${localize('error.invalid_configuration')}: ` + + (hint && hint.size + ? JSON.stringify([...hint], null, ' ') + : localize('error.invalid_configuration_no_hint')), + ); + } + const config = + parseResult.data.performance.profile !== 'low' + ? parseResult.data + : setLowPerformanceProfile(inputConfig, parseResult.data); + + this._rawConfig = inputConfig; + if (isEqual(this._config, config)) { + return; + } + + this._config = config; + this._cardWideConfig = { + performance: config.performance, + debug: config.debug, + }; + + this._api.getConditionsManager().setConditionsFromConfig(); + this._api.getConditionsManager().setState({ + view: undefined, + displayMode: undefined, + camera: undefined, + }); + this._api.getMediaLoadedInfoManager().clear(); + this._api.getViewManager().reset(); + this._api.getMessageManager().reset(); + this._api.getAutomationsManager().setAutomationsFromConfig(); + this._api.getStyleManager().setPerformance(); + this._api.getCardElementManager().update(); + + this.computeOverrideConfig(); + } + + public computeOverrideConfig(): void { + const conditionsManager = this._api.getConditionsManager(); + if (!this._config) { + return; + } + const overriddenConfig = getOverriddenConfig( + conditionsManager, + this._config, + this._config.overrides, + ) as FrigateCardConfig; + + // Save on Lit re-rendering costs by only updating the configuration if it + // actually changes. + if (isEqual(overriddenConfig, this._overriddenConfig)) { + return; + } + + const previousConfig = this._overriddenConfig; + this._overriddenConfig = overriddenConfig; + + this._api.getStyleManager().setMinMaxHeight(); + + if ( + previousConfig && + (!isEqual(previousConfig?.cameras, this._overriddenConfig?.cameras) || + !isEqual(previousConfig?.cameras_global, this._overriddenConfig?.cameras_global)) + ) { + this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); + } + + if ( + previousConfig && + previousConfig?.live.microphone.always_connected !== + this._overriddenConfig?.live.microphone.always_connected + ) { + this._api + .getInitializationManager() + .uninitialize(InitializationAspect.MICROPHONE_CONNECT); + } + + this._api.getCardElementManager().update(); + } +} diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts new file mode 100644 index 00000000..50a0ca47 --- /dev/null +++ b/src/card-controller/controller.ts @@ -0,0 +1,259 @@ +import { LovelaceCardEditor } from 'custom-card-helpers'; +import { ReactiveController } from 'lit'; +import { CameraManager } from '../camera-manager/manager'; +import { FrigateCardConfig } from '../config/types'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { EntityCache } from '../utils/ha/entity-registry/cache'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { ActionsManager } from './actions-manager'; +import { AutoUpdateManager } from './auto-update-manager'; +import { AutomationsManager } from './automations-manager'; +import { CameraURLManager } from './camera-url-manager'; +import { + CardElementManager, + CardHTMLElement, + MenuToggleCallback, + ScrollCallback, +} from './card-element-manager'; +import { ConditionsManager, ConditionsManagerListener } from './conditions-manager'; +import { ConfigManager } from './config-manager'; +import { DownloadManager } from './download-manager'; +import { ExpandManager } from './expand-manager'; +import { FullscreenManager } from './fullscreen-manager'; +import { HASSManager } from './hass-manager'; +import { InitializationManager } from './initialization-manager'; +import { InteractionManager } from './interaction-manager'; +import { MediaLoadedInfoManager } from './media-info-manager'; +import { MediaPlayerManager } from './media-player-manager'; +import { MessageManager } from './message-manager'; +import { MicrophoneManager } from './microphone-manager'; +import { QueryStringManager } from './query-string-manager'; +import { StyleManager } from './style-manager'; +import { TriggersManager } from './triggers-manager'; +import { + CardActionsManagerAPI, + CardAutoRefreshAPI, + CardAutomationsAPI, + CardCameraAPI, + CardCameraURLAPI, + CardConditionAPI, + CardConfigAPI, + CardDownloadAPI, + CardElementAPI, + CardExpandAPI, + CardFullscreenAPI, + CardHASSAPI, + CardInitializerAPI, + CardInteractionAPI, + CardMediaLoadedAPI, + CardMediaPlayerAPI, + CardMessageAPI, + CardMicrophoneAPI, + CardQueryStringAPI, + CardStyleAPI, + CardTriggersAPI, + CardViewAPI, +} from './types'; +import { ViewManager } from './view-manager'; + +export class CardController + implements + CardActionsManagerAPI, + CardAutomationsAPI, + CardAutoRefreshAPI, + CardCameraAPI, + CardCameraURLAPI, + CardConditionAPI, + CardConfigAPI, + CardDownloadAPI, + CardElementAPI, + CardExpandAPI, + CardFullscreenAPI, + CardHASSAPI, + CardInitializerAPI, + CardInteractionAPI, + CardMediaLoadedAPI, + CardMediaPlayerAPI, + CardMessageAPI, + CardMicrophoneAPI, + CardQueryStringAPI, + CardStyleAPI, + CardTriggersAPI, + CardViewAPI, + ReactiveController +{ + // These properties may be used in the construction of 'managers' (and should + // be created first). + protected _entityRegistryManager = new EntityRegistryManager(new EntityCache()); + protected _resolvedMediaCache = new ResolvedMediaCache(); + + protected _actionsManager = new ActionsManager(this); + protected _automationsManager = new AutomationsManager(this); + protected _autoUpdateManager = new AutoUpdateManager(this); + protected _cameraManager = new CameraManager(this); + protected _cameraURLManager = new CameraURLManager(this); + protected _cardElementManager: CardElementManager; + protected _conditionsManager: ConditionsManager; + protected _configManager = new ConfigManager(this); + protected _downloadManager = new DownloadManager(this); + protected _expandManager = new ExpandManager(this); + protected _fullscreenManager = new FullscreenManager(this); + protected _hassManager = new HASSManager(this); + protected _initializationManager = new InitializationManager(this); + protected _interactionManager = new InteractionManager(this); + protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this); + protected _mediaPlayerManager = new MediaPlayerManager(this); + protected _messageManager = new MessageManager(this); + protected _microphoneManager = new MicrophoneManager(this); + protected _queryStringManager = new QueryStringManager(this); + protected _styleManager = new StyleManager(this); + protected _triggersManager = new TriggersManager(this); + protected _viewManager = new ViewManager(this); + + constructor( + host: CardHTMLElement, + scrollCallback: ScrollCallback, + menuToggleCallback: MenuToggleCallback, + conditionListener: ConditionsManagerListener, + ) { + host.addController(this); + + this._conditionsManager = new ConditionsManager(this, conditionListener); + this._cardElementManager = new CardElementManager( + this, + host, + scrollCallback, + menuToggleCallback, + ); + } + + // ************************************************************************* + // Accessors + // ************************************************************************* + + public getActionsManager(): ActionsManager { + return this._actionsManager; + } + + public getAutomationsManager(): AutomationsManager { + return this._automationsManager; + } + + public getAutoUpdateManager(): AutoUpdateManager { + return this._autoUpdateManager; + } + + public getCameraManager(): CameraManager { + return this._cameraManager; + } + + public getCameraURLManager(): CameraURLManager { + return this._cameraURLManager; + } + + public getCardElementManager(): CardElementManager { + return this._cardElementManager; + } + + public getConditionsManager(): ConditionsManager { + return this._conditionsManager; + } + + public static async getConfigElement(): Promise { + await import('../editor.js'); + return document.createElement('frigate-card-editor'); + } + + public getConfigManager(): ConfigManager { + return this._configManager; + } + public getDownloadManager(): DownloadManager { + return this._downloadManager; + } + + public getEntityRegistryManager(): EntityRegistryManager { + return this._entityRegistryManager; + } + + public getExpandManager(): ExpandManager { + return this._expandManager; + } + + public getFullscreenManager(): FullscreenManager { + return this._fullscreenManager; + } + + public getHASSManager(): HASSManager { + return this._hassManager; + } + + public getInitializationManager(): InitializationManager { + return this._initializationManager; + } + + public getInteractionManager(): InteractionManager { + return this._interactionManager; + } + + public getMediaLoadedInfoManager(): MediaLoadedInfoManager { + return this._mediaLoadedInfoManager; + } + + public getMediaPlayerManager(): MediaPlayerManager { + return this._mediaPlayerManager; + } + + public getMessageManager(): MessageManager { + return this._messageManager; + } + + public getMicrophoneManager(): MicrophoneManager { + return this._microphoneManager; + } + + public getQueryStringManager(): QueryStringManager { + return this._queryStringManager; + } + + public getResolvedMediaCache(): ResolvedMediaCache { + return this._resolvedMediaCache; + } + + public static getStubConfig(entities: string[]): FrigateCardConfig { + const cameraEntity = entities.find((element) => element.startsWith('camera.')); + return { + cameras: [ + { + camera_entity: cameraEntity ?? 'camera.demo', + }, + ], + // Need to use 'as unknown' to convince Typescript that this really isn't a + // mistake, despite the miniscule size of the configuration vs the full type + // description. + } as unknown as FrigateCardConfig; + } + + public getStyleManager(): StyleManager { + return this._styleManager; + } + + public getTriggersManager(): TriggersManager { + return this._triggersManager; + } + + public getViewManager(): ViewManager { + return this._viewManager; + } + + // ************************************************************************* + // Handlers + // ************************************************************************* + + public hostConnected(): void { + this.getCardElementManager().elementConnected(); + } + + public hostDisconnected(): void { + this.getCardElementManager().elementDisconnected(); + } +} diff --git a/src/card-controller/download-manager.ts b/src/card-controller/download-manager.ts new file mode 100644 index 00000000..f3dc5274 --- /dev/null +++ b/src/card-controller/download-manager.ts @@ -0,0 +1,41 @@ +import { downloadMedia, downloadURL } from '../utils/download'; +import { generateScreenshotTitle } from '../utils/screenshot'; +import { CardDownloadAPI } from './types'; + +export class DownloadManager { + protected _api: CardDownloadAPI; + + constructor(api: CardDownloadAPI) { + this._api = api; + } + + public async downloadViewerMedia(): Promise { + const media = this._api + .getViewManager() + .getView() + ?.queryResults?.getSelectedResult(); + const hass = this._api.getHASSManager().getHASS(); + + if (!media || !hass) { + return false; + } + + try { + await downloadMedia(hass, this._api.getCameraManager(), media); + } catch (error: unknown) { + this._api.getMessageManager().setErrorIfHigherPriority(error); + return false; + } + return true; + } + + public async downloadScreenshot(): Promise { + const url = await this._api + .getMediaLoadedInfoManager() + .get() + ?.player?.getScreenshotURL(); + if (url) { + downloadURL(url, generateScreenshotTitle(this._api.getViewManager().getView())); + } + } +} diff --git a/src/card-controller/expand-manager.ts b/src/card-controller/expand-manager.ts new file mode 100644 index 00000000..9516a9f0 --- /dev/null +++ b/src/card-controller/expand-manager.ts @@ -0,0 +1,31 @@ +import { CardExpandAPI } from './types'; + +export class ExpandManager { + protected _expanded = false; + protected _api: CardExpandAPI; + + constructor(api: CardExpandAPI) { + this._api = api; + } + + public isExpanded(): boolean { + return this._expanded; + } + + public toggleExpanded(): void { + this.setExpanded(!this._expanded); + } + + public setExpanded(expanded: boolean): void { + if (expanded && this._api.getFullscreenManager().isInFullscreen()) { + // Fullscreen and expanded mode are mutually exclusive. + this._api.getFullscreenManager().stopFullscreen(); + } + + this._expanded = expanded; + this._api.getConditionsManager()?.setState({ + expand: expanded, + }); + this._api.getCardElementManager().update(); + } +} diff --git a/src/card-controller/fullscreen-manager.ts b/src/card-controller/fullscreen-manager.ts new file mode 100644 index 00000000..418bf72f --- /dev/null +++ b/src/card-controller/fullscreen-manager.ts @@ -0,0 +1,47 @@ +import screenfull from 'screenfull'; +import { CardFullscreenAPI } from './types'; + +export class FullscreenManager { + protected _api: CardFullscreenAPI; + + constructor(api: CardFullscreenAPI) { + this._api = api; + } + + public connect(): void { + if (screenfull.isEnabled) { + screenfull.on('change', this._fullscreenHandler); + } + } + + public disconnect(): void { + if (screenfull.isEnabled) { + screenfull.off('change', this._fullscreenHandler); + } + } + + public isInFullscreen(): boolean { + return screenfull.isEnabled && screenfull.isFullscreen; + } + + public toggleFullscreen(): void { + screenfull.toggle(this._api.getCardElementManager().getElement()); + } + + public stopFullscreen(): void { + screenfull.exit(); + } + + protected _fullscreenHandler = (): void => { + this._api.getExpandManager().setExpanded(false); + + this._api.getConditionsManager()?.setState({ + fullscreen: this.isInFullscreen(), + }); + + // Re-render after a change to fullscreen mode to take advantage of + // the expanded screen real-estate (vs staying in aspect-ratio locked + // modes). + this._api.getCardElementManager().update(); + }; +} diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass-manager.ts new file mode 100644 index 00000000..b449be06 --- /dev/null +++ b/src/card-controller/hass-manager.ts @@ -0,0 +1,89 @@ +import { CameraConfig } from '../config/types'; +import { localize } from '../localize/localize'; +import { ExtendedHomeAssistant } from '../types'; +import { hasHAConnectionStateChanged, isHassDifferent } from '../utils/ha'; +import { CardHASSAPI } from './types'; + +export class HASSManager { + protected _hass: ExtendedHomeAssistant | null = null; + protected _api: CardHASSAPI; + + constructor(api: CardHASSAPI) { + this._api = api; + } + + public getHASS(): ExtendedHomeAssistant | null { + return this._hass; + } + + public setHASS(hass: ExtendedHomeAssistant): void { + const getSelectedCameraConfig = (): CameraConfig | null => { + const view = this._api.getViewManager().getView(); + const cameraManager = this._api.getCameraManager(); + + return view && cameraManager + ? cameraManager?.getStore().getCameraConfig(view.camera) ?? null + : null; + }; + + const oldHass = this._hass; + this._hass = hass; + + const selectedCamera = getSelectedCameraConfig(); + + if (hasHAConnectionStateChanged(oldHass, hass)) { + if (!this._hass?.connected) { + this._api.getMessageManager().setMessageIfHigherPriority({ + message: localize('error.reconnecting'), + icon: 'mdi:lan-disconnect', + type: 'connection', + dotdotdot: true, + }); + } else { + this._api.getViewManager().setViewDefault(); + } + } else if ( + // Home Assistant pumps a lot of updates through. Re-rendering the card is + // necessary at times (e.g. to update the 'clip' view as new clips + // arrive), but also is a jarring experience for the user (e.g. if they + // are browsing the mini-gallery). Do not allow re-rendering from a Home + // Assistant update if there's been recent interaction (e.g. clicks on the + // card) or if there is media active playing. + this._isAutomatedViewUpdateAllowed() && + isHassDifferent(this._hass, oldHass, [ + ...(this._api.getConfigManager().getConfig()?.view.update_entities ?? []), + ...(selectedCamera?.triggers.entities ?? []), + ]) + ) { + // If entities being monitored have changed then reset the view to the + // default. + this._api.getViewManager().setViewDefault(); + } else if ( + isHassDifferent(this._hass, oldHass, [ + ...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []), + + // Refresh the card if media player state changes: + // https://github.com/dermotduffy/frigate-hass-card/issues/881 + ...this._api.getMediaPlayerManager().getMediaPlayers(), + ]) + ) { + this._api.getCardElementManager().update(); + } + + this._api.getTriggersManager().updateTriggeredCameras(oldHass); + + if (this._api.getConditionsManager().hasHAStateConditions()) { + this._api.getConditionsManager().setState({ state: this._hass.states }); + } + + // Dark mode may depend on HASS. + this._api.getStyleManager().setLightOrDarkMode(); + } + + protected _isAutomatedViewUpdateAllowed(): boolean { + return ( + this._api.getConfigManager().getConfig()?.view.update_force || + !this._api.getInteractionManager().hasInteraction() + ); + } +} diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts new file mode 100644 index 00000000..c6fa07da --- /dev/null +++ b/src/card-controller/initialization-manager.ts @@ -0,0 +1,134 @@ +import { loadLanguages } from '../localize/localize'; +import { sideLoadHomeAssistantElements } from '../utils/ha'; +import { Initializer } from '../utils/initializer/initializer'; +import { CardInitializerAPI } from './types'; + +export enum InitializationAspect { + LANGUAGES = 'languages', + SIDE_LOAD_ELEMENTS = 'side-load-elements', + MEDIA_PLAYERS = 'media-players', + CAMERAS = 'cameras', + MICROPHONE_CONNECT = 'microphone-connect', +} + +export class InitializationManager { + protected _api: CardInitializerAPI; + protected _initializer; + + constructor(api: CardInitializerAPI, initializer?: Initializer) { + this._api = api; + this._initializer = initializer ?? new Initializer(); + } + + public isInitializedMandatory(): boolean { + return this._initializer.isInitializedMultiple([ + InitializationAspect.LANGUAGES, + InitializationAspect.SIDE_LOAD_ELEMENTS, + InitializationAspect.CAMERAS, + ]); + } + + /** + * Initialize the hard requirements for rendering anything. + * @returns `true` if card rendering can continue. + */ + public async initializeMandatory(): Promise { + const hass = this._api.getHASSManager().getHASS(); + if (!hass) { + return false; + } + + if ( + !(await this._initializer.initializeMultipleIfNecessary({ + // Caution: Ensure nothing in this set of initializers requires + // config or languages since they will not yet have been initialized. + [InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass), + [InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => + await sideLoadHomeAssistantElements(), + })) + ) { + return false; + } + + if (!this._api.getConfigManager().hasConfig()) { + return false; + } + + if ( + !(await this._initializer.initializeIfNecessary( + InitializationAspect.CAMERAS, + async () => await this._api.getCameraManager().initializeCamerasFromConfig(), + )) + ) { + return false; + } + + if (!this._api.getMessageManager().hasMessage()) { + // Set a view on initial load. However, if the query string contains a + // view related action, we don't set any view here and allow that content + // to be triggered by the firstUpdated() call that runs query string + // actions. To do otherwise may cause a race condition between the default + // view and the querystring view, see: + // https://github.com/dermotduffy/frigate-hass-card/issues/1200 + const hasViewRelatedActions = this._api + .getQueryStringManager() + .hasViewRelatedActions(); + if (hasViewRelatedActions) { + this._api.getQueryStringManager().executeViewRelated(); + } else { + this._api.getViewManager().setViewDefault(); + } + } + + this._api.getCardElementManager().update(); + return true; + } + + /** + * Initialize aspects of the card that can load in the 'background'. + * @returns `true` if card rendering can continue. + */ + public async initializeBackgroundIfNecessary(): Promise { + const hass = this._api.getHASSManager().getHASS(); + const config = this._api.getConfigManager().getConfig(); + + if (!hass || !config) { + return false; + } + + if ( + this._initializer.isInitializedMultiple([ + ...(config.menu.buttons.media_player.enabled + ? [InitializationAspect.MEDIA_PLAYERS] + : []), + ...(config.live.microphone.always_connected + ? [InitializationAspect.MICROPHONE_CONNECT] + : []), + ]) + ) { + return true; + } + + if ( + !(await this._initializer.initializeMultipleIfNecessary({ + ...(config.menu.buttons.media_player.enabled && { + [InitializationAspect.MEDIA_PLAYERS]: async () => + await this._api.getMediaPlayerManager().initialize(), + }), + ...(config.live.microphone.always_connected && { + [InitializationAspect.MICROPHONE_CONNECT]: async () => + await this._api.getMicrophoneManager().connect(), + }), + })) + ) { + return false; + } + + this._api.getCardElementManager().update(); + return true; + } + + public uninitialize(aspect: InitializationAspect) { + return this._initializer.uninitialize(aspect); + } +} diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts new file mode 100644 index 00000000..24a473b8 --- /dev/null +++ b/src/card-controller/interaction-manager.ts @@ -0,0 +1,50 @@ +import throttle from 'lodash-es/throttle'; +import { Timer } from '../utils/timer'; +import { CardInteractionAPI } from './types'; + +export class InteractionManager { + protected _timer = new Timer(); + protected _api: CardInteractionAPI; + + constructor(api: CardInteractionAPI) { + this._api = api; + } + + // The mouse handler may be called continually, throttle it to at most once + // per second for performance reasons. + public reportInteraction = throttle(() => { + this._reportInteraction(); + }, 1 * 1000); + + public hasInteraction(): boolean { + return this._timer.isRunning(); + } + + /** + * Start the user interaction ('screensaver') timer to reset the view to + * default `view.timeout_seconds` after user interaction. + */ + protected _reportInteraction(): void { + this._timer.stop(); + + // Interactions reset the trigger state. + this._api.getTriggersManager().untrigger(); + + const timeoutSeconds = this._api.getConfigManager().getConfig() + ?.view.timeout_seconds; + + if (timeoutSeconds) { + this._timer.start(timeoutSeconds, () => { + if (this._isAutomatedUpdateAllowed()) { + this._api.getViewManager().setViewDefault(); + this._api.getStyleManager().setLightOrDarkMode(); + } + }); + } + this._api.getStyleManager().setLightOrDarkMode(); + } + + protected _isAutomatedUpdateAllowed(): boolean { + return !this._api.getTriggersManager().isTriggered(); + } +} diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts new file mode 100644 index 00000000..88dde87a --- /dev/null +++ b/src/card-controller/media-info-manager.ts @@ -0,0 +1,52 @@ +import { MediaLoadedInfo } from '../types'; +import { log } from '../utils/debug'; +import { isValidMediaLoadedInfo } from '../utils/media-info'; +import { CardMediaLoadedAPI } from './types'; + +export class MediaLoadedInfoManager { + protected _api: CardMediaLoadedAPI; + protected _current: MediaLoadedInfo | null = null; + protected _lastKnown: MediaLoadedInfo | null = null; + + constructor(api: CardMediaLoadedAPI) { + this._api = api; + } + + public set(mediaInfo: MediaLoadedInfo): void { + if (!isValidMediaLoadedInfo(mediaInfo)) { + return; + } + + log( + this._api.getConfigManager().getCardWideConfig(), + `Frigate Card media load: `, + mediaInfo, + ); + + this._current = mediaInfo; + this._lastKnown = mediaInfo; + + this._api.getConditionsManager().setState({ media_loaded: true }); + + // Fresh media information may change how the card is rendered. + this._api.getStyleManager().setExpandedMode(); + this._api.getCardElementManager().update(); + } + + public get(): MediaLoadedInfo | null { + return this._current; + } + + public getLastKnown(): MediaLoadedInfo | null { + return this._lastKnown; + } + + public clear(): void { + this._current = null; + this._api.getConditionsManager().setState({ media_loaded: false }); + } + + public has(): boolean { + return !!this._current; + } +} diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts new file mode 100644 index 00000000..34dabdec --- /dev/null +++ b/src/card-controller/media-player-manager.ts @@ -0,0 +1,125 @@ +import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const'; +import { ViewMedia } from '../view/media'; +import { ViewMediaClassifier } from '../view/media-classifier'; +import { errorToConsole } from '../utils/basic'; +import { Entity } from '../utils/ha/entity-registry/types'; +import { supportsFeature } from '../utils/ha/update'; +import { CardMediaPlayerAPI } from './types'; + +export class MediaPlayerManager { + protected _mediaPlayers: string[] = []; + + protected _api: CardMediaPlayerAPI; + + constructor(api: CardMediaPlayerAPI) { + this._api = api; + } + + public getMediaPlayers(): string[] { + return this._mediaPlayers; + } + + public hasMediaPlayers(): boolean { + return this._mediaPlayers.length > 0; + } + + public async initialize(): Promise { + const hass = this._api.getHASSManager().getHASS(); + if (!hass) { + return; + } + + const isValidMediaPlayer = (entityID: string): boolean => { + if (entityID.startsWith('media_player.')) { + const stateObj = hass.states[entityID]; + if ( + stateObj && + stateObj.state !== 'unavailable' && + supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA) + ) { + return true; + } + } + return false; + }; + + const mediaPlayers = Object.keys(hass.states).filter(isValidMediaPlayer); + let mediaPlayerEntities: Map | null = null; + try { + mediaPlayerEntities = await this._api + .getEntityRegistryManager() + .getEntities(hass, mediaPlayers); + } catch (e) { + // Failing to fetch media player information is not considered + // sufficiently serious to block card startup -- it is just logged and we + // move on. + errorToConsole(e as Error); + } + + // Filter out entities that are marked as hidden (this information is not + // available in the HA state, only in the registry). + this._mediaPlayers = mediaPlayers.filter((entityID) => { + // Specifically allow for media players that are not found in the entity registry: + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1016 + const entity = mediaPlayerEntities?.get(entityID); + return !entity || !entity.hidden_by; + }); + } + + public async stop(mediaPlayer: string): Promise { + await this._api + .getHASSManager() + .getHASS() + ?.callService('media_player', 'media_stop', { + entity_id: mediaPlayer, + }); + } + + public async playLive(mediaPlayer: string, cameraID: string): Promise { + const hass = this._api.getHASSManager().getHASS(); + const cameraConfig = this._api + .getCameraManager() + .getStore() + .getCameraConfig(cameraID); + const cameraEntity = cameraConfig?.camera_entity ?? null; + + if (!hass || !cameraEntity) { + return; + } + + const title = + this._api.getCameraManager().getCameraMetadata(cameraID)?.title ?? null; + const thumbnail = hass.states[cameraEntity]?.attributes?.entity_picture ?? null; + + await hass.callService('media_player', 'play_media', { + entity_id: mediaPlayer, + media_content_id: `media-source://camera/${cameraEntity}`, + media_content_type: 'application/vnd.apple.mpegurl', + extra: { + ...(title && { title: title }), + ...(thumbnail && { thumb: thumbnail }), + }, + }); + } + + public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise { + const hass = this._api.getHASSManager().getHASS(); + + if (!hass || !media) { + return; + } + + const title = media.getTitle(); + const thumbnail = media.getThumbnail(); + + await hass.callService('media_player', 'play_media', { + entity_id: mediaPlayer, + media_content_id: media.getContentID(), + media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image', + extra: { + ...(title && { title: title }), + ...(thumbnail && { thumb: thumbnail }), + }, + }); + } +} diff --git a/src/card-controller/message-manager.ts b/src/card-controller/message-manager.ts new file mode 100644 index 00000000..fdc50335 --- /dev/null +++ b/src/card-controller/message-manager.ts @@ -0,0 +1,67 @@ +import { FrigateCardError, MESSAGE_TYPE_PRIORITIES, Message } from '../types'; +import { errorToConsole } from '../utils/basic'; +import { CardMessageAPI } from './types'; + +export class MessageManager { + protected _message: Message | null = null; + protected _api: CardMessageAPI; + + constructor(api: CardMessageAPI) { + this._api = api; + } + + public getMessage(): Message | null { + return this._message; + } + + public hasMessage(): boolean { + return !!this._message; + } + + public hasErrorMessage(): boolean { + return this._message?.type === 'error'; + } + + public reset(): void { + const hadMessage = this.hasMessage(); + this._message = null; + + if (hadMessage) { + this._api.getCardElementManager().update(); + } + } + + public setErrorIfHigherPriority(error: unknown): void { + // This object should accept unknown objects to be able to seamlessly + // process arguments to catch() which can only be unknown/any. + if (!(error instanceof Error)) { + return; + } + + errorToConsole(error); + this.setMessageIfHigherPriority({ + message: error.message, + type: 'error', + ...(error instanceof FrigateCardError && { context: error.context }), + }); + } + + public setMessageIfHigherPriority(message: Message): boolean { + const currentPriority = this._message + ? MESSAGE_TYPE_PRIORITIES[this._message.type] + : 0; + const newPriority = MESSAGE_TYPE_PRIORITIES[message.type]; + + if (this._message && newPriority < currentPriority) { + return false; + } + + this._message = message; + + // When a message is displayed it effectively unloads the media. + this._api.getMediaLoadedInfoManager().clear(); + this._api.getCardElementManager().scrollReset(); + this._api.getCardElementManager().update(); + return true; + } +} diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts new file mode 100644 index 00000000..d2d878b0 --- /dev/null +++ b/src/card-controller/microphone-manager.ts @@ -0,0 +1,107 @@ +import { errorToConsole } from '../utils/basic'; +import { Timer } from '../utils/timer'; +import { CardMicrophoneAPI } from './types'; + +export class MicrophoneManager { + protected _api: CardMicrophoneAPI; + protected _stream?: MediaStream | null; + protected _timer = new Timer(); + + // We keep mute state separate from the stream state so that mute/unmute can + // be expressed before the stream is created -- and when it's create it will + // have the right mute status. + protected _mute = true; + + constructor(api: CardMicrophoneAPI) { + this._api = api; + } + + public async connect(): Promise { + try { + this._stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false, + }); + } catch (e: unknown) { + errorToConsole(e as Error); + this._stream = null; + } + this._setMute(); + } + + public async disconnect(): Promise { + this._stream?.getTracks().forEach((track) => track.stop()); + this._stream = undefined; + + this._api.getCardElementManager().update(); + } + + public getStream(): MediaStream | undefined { + return this._stream ?? undefined; + } + + protected _setMute(): void { + this._stream?.getTracks().forEach((track) => { + track.enabled = !this._mute; + }); + this._startTimer(); + + this._api.getCardElementManager().update(); + } + + public mute(): void { + this._mute = true; + this._setMute(); + } + + public async unmute(): Promise { + const unmute = (): void => { + this._mute = false; + this._setMute(); + }; + + if (!this.isConnected() && !this.isForbidden()) { + // The connect() call is async and make take an arbitrary amount of + // time for the user to grant access to their microphone. With a + // momentary microphone button the mute call (on mouse release) may + // arrive before the connection is even granted, so we unmute first + // before the connection is made, so the mute call on release will not + // be 'overwritten' incorrectly. + unmute(); + await this.connect(); + } else if (this.isConnected()) { + unmute(); + } + } + + public isConnected(): boolean { + return !!this._stream; + } + + public isForbidden(): boolean { + return this._stream === null; + } + + public isMuted(): boolean { + // For safety, this function always returns the stream mute status directly + // (rather the internal state). + return !this._stream || this._stream.getTracks().every((track) => !track.enabled); + } + + protected _startTimer(): void { + const microphoneConfig = this._api.getConfigManager().getConfig() + ?.live.microphone; + + if (microphoneConfig?.always_connected) { + return; + } + + const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0; + + if (disconnectSeconds) { + this._timer.start(disconnectSeconds, () => { + this.disconnect(); + }); + } + } +} diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts new file mode 100644 index 00000000..1dabed43 --- /dev/null +++ b/src/card-controller/query-string-manager.ts @@ -0,0 +1,160 @@ +import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types'; +import { createFrigateCardCustomAction } from '../utils/action.js'; +import { CardQueryStringAPI } from './types'; +import { ViewManagerSetViewParameters } from './view-manager'; + +interface QueryStringViewIntent { + view?: ViewManagerSetViewParameters & { + default?: boolean; + }; + other?: FrigateCardCustomAction[]; +} + +export class QueryStringManager { + protected _api: CardQueryStringAPI; + + constructor(api: CardQueryStringAPI) { + this._api = api; + } + + public hasViewRelatedActions(): boolean { + return !!this._calculateIntent().view; + } + + public executeNonViewRelated(): void { + this._executeNonViewRelated(this._calculateIntent()); + } + + public executeViewRelated(): void { + this._executeViewRelated(this._calculateIntent()); + } + + public executeAll(): void { + const intent = this._calculateIntent(); + this._executeViewRelated(intent); + this._executeNonViewRelated(intent); + } + + protected _executeViewRelated(intent: QueryStringViewIntent): void { + if (intent.view) { + if (intent.view.default) { + this._api.getViewManager().setViewDefault({ + ...(intent.view.cameraID && { cameraID: intent.view.cameraID }), + ...(intent.view.substream && { substream: intent.view.substream }), + }); + } else { + this._api.getViewManager().setViewByParameters({ + ...(intent.view.viewName && { viewName: intent.view.viewName }), + ...(intent.view.cameraID && { cameraID: intent.view.cameraID }), + ...(intent.view.substream && { substream: intent.view.substream }), + }); + } + } + } + + protected _executeNonViewRelated(intent: QueryStringViewIntent): void { + // Only execute non-view actions when the card has rendered at least once. + if (!this._api.getCardElementManager().hasUpdated()) { + return; + } + + intent.other?.forEach((action) => + this._api.getActionsManager().executeAction(action), + ); + } + + protected _calculateIntent(): QueryStringViewIntent { + const result: QueryStringViewIntent = {}; + for (const action of this._getActions()) { + if (this._isViewAction(action)) { + (result.view ??= {}).viewName = action.frigate_card_action; + (result.view ??= {}).default = undefined; + } else if (action.frigate_card_action === 'default') { + (result.view ??= {}).default = true; + (result.view ??= {}).viewName = undefined; + } else if (action.frigate_card_action === 'camera_select') { + (result.view ??= {}).cameraID = action.camera; + } else if (action.frigate_card_action === 'live_substream_select') { + (result.view ??= {}).substream = action.camera; + } else { + (result.other ??= []).push(action); + } + } + return result; + } + + protected _getActions(): FrigateCardCustomAction[] { + const params = new URLSearchParams(window.location.search); + const actions: FrigateCardCustomAction[] = []; + const actionRE = new RegExp( + /^frigate-card-action([.:](?\w+))?[.:](?\w+)/, + ); + for (const [key, value] of params.entries()) { + const match = key.match(actionRE); + if (!match || !match.groups) { + continue; + } + const cardID: string | undefined = match.groups['cardID']; + const action = match.groups['action']; + + let customAction: FrigateCardCustomAction | null = null; + switch (action) { + case 'camera_select': + case 'live_substream_select': + if (value) { + customAction = createFrigateCardCustomAction(action, { + camera: value, + cardID: cardID, + }); + } + break; + case 'camera_ui': + case 'clip': + case 'clips': + case 'default': + case 'diagnostics': + case 'download': + case 'expand': + case 'image': + case 'live': + case 'menu_toggle': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + customAction = createFrigateCardCustomAction(action, { + cardID: cardID, + }); + break; + default: + console.warn( + `Frigate card received unknown card action in query string: ${action}`, + ); + } + if (customAction) { + actions.push(customAction); + } + } + return actions; + } + + protected _isViewAction = ( + action: FrigateCardCustomAction, + ): action is FrigateCardViewAction => { + switch (action.frigate_card_action) { + case 'clip': + case 'clips': + case 'diagnostics': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + return true; + } + return false; + }; +} diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts new file mode 100644 index 00000000..d6185d5e --- /dev/null +++ b/src/card-controller/style-manager.ts @@ -0,0 +1,126 @@ +import { FrigateCardConfig } from '../config/types'; +import { setPerformanceCSSStyles } from '../performance'; +import { View } from '../view/view'; +import { setOrRemoveAttribute } from '../utils/basic'; +import { CardStyleAPI } from './types'; + +export class StyleManager { + protected _api: CardStyleAPI; + + constructor(api: CardStyleAPI) { + this._api = api; + } + + public setLightOrDarkMode(): void { + const config = this._api.getConfigManager().getConfig(); + const isDarkMode = + config?.view.dark_mode === 'on' || + (config?.view.dark_mode === 'auto' && + (!this._api.getInteractionManager().hasInteraction() || + !!this._api.getHASSManager().getHASS()?.themes.darkMode)); + + setOrRemoveAttribute( + this._api.getCardElementManager().getElement(), + isDarkMode, + 'dark', + ); + } + + public setExpandedMode(): void { + const card = this._api.getCardElementManager().getElement(); + const view = this._api.getViewManager().getView(); + + // When a new media loads, set the aspect ratio for when the card is + // expanded/popped-up. This is based exclusively on last media content, + // as dimension configuration does not apply in fullscreen or expanded mode. + const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown(); + card.style.setProperty( + '--frigate-card-expand-aspect-ratio', + view?.isAnyMediaView() && lastKnown + ? `${lastKnown.width} / ${lastKnown.height}` + : 'unset', + ); + // Non-media may have no intrinsic dimensions (or multiple media items in a + // grid) and so we need to explicit request the dialog to use all available + // space. + const isGrid = view?.isGrid(); + card.style.setProperty( + '--frigate-card-expand-width', + !isGrid && view?.isAnyMediaView() + ? 'none' + : 'var(--frigate-card-expand-max-width)', + ); + card.style.setProperty( + '--frigate-card-expand-height', + !isGrid && view?.isAnyMediaView() + ? 'none' + : 'var(--frigate-card-expand-max-height)', + ); + } + + public setMinMaxHeight(): void { + const config = this._api.getConfigManager().getConfig(); + if (config) { + const card = this._api.getCardElementManager().getElement(); + card.style.setProperty('--frigate-card-min-height', config.dimensions.min_height); + card.style.setProperty('--frigate-card-max-height', config.dimensions.max_height); + } + } + + public setPerformance(): void { + setPerformanceCSSStyles( + this._api.getCardElementManager().getElement(), + this._api.getConfigManager().getCardWideConfig()?.performance, + ); + } + + protected _isAspectRatioEnforced( + config: FrigateCardConfig, + view?: View | null, + ): boolean { + const aspectRatioMode = config.dimensions.aspect_ratio_mode; + + // Do not artifically constrain aspect ratio if: + // - It's fullscreen. + // - It's in expanded mode. + // - Aspect ratio enforcement is disabled. + // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the + // gallery) or diagnostics / timeline. + return !( + this._api.getFullscreenManager().isInFullscreen() || + this._api.getExpandManager().isExpanded() || + aspectRatioMode === 'unconstrained' || + (aspectRatioMode === 'dynamic' && + (!view || + view?.isAnyMediaView() || + view?.is('timeline') || + view?.is('diagnostics'))) + ); + } + + /** + * Get the aspect ratio padding required to enforce the aspect ratio (if it is + * required). + * @returns A padding percentage. + */ + public getAspectRatioStyle(): string { + const config = this._api.getConfigManager().getConfig(); + const view = this._api.getViewManager().getView(); + + if (config) { + if (!this._isAspectRatioEnforced(config, view)) { + return 'auto'; + } + + const aspectRatioMode = config.dimensions.aspect_ratio_mode; + + const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown(); + if (lastKnown && aspectRatioMode === 'dynamic') { + return `${lastKnown.width} / ${lastKnown.height}`; + } + + return `${config.dimensions.aspect_ratio[0]} / ${config.dimensions.aspect_ratio[1]}`; + } + return '16 / 9'; + } +} diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts new file mode 100644 index 00000000..95605faf --- /dev/null +++ b/src/card-controller/triggers-manager.ts @@ -0,0 +1,126 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import orderBy from 'lodash-es/orderBy'; +import { getHassDifferences, isTriggeredState } from '../utils/ha'; +import { Timer } from '../utils/timer'; +import { CardTriggersAPI } from './types'; + +export class TriggersManager { + protected _api: CardTriggersAPI; + + protected _triggers: Map = new Map(); + protected _untriggerTimer = new Timer(); + + constructor(api: CardTriggersAPI) { + this._api = api; + } + + public isTriggered(): boolean { + return !!this._triggers.size || this._untriggerTimer.isRunning(); + } + + public updateTriggeredCameras(oldHass?: HomeAssistant | null): boolean { + if (!this._shouldTrackTriggers()) { + return false; + } + + const hass = this._api.getHASSManager().getHASS(); + + const now = new Date(); + let triggerChanges = false; + + const cameras = this._api.getCameraManager().getStore().getVisibleCameras(); + for (const [cameraID, config] of cameras?.entries()) { + const triggerEntities = config.triggers.entities; + const diffs = getHassDifferences(hass, oldHass, triggerEntities, { + stateOnly: true, + }); + const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState)); + const shouldUntrigger = triggerEntities.every( + (entity) => !isTriggeredState(hass?.states[entity]), + ); + if (shouldTrigger) { + this._triggers.set(cameraID, now); + triggerChanges = true; + } else if (shouldUntrigger && this._triggers.has(cameraID)) { + this._triggers.delete(cameraID); + triggerChanges = true; + } + } + + if (triggerChanges) { + const targetCameraID = this._getMostRecentTrigger(); + if (targetCameraID) { + this._triggerAction(targetCameraID); + return true; + } else { + this._startUntriggerTimer(); + } + } + return false; + } + + public untrigger(): void { + const wasTriggered = this.isTriggered(); + this._triggers.clear(); + this._untriggerTimer.stop(); + + if (wasTriggered) { + this._untriggerAction(); + } + } + + protected _triggerAction(cameraID: string): void { + const view = this._api.getViewManager().getView(); + if ( + this._isAutomatedViewUpdateAllowed() && + (view?.camera !== cameraID || !view?.is('live')) + ) { + this._api.getViewManager().setViewByParameters({ + viewName: 'live', + cameraID: cameraID, + }); + } + } + + protected _untriggerAction(): void { + if ( + !this.isTriggered() && + this._isAutomatedViewUpdateAllowed() && + this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset + ) { + this._api.getViewManager().setViewDefault(); + } + } + + protected _isAutomatedViewUpdateAllowed(): boolean { + return ( + this._api.getConfigManager().getConfig()?.view.update_force || + !this._api.getInteractionManager().hasInteraction() + ); + } + + protected _shouldTrackTriggers(): boolean { + return !!this._api.getConfigManager().getConfig()?.view.scan.enabled; + } + + protected _startUntriggerTimer(): void { + this._untriggerTimer.start( + /* istanbul ignore next: the case of config being null here cannot be + reached, as there's no way to have the untrigger call happen without + a config. -- @preserve */ + this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0, + () => { + this._untriggerAction(); + }, + ); + } + + protected _getMostRecentTrigger(): string | null { + const sorted = orderBy( + [...this._triggers.entries()], + (entry) => entry[1].getTime(), + 'desc', + ); + return sorted.length ? sorted[0][0] : null; + } +} diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts new file mode 100644 index 00000000..28f0a115 --- /dev/null +++ b/src/card-controller/types.ts @@ -0,0 +1,214 @@ +import { CameraManager } from '../camera-manager/manager'; +import { ConditionsManager } from './conditions-manager'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { ActionsManager } from './actions-manager'; +import { AutoUpdateManager } from './auto-update-manager'; +import { AutomationsManager } from './automations-manager'; +import { CameraURLManager } from './camera-url-manager'; +import { CardElementManager } from './card-element-manager'; +import { ConfigManager } from './config-manager'; +import { DownloadManager } from './download-manager'; +import { ExpandManager } from './expand-manager'; +import { FullscreenManager } from './fullscreen-manager'; +import { HASSManager } from './hass-manager'; +import { InitializationManager } from './initialization-manager'; +import { InteractionManager } from './interaction-manager'; +import { MediaLoadedInfoManager } from './media-info-manager'; +import { MediaPlayerManager } from './media-player-manager'; +import { MessageManager } from './message-manager'; +import { MicrophoneManager } from './microphone-manager'; +import { StyleManager } from './style-manager'; +import { TriggersManager } from './triggers-manager'; +import { ViewManager } from './view-manager'; +import { QueryStringManager } from './query-string-manager'; + +/** + * This defines a series of limited APIs that various manager helpers use to + * control the card. Explicitly specifying them helps make coupling intentional + * and avoids cyclic importing. + */ + +export interface CardActionsManagerAPI { + getCameraManager(): CameraManager; + getCameraURLManager(): CameraURLManager; + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; + getDownloadManager(): DownloadManager; + getExpandManager(): ExpandManager; + getFullscreenManager(): FullscreenManager; + getHASSManager(): HASSManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMediaPlayerManager(): MediaPlayerManager; + getMessageManager(): MessageManager; + getViewManager(): ViewManager; + getMicrophoneManager(): MicrophoneManager; +} + +export interface CardAutomationsAPI { + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getMessageManager(): MessageManager; +} + +export interface CardAutoRefreshAPI { + getConfigManager(): ConfigManager; + getViewManager(): ViewManager; + getTriggersManager(): TriggersManager; + getInteractionManager(): InteractionManager; +} + +export interface CardCameraAPI { + getConfigManager(): ConfigManager; + getEntityRegistryManager(): EntityRegistryManager; + getResolvedMediaCache(): ResolvedMediaCache; + getHASSManager(): HASSManager; + getMessageManager(): MessageManager; +} + +export interface CardCameraURLAPI { + getCameraManager(): CameraManager; + getViewManager(): ViewManager; +} + +export interface CardConditionAPI { + getAutomationsManager(): AutomationsManager; + getConfigManager(): ConfigManager; +} + +export interface CardConfigAPI { + getAutomationsManager(): AutomationsManager; + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getInitializationManager(): InitializationManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMessageManager(): MessageManager; + getStyleManager(): StyleManager; + getViewManager(): ViewManager; +} + +export interface CardDownloadAPI { + getCameraManager(): CameraManager; + getHASSManager(): HASSManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMessageManager(): MessageManager; + getViewManager(): ViewManager; +} + +export interface CardElementAPI { + getActionsManager(): ActionsManager; + getFullscreenManager(): FullscreenManager; + getInteractionManager(): InteractionManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getQueryStringManager(): QueryStringManager; +} + +export interface CardExpandAPI { + getFullscreenManager(): FullscreenManager; + getConditionsManager(): ConditionsManager; + getCardElementManager(): CardElementManager; +} + +export interface CardFullscreenAPI { + getCardElementManager(): CardElementManager; + getExpandManager(): ExpandManager; + getConditionsManager(): ConditionsManager; + getMediaPlayerManager(): MediaPlayerManager; +} + +export interface CardHASSAPI { + getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; + getInteractionManager(): InteractionManager; + getMediaPlayerManager(): MediaPlayerManager; + getMessageManager(): MessageManager; + getStyleManager(): StyleManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; +} + +export interface CardInitializerAPI { + getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; + getEntityRegistryManager(): EntityRegistryManager; + getHASSManager(): HASSManager; + getMediaPlayerManager(): MediaPlayerManager; + getMessageManager(): MessageManager; + getMicrophoneManager(): MicrophoneManager; + getQueryStringManager(): QueryStringManager; + getResolvedMediaCache(): ResolvedMediaCache; + getViewManager(): ViewManager; +} + +export interface CardInteractionAPI { + getConfigManager(): ConfigManager; + getStyleManager(): StyleManager; + getTriggersManager(): TriggersManager; + getViewManager(): ViewManager; +} + +export interface CardMediaLoadedAPI { + getConditionsManager(): ConditionsManager; + getConfigManager(): ConfigManager; + getCardElementManager(): CardElementManager; + getStyleManager(): StyleManager; +} + +export interface CardMediaPlayerAPI { + getHASSManager(): HASSManager; + getCameraManager(): CameraManager; + getEntityRegistryManager(): EntityRegistryManager; +} + +export interface CardMessageAPI { + getConditionsManager(): ConditionsManager; + getCardElementManager(): CardElementManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; +} + +export interface CardMicrophoneAPI { + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; +} + +export interface CardQueryStringAPI { + getCardElementManager(): CardElementManager; + getViewManager(): ViewManager; + getActionsManager(): ActionsManager; +} + +export interface CardStyleAPI { + getCardElementManager(): CardElementManager; + getConfigManager(): ConfigManager; + getExpandManager(): ExpandManager; + getFullscreenManager(): FullscreenManager; + getHASSManager(): HASSManager; + getInteractionManager(): InteractionManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getViewManager(): ViewManager; +} + +export interface CardTriggersAPI { + getCameraManager(): CameraManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getInteractionManager(): InteractionManager; + getViewManager(): ViewManager; +} + +export interface CardViewAPI { + getAutoUpdateManager(): AutoUpdateManager; + getCameraManager(): CameraManager; + getConfigManager(): ConfigManager; + getHASSManager(): HASSManager; + getMediaLoadedInfoManager(): MediaLoadedInfoManager; + getMessageManager(): MessageManager; + getStyleManager(): StyleManager; + getConditionsManager(): ConditionsManager; + getCardElementManager(): CardElementManager; +} diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts new file mode 100644 index 00000000..3465f710 --- /dev/null +++ b/src/card-controller/view-manager.ts @@ -0,0 +1,286 @@ +import { ViewContext } from 'view'; +import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../config/types'; +import { View } from '../view/view'; +import { getAllDependentCameras } from '../utils/camera'; +import { log } from '../utils/debug'; +import { executeMediaQueryForView } from '../utils/media-to-view'; +import { CardViewAPI } from './types'; + +interface ViewManagerSetViewDefaultParameters { + cameraID?: string; + substream?: string; +} + +export interface ViewManagerSetViewParameters + extends ViewManagerSetViewDefaultParameters { + viewName?: FrigateCardView; +} + +export class ViewManager { + protected _view: View | null = null; + protected _api: CardViewAPI; + + constructor(api: CardViewAPI) { + this._api = api; + } + + public getView(): View | null { + return this._view; + } + + public setView(view: View): void { + this._setView(view); + } + + public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void { + const config = this._api.getConfigManager().getConfig(); + if (config) { + let forceCameraID: string | null = params?.cameraID ?? null; + if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) { + const cameraIDs = [ + ...this._api.getCameraManager().getStore().getVisibleCameraIDs(), + ]; + const currentIndex = cameraIDs.indexOf(this._view.camera); + const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1; + forceCameraID = cameraIDs[targetIndex]; + } + + this.setViewByParameters({ + ...params, + viewName: config.view.default, + ...(forceCameraID && { cameraID: forceCameraID }), + }); + + // Restart the refresh timer, so the default view is refreshed at a fixed + // interval from now (if so configured). + this._api.getAutoUpdateManager().startDefaultViewTimer(); + } + } + + public setViewByParameters(params: ViewManagerSetViewParameters): void { + const config = this._api.getConfigManager().getConfig(); + + if (config) { + let cameraID: string | null = null; + + const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs(); + if (cameras.size) { + if (params?.cameraID && cameras.has(params.cameraID)) { + cameraID = params.cameraID; + } else { + // Reset to the default camera. + cameraID = cameras.keys().next().value; + } + } + const viewName = params?.viewName ?? this._view?.view ?? config.view.default; + if (cameraID && viewName && this.isViewSupportedByCamera(cameraID, viewName)) { + const displayMode = + this._view?.displayMode ?? + this._getDefaultDisplayModeForView(viewName, config); + let view: View = new View({ + view: viewName, + camera: cameraID, + displayMode: displayMode, + }); + if (params.substream) { + view = this._createViewWithSelectedSubstream(view, params.substream); + } + this._setView(view); + } + } + } + + public setViewWithNewContext(context: ViewContext): void { + if (this._view) { + return this._setView(this._view?.clone().mergeInContext(context)); + } + } + + public reset(): void { + this._view = null; + } + + public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise { + const hass = this._api.getHASSManager().getHASS(); + + if (this._view && hass) { + const view = this._view.evolve({ + displayMode: displayMode, + }); + + const cameraCount = this._api + .getCameraManager() + .getStore() + .getVisibleCameraCount(); + const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0; + const generateNewQuery = + view?.query && + queryCameraCount && + ((view.isGrid() && queryCameraCount < cameraCount) || + (!view.isGrid() && queryCameraCount > 1)); + + if (generateNewQuery && view && view.query) { + // If the user requests a grid but the current query does not have a + // query for more than one camera, reset the query results, change the + // existing query to refer to all cameras and execute it to fetch new + // results. + let viewWithNewQuery: View | null = null; + try { + viewWithNewQuery = await executeMediaQueryForView( + this._api.getCameraManager(), + view, + view.query + .clone() + .setQueryCameraIDs( + view.isGrid() + ? this._api.getCameraManager().getStore().getVisibleCameraIDs() + : view.camera, + ), + ); + } catch (e: unknown) { + this._api.getMessageManager().setErrorIfHigherPriority(e); + } + + if (viewWithNewQuery) { + return this._setView(viewWithNewQuery); + } + } else { + return this._setView(view); + } + } + } + + public setViewWithSubstream(substream?: string): void { + if (!this._view) { + return; + } + this._setView( + substream + ? this._createViewWithSelectedSubstream(this._view, substream) + : this._createViewWithNextStream(this._view), + ); + } + + public setViewWithoutSubstream(): void { + const view = this._createViewWithoutSubstream(); + if (view) { + return this._setView(view); + } + } + + public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean { + const capabilities = this._api.getCameraManager().getCameraCapabilities(cameraID); + switch (view) { + case 'live': + case 'image': + case 'diagnostics': + return true; + case 'clip': + case 'clips': + return !!capabilities?.supportsClips; + case 'snapshot': + case 'snapshots': + return !!capabilities?.supportsSnapshots; + case 'recording': + case 'recordings': + return !!capabilities?.supportsRecordings; + case 'timeline': + return !!capabilities?.supportsTimeline; + case 'media': + return ( + !!capabilities?.supportsClips || + !!capabilities?.supportsSnapshots || + !!capabilities?.supportsRecordings + ); + } + } + + protected _getDefaultDisplayModeForView( + viewName: FrigateCardView, + config?: FrigateCardConfig, + ): ViewDisplayMode { + let mode: ViewDisplayMode | null = null; + switch (viewName) { + case 'media': + case 'clip': + case 'recording': + case 'snapshot': + mode = config?.media_viewer.display?.mode ?? null; + break; + case 'live': + mode = config?.live.display?.mode ?? null; + break; + } + return mode ?? 'single'; + } + + protected _setView(view: View): void { + const oldView = this._view; + View.adoptFromViewIfAppropriate(view, oldView); + + log( + this._api.getConfigManager().getCardWideConfig(), + `Frigate Card view change: `, + view.view, + ); + this._view = view; + + if (View.isMajorMediaChange(oldView, view)) { + this._api.getMediaLoadedInfoManager().clear(); + } + + if (oldView?.view !== view.view) { + this._api.getCardElementManager().scrollReset(); + } + + this._api.getMessageManager().reset(); + this._api.getStyleManager().setExpandedMode(); + + this._api.getConditionsManager()?.setState({ + view: view.view, + camera: view.camera, + displayMode: view.displayMode ?? undefined, + }); + this._api.getCardElementManager().update(); + } + + protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View { + const overrides: Map = + baseView?.context?.live?.overrides ?? new Map(); + overrides.set(baseView.camera, substreamID); + return baseView.clone().mergeInContext({ + live: { overrides: overrides }, + }); + } + + protected _createViewWithNextStream(baseView: View): View { + const dependencies = [ + ...getAllDependentCameras(this._api.getCameraManager(), baseView.camera), + ]; + if (dependencies.length <= 1) { + return baseView.clone(); + } + + const view = baseView.clone(); + const overrides: Map = view.context?.live?.overrides ?? new Map(); + const currentOverride = overrides.get(view.camera) ?? view.camera; + const currentIndex = dependencies.indexOf(currentOverride); + const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; + overrides.set(view.camera, dependencies[newIndex]); + view.mergeInContext({ live: { overrides: overrides } }); + + return view; + } + + protected _createViewWithoutSubstream(): View | null { + if (!this._view) { + return null; + } + const view = this._view.clone(); + const overrides: Map | undefined = view.context?.live?.overrides; + if (overrides && overrides.has(view.camera)) { + view.context?.live?.overrides?.delete(view.camera); + } + return view; + } +} diff --git a/tests/card-controller/actions-manager.test.ts b/tests/card-controller/actions-manager.test.ts new file mode 100644 index 00000000..8496f4f0 --- /dev/null +++ b/tests/card-controller/actions-manager.test.ts @@ -0,0 +1,728 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { + ActionType, + FrigateCardCustomAction, + FrigateCardView, + frigateCardCustomActionSchema, +} from '../../src/config/types'; +import { FrigateCardMediaPlayer } from '../../src/types'; +import { + convertActionToFrigateCardCustomAction, + frigateCardHandleActionConfig, + getActionConfigGivenAction, +} from '../../src/utils/action.js'; +import { ActionsManager } from '../../src/card-controller/actions-manager'; +import { + createCardAPI, + createConfig, + createHASS, + createMediaLoadedInfo, + createView, + createViewWithMedia, +} from '../test-utils'; + +vi.mock('../../src/utils/action.js'); +vi.mock('../../src/camera-manager/manager.js'); + +const createAction = ( + action: Record, +): FrigateCardCustomAction | null => { + const result = frigateCardCustomActionSchema.safeParse({ + action: 'custom:frigate-card-action', + ...action, + }); + return result.success ? result.data : null; +}; + +describe('ActionsManager.getMergedActions', () => { + const config = { + view: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '1', + }, + }, + }, + live: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '2', + }, + }, + }, + media_gallery: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '3', + }, + }, + }, + media_viewer: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '4', + }, + }, + }, + image: { + actions: { + tap_action: { + action: 'navigate', + navigation_path: '5', + }, + }, + }, + }; + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('should get no merged actions with a message', () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: 'live' }), + ); + vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + + const manager = new ActionsManager(api); + + expect(manager.getMergedActions()).toEqual({}); + }); + + describe('should get merged actions with live view', () => { + it.each([ + [ + 'live' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '2', + }, + }, + ], + [ + 'clips' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '3', + }, + }, + ], + [ + 'clip' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '4', + }, + }, + ], + [ + 'image' as const, + { + tap_action: { + action: 'navigate', + navigation_path: '5', + }, + }, + ], + ['timeline' as const, {}], + ])('%s', (viewName: FrigateCardView, result: Record) => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ view: viewName }), + ); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); + + const manager = new ActionsManager(api); + + expect(manager.getMergedActions()).toEqual(result); + }); + }); +}); + +// @vitest-environment jsdom +describe('ActionsManager.handleInteraction', () => { + it('should handle interaction', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new ActionsManager(api); + + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const actionForThisInteraction: ActionType = { + action: 'none', + }; + vi.mocked(getActionConfigGivenAction).mockReturnValue(actionForThisInteraction); + + manager.handleInteraction('tap'); + + expect(frigateCardHandleActionConfig).toBeCalledWith( + element, + hass, + manager.getMergedActions(), + 'tap', + actionForThisInteraction, + ); + }); + + it('should not handle interaction', () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + + // No values of hass. + manager.handleInteraction('tap'); + expect(frigateCardHandleActionConfig).not.toBeCalledWith(); + }); +}); + +describe('ActionsManager.handleActionEvent', () => { + it('should handle event', () => { + const action = createAction({ frigate_card_action: 'default' })!; + const event: CustomEvent = new CustomEvent('ll-custom', { + detail: action, + }); + + // The file containing convertActionToFrigateCardCustomAction (action.ts) is + // mocked, so need to provide a value here. + vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(action); + + const api = createCardAPI(); + const manager = new ActionsManager(api); + + manager.handleActionEvent(event); + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('should not handle event without detail', () => { + const action = createAction({ frigate_card_action: 'default' })!; + const event = new Event('ll-custom'); + + // Mock this out just so that if the sentinel in handleActionEvent failed, + // it would still trigger a test failure below. + vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(action); + + const api = createCardAPI(); + const manager = new ActionsManager(api); + manager.handleActionEvent(event); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should not handle malformed action', () => { + const action = createAction({ frigate_card_action: 'default' })!; + const event: CustomEvent = new CustomEvent('ll-custom', { + detail: action, + }); + + vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(null); + + const api = createCardAPI(); + const manager = new ActionsManager(api); + manager.handleActionEvent(event); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); +}); + +describe('ActionsManager.executeAction', () => { + it('should not handle actions with different card_id', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + card_id: 'foo', + }), + ); + + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + card_id: 'NOT_foo', + frigate_card_action: 'default', + })!, + ); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should handle default action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'default', + })!, + ); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + describe('should handle view action', async () => { + it.each([ + ['clip' as const], + ['clips' as const], + ['image' as const], + ['live' as const], + ['recording' as const], + ['recordings' as const], + ['snapshot' as const], + ['snapshots' as const], + ['timeline' as const], + ])('%s', async (viewName: FrigateCardView) => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: viewName, + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: viewName, + }), + ); + }); + }); + + it('should handle download action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'download', + })!, + ); + + expect(api.getDownloadManager().downloadViewerMedia).toBeCalled(); + }); + + it('should handle camera ui action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'camera_ui', + })!, + ); + + expect(api.getCameraURLManager().openURL).toBeCalled(); + }); + + it('should handle expand action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'expand', + })!, + ); + + expect(api.getExpandManager().toggleExpanded).toBeCalled(); + }); + + it('should handle fullscreen action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'fullscreen', + })!, + ); + + expect(api.getFullscreenManager().toggleFullscreen).toBeCalled(); + }); + + it('should handle menu toggle action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'menu_toggle', + })!, + ); + + expect(api.getCardElementManager().toggleMenu).toBeCalled(); + }); + + describe('should handle camera_select action', () => { + it('with valid camera and view', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + + await manager.executeAction( + createAction({ + frigate_card_action: 'camera_select', + camera: 'camera', + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'live', + cameraID: 'camera', + }), + ); + }); + + it('without config', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'timeline', + }), + ); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + + await manager.executeAction( + createAction({ + frigate_card_action: 'camera_select', + camera: 'camera', + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'timeline', + cameraID: 'camera', + }), + ); + }); + + it('with target view', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + // Change to clips view when the camera changes. + camera_select: 'clips', + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'live', + }), + ); + vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'camera_select', + camera: 'camera', + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'clips', + cameraID: 'camera', + }), + ); + }); + + it('without a current view', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'camera_select', + camera: 'camera', + })!, + ); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('with an unsupported view', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'timeline', + }), + ); + + await manager.executeAction( + createAction({ + frigate_card_action: 'camera_select', + camera: 'camera', + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + // Should have fallen back to the default view. + viewName: 'live', + cameraID: 'camera', + }), + ); + }); + }); + + it('should handle live_substream_select action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'live_substream_select', + camera: 'substream', + })!, + ); + + expect(api.getViewManager().setViewWithSubstream).toBeCalledWith('substream'); + }); + + it('should handle live_substream_off action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'live_substream_off', + })!, + ); + + expect(api.getViewManager().setViewWithoutSubstream).toBeCalled(); + }); + + it('should handle live_substream_on action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'live_substream_on', + })!, + ); + + expect(api.getViewManager().setViewWithSubstream).toBeCalledWith(); + }); + + describe('should handle media_player action', () => { + it('to stop', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'media_player', + media_player_action: 'stop', + media_player: 'this_is_a_media_player', + })!, + ); + + expect(api.getMediaPlayerManager().stop).toBeCalledWith('this_is_a_media_player'); + }); + + it('to play live', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera', + view: 'live', + }), + ); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + })!, + ); + + expect(api.getMediaPlayerManager().playLive).toBeCalledWith( + 'this_is_a_media_player', + 'camera', + ); + }); + + it('to play media', async () => { + const api = createCardAPI(); + const view = createViewWithMedia({ + camera: 'camera', + view: 'media', + }); + + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + })!, + ); + + expect(api.getMediaPlayerManager().playMedia).toBeCalledWith( + 'this_is_a_media_player', + view.queryResults?.getSelectedResult(), + ); + }); + + it('to play media without selected media', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + view: 'media', + }), + ); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'media_player', + media_player_action: 'play', + media_player: 'this_is_a_media_player', + })!, + ); + + expect(api.getMediaPlayerManager().playMedia).not.toBeCalled(); + }); + }); + + it('should handle diagnostics action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'diagnostics', + })!, + ); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith( + expect.objectContaining({ + viewName: 'diagnostics', + }), + ); + }); + + it('should handle microphone_mute action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'microphone_mute', + })!, + ); + + expect(api.getMicrophoneManager().mute).toBeCalled(); + }); + + it('should handle microphone_unmute action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'microphone_unmute', + })!, + ); + + expect(api.getMicrophoneManager().unmute).toBeCalled(); + }); + + describe('should handle media player action', () => { + it.each([ + ['mute' as const], + ['unmute' as const], + ['play' as const], + ['pause' as const], + ])('%s', async (action: 'mute' | 'unmute' | 'play' | 'pause') => { + const api = createCardAPI(); + const player = mock(); + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: action, + })!, + ); + + expect(player[action]).toBeCalled(); + }); + }); + + it('should handle screenshot action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'screenshot', + })!, + ); + + expect(api.getDownloadManager().downloadScreenshot).toBeCalled(); + }); + + it('should handle display_mode_select action', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeAction( + createAction({ + frigate_card_action: 'display_mode_select', + display_mode: 'grid', + })!, + ); + + expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid'); + }); + + it('should handle unknown action', async () => { + const manager = new ActionsManager(createCardAPI()); + + const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + await manager.executeAction( + // Have to manually create the action (vs using `createAction()`) since + // it's malformed. + { + frigate_card_action: 'not_a_real_action', + } as unknown as FrigateCardCustomAction, + ); + + expect(spy).toBeCalledWith( + 'Frigate card received unknown card action: not_a_real_action', + ); + }); +}); diff --git a/tests/card-controller/auto-update-manager.test.ts b/tests/card-controller/auto-update-manager.test.ts new file mode 100644 index 00000000..2f1c2e2c --- /dev/null +++ b/tests/card-controller/auto-update-manager.test.ts @@ -0,0 +1,73 @@ +import add from 'date-fns/add'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; +import { createCardAPI, createConfig } from '../test-utils'; + +// @vitest-environment jsdom +describe('AutoUpdateManager', () => { + const start = new Date('2023-09-23T19:12:00'); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should set default view when allowed', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + update_seconds: 10, + }, + }), + ); + // Card is triggered. + vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + vi.useFakeTimers(); + vi.setSystemTime(start); + + const manager = new AutoUpdateManager(api); + manager.startDefaultViewTimer(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); + + vi.setSystemTime(add(start, { seconds: 20 })); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('should not set default view when not configured', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + update_seconds: 0, + }, + }), + ); + vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + + vi.useFakeTimers(); + vi.setSystemTime(start); + + const manager = new AutoUpdateManager(api); + manager.startDefaultViewTimer(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts new file mode 100644 index 00000000..450bb17d --- /dev/null +++ b/tests/card-controller/automations-manager.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { frigateCardHandleAction } from '../../src/utils/action.js'; +import { + AutomationsManager, +} from '../../src/card-controller/automations-manager.js'; +import { createCardAPI, createConfig, createHASS } from '../test-utils.js'; + +vi.mock('../../src/utils/action.js'); + +describe('AutomationsManager', () => { + const actions = [ + { + action: 'custom:frigate-card-action', + frigate_card_action: 'clips', + }, + ]; + const conditions = { fullscreen: true }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should do nothing without hass', () => { + const api = createCardAPI(); + + const automationsManager = new AutomationsManager(api); + automationsManager.execute(); + expect(frigateCardHandleAction).not.toBeCalled(); + }); + + it('should do nothing without automations', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const automationsManager = new AutomationsManager(api); + automationsManager.setAutomationsFromConfig(); + automationsManager.execute(); + expect(frigateCardHandleAction).not.toBeCalled(); + }); + + it('should execute actions', () => { + const config = createConfig({ + automations: [ + { + conditions: conditions, + actions: actions, + }, + ], + }); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config); + + const automationsManager = new AutomationsManager(api); + automationsManager.setAutomationsFromConfig(); + + automationsManager.execute(); + expect(frigateCardHandleAction).not.toBeCalled(); + + vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(true); + + automationsManager.execute(); + expect(frigateCardHandleAction).toBeCalledTimes(1); + + // Automation will not re-fire when condition continues to evaluate the + // same. + automationsManager.execute(); + expect(frigateCardHandleAction).toBeCalledTimes(1); + + vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(false); + + automationsManager.execute(); + expect(frigateCardHandleAction).toBeCalledTimes(1); + + vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(true); + + automationsManager.execute(); + expect(frigateCardHandleAction).toBeCalledTimes(2); + }); + + it('should execute actions_not', () => { + const config = createConfig({ + automations: [ + { + conditions: conditions, + actions_not: actions, + }, + ], + }); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config); + vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(false); + + const automationsManager = new AutomationsManager(api); + automationsManager.setAutomationsFromConfig(); + + automationsManager.execute(); + + expect(frigateCardHandleAction).toBeCalled(); + }); + + it('should prevent automation loops', () => { + const config = createConfig({ + automations: [ + { + conditions: { fullscreen: true }, + actions: actions, + }, + { + conditions: { fullscreen: true }, + actions_not: actions, + }, + ], + }); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config); + + const automationsManager = new AutomationsManager(api); + automationsManager.setAutomationsFromConfig(); + + // Create a setup where one automation action causes another... + let evaluation = true; + vi.mocked(frigateCardHandleAction).mockImplementation(() => { + evaluation = !evaluation; + vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue( + evaluation, + ); + automationsManager.execute(); + }); + + vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(evaluation); + + automationsManager.execute(); + + expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( + expect.objectContaining({ + type: 'error', + message: + 'Too many nested automation calls, please check your configuration for loops', + }), + ); + + expect(frigateCardHandleAction).toBeCalledTimes(10); + }); +}); diff --git a/tests/card-controller/camera-url-manager.test.ts b/tests/card-controller/camera-url-manager.test.ts new file mode 100644 index 00000000..881af96a --- /dev/null +++ b/tests/card-controller/camera-url-manager.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CameraEndpoint } from '../../src/camera-manager/types'; +import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; +import { + CardCameraURLAPI +} from '../../src/card-controller/types'; +import { createCardAPI, createViewWithMedia } from '../test-utils'; + +const createAPIWithMedia = (): CardCameraURLAPI => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createViewWithMedia() + ) + return api; +}; + +// @vitest-environment jsdom +describe('CameraURLManager', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should get URL', () => { + const api = createAPIWithMedia(); + const manager = new CameraURLManager(api); + + const endpoint: CameraEndpoint = { + endpoint: 'http://frigate', + }; + + vi.mocked(api.getCameraManager().getCameraEndpoints)?.mockReturnValue({ + ui: endpoint, + }); + + expect(manager.getCameraURL()).toBe('http://frigate'); + expect(manager.hasCameraURL()).toBeTruthy(); + + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + manager.openURL(); + expect(windowSpy).toBeCalledWith('http://frigate'); + }); + + it('should not get URL without view', () => { + const manager = new CameraURLManager(createCardAPI()); + expect(manager.getCameraURL()).toBeNull(); + + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + manager.openURL(); + expect(windowSpy).not.toBeCalled(); + }); + + it('should not get URL without cameraManager endpoints', () => { + const api = createAPIWithMedia(); + vi.mocked(api.getCameraManager().getCameraEndpoints)?.mockReturnValue(null); + const manager = new CameraURLManager(api); + expect(manager.getCameraURL()).toBeNull(); + }); +}); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts new file mode 100644 index 00000000..a9eb5f40 --- /dev/null +++ b/tests/card-controller/card-element-manager.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CardElementManager, + CardHTMLElement, +} from '../../src/card-controller/card-element-manager'; +import { createCardAPI } from '../test-utils'; +import { mock } from 'vitest-mock-extended'; + +const createElement = (): CardHTMLElement => { + const element = document.createElement('div') as unknown as CardHTMLElement; + element.requestUpdate = vi.fn(); + return element as CardHTMLElement; +}; + +// @vitest-environment jsdom +describe('CardElementManager', () => { + afterEach(() => { + vi.unstubAllGlobals(); + global.window.location = mock(); + }); + + it('should get element', () => { + const element = createElement(); + const manager = new CardElementManager( + createCardAPI(), + element, + () => undefined, + () => undefined, + ); + + expect(manager.getElement()).toBe(element); + }); + + it('should reset scroll', () => { + const callback = vi.fn(); + const manager = new CardElementManager( + createCardAPI(), + createElement(), + callback, + () => undefined, + ); + + manager.scrollReset(); + + expect(callback).toBeCalled(); + }); + + it('should toggle menu', () => { + const callback = vi.fn(); + const manager = new CardElementManager( + createCardAPI(), + createElement(), + () => undefined, + callback, + ); + + manager.toggleMenu(); + + expect(callback).toBeCalled(); + }); + + it('should update', () => { + const element = createElement(); + const manager = new CardElementManager( + createCardAPI(), + element, + () => undefined, + () => undefined, + ); + + manager.update(); + expect(element.requestUpdate).toBeCalled(); + }); + + it('should get hasUpdated', () => { + const element = createElement(); + element.hasUpdated = true; + const manager = new CardElementManager( + createCardAPI(), + element, + () => undefined, + () => undefined, + ); + + expect(manager.hasUpdated()).toBeTruthy(); + }); + + it('should get height', () => { + const element = createElement(); + element.getBoundingClientRect = vi.fn().mockReturnValue({ + width: 200, + height: 800, + }); + + const manager = new CardElementManager( + createCardAPI(), + element, + () => undefined, + () => undefined, + ); + + expect(manager.getCardHeight()).toBe(800); + }); + + it('should connect', () => { + const windowAddEventListener = vi.spyOn(global.window, 'addEventListener'); + + const addEventListener = vi.fn(); + const element = createElement(); + element.addEventListener = addEventListener; + + const api = createCardAPI(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementConnected(); + + expect(element.getAttribute('panel')).toBeNull(); + expect(api.getFullscreenManager().connect).toBeCalled(); + + expect(addEventListener).toBeCalledWith( + 'mousemove', + api.getInteractionManager().reportInteraction, + ); + expect(addEventListener).toBeCalledWith( + 'll-custom', + api.getActionsManager().handleActionEvent, + ); + expect(addEventListener).toBeCalledWith( + '@action', + api.getInteractionManager().reportInteraction, + ); + expect(windowAddEventListener).toBeCalledWith('location-changed', expect.anything()); + expect(windowAddEventListener).toBeCalledWith('popstate', expect.anything()); + }); + + it('should disconnect', () => { + const windowRemoveEventListener = vi.spyOn(global.window, 'removeEventListener'); + + const element = createElement(); + element.setAttribute('panel', ''); + + const removeEventListener = vi.fn(); + element.removeEventListener = removeEventListener; + + const api = createCardAPI(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementDisconnected(); + + expect(element.getAttribute('panel')).toBeNull(); + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getFullscreenManager().disconnect).toBeCalled(); + + expect(removeEventListener).toBeCalledWith( + 'mousemove', + api.getInteractionManager().reportInteraction, + ); + expect(removeEventListener).toBeCalledWith( + 'll-custom', + api.getActionsManager().handleActionEvent, + ); + expect(removeEventListener).toBeCalledWith( + '@action', + api.getInteractionManager().reportInteraction, + ); + expect(windowRemoveEventListener).toBeCalledWith( + 'location-changed', + expect.anything(), + ); + expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything()); + }); +}); diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts new file mode 100644 index 00000000..148c0dda --- /dev/null +++ b/tests/card-controller/conditions-manager.test.ts @@ -0,0 +1,363 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FrigateCardCondition } from '../../src/config/types'; +import { + ConditionEvaluateRequestEvent, + ConditionsManager, + evaluateConditionViaEvent, + getOverriddenConfig, + getOverridesByKey, +} from '../../src/card-controller/conditions-manager'; +import { + createCardAPI, + createCondition, + createConfig, + createStateEntity, +} from '../test-utils'; + +// @vitest-environment jsdom +describe('ConditionEvaluateRequestEvent', () => { + it('should construct', () => { + const condition = createCondition({ fullscreen: true }); + const event = new ConditionEvaluateRequestEvent(condition, { + bubbles: true, + composed: true, + }); + + expect(event.type).toBe('frigate-card:condition:evaluate'); + expect(event.condition).toBe(condition); + expect(event.bubbles).toBeTruthy(); + expect(event.composed).toBeTruthy(); + }); +}); + +describe('evaluateConditionViaEvent', () => { + it('should evaluate true without condition', () => { + const element = document.createElement('div'); + expect(evaluateConditionViaEvent(element)).toBeTruthy(); + }); + it('should dispatch event with condition and evaluate true', () => { + const element = document.createElement('div'); + const condition = createCondition({ fullscreen: true }); + const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => { + expect(ev.condition).toBe(condition); + ev.evaluation = true; + }); + element.addEventListener('frigate-card:condition:evaluate', handler); + + expect(evaluateConditionViaEvent(element, condition)).toBeTruthy(); + expect(handler).toBeCalled(); + }); + it('should dispatch event with condition and evaluate false', () => { + const element = document.createElement('div'); + const condition = createCondition({ fullscreen: true }); + const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => { + expect(ev.condition).toBe(condition); + ev.evaluation = false; + }); + element.addEventListener('frigate-card:condition:evaluate', handler); + + expect(evaluateConditionViaEvent(element, condition)).toBeFalsy(); + expect(handler).toBeCalled(); + }); + it('should dispatch event evaluate false if no evaluation', () => { + const element = document.createElement('div'); + const condition = createCondition({ fullscreen: true }); + const handler = vi.fn(); + element.addEventListener('frigate-card:condition:evaluate', handler); + + expect(evaluateConditionViaEvent(element, condition)).toBeFalsy(); + expect(handler).toBeCalled(); + }); +}); + +describe('getOverriddenConfig', () => { + const config = { + menu: { + style: 'none', + }, + }; + const overrides = [ + { + overrides: { + menu: { + style: 'above', + }, + }, + conditions: { + fullscreen: true, + }, + }, + ]; + + it('should not override config', () => { + const manager = new ConditionsManager(createCardAPI()); + expect(getOverriddenConfig(manager, config, overrides)).toBe(config); + }); + + it('should override config', () => { + const manager = new ConditionsManager(createCardAPI()); + manager.setState({ fullscreen: true }); + + expect(getOverriddenConfig(manager, config, overrides)).toEqual({ + menu: { + style: 'above', + }, + }); + }); + + it('should do nothing without overrides', () => { + const manager = new ConditionsManager(createCardAPI()); + manager.setState({ fullscreen: true }); + + expect(getOverriddenConfig(manager, config)).toBe(config); + }); +}); + +describe('getOverridesByKey', () => { + const condition = { + fullscreen: true, + }; + const override = { + menu: { + style: 'above', + }, + }; + const overrides = [ + { + overrides: override, + conditions: condition, + }, + ]; + + it('should get overrides', () => { + expect(getOverridesByKey('menu', overrides)).toEqual([ + { conditions: condition, overrides: { style: 'above' } }, + ]); + }); + + it('should get no overrides', () => { + expect(getOverridesByKey('live', overrides)).toEqual([]); + }); + + it('should get no overrides when undefined', () => { + expect(getOverridesByKey('live')).toEqual([]); + }); +}); + +describe('ConditionsManager', () => { + const config = { + type: 'custom:frigate-card', + cameras: [], + elements: [ + { + type: 'custom:frigate-card-conditional', + conditions: { + fullscreen: true, + }, + elements: [ + { + type: 'custom:nested-unknown-object', + unknown_key: { + type: 'custom:frigate-card-conditional', + conditions: { + media_query: 'media query goes here', + }, + elements: [], + }, + }, + ], + }, + ], + overrides: [ + { + overrides: { + menu: { + style: 'overlay', + }, + }, + conditions: { + fullscreen: true, + state: [ + { + entity: 'binary_sensor.foo', + state: 'on', + }, + ], + }, + }, + ], + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should get epoch', () => { + const manager = new ConditionsManager(createCardAPI()); + const epoch_1 = manager.getEpoch(); + expect(epoch_1).toEqual({ manager: manager }); + + manager.setState({ fullscreen: true }); + + const epoch_2 = manager.getEpoch(); + expect(epoch_2).toEqual({ manager: manager }); + + // Since the state was set the wrappers should be different. + expect(epoch_1).not.toBe(epoch_2); + }); + + it('should not return hasHAStateConditions without HA state conditions', () => { + const manager = new ConditionsManager(createCardAPI()); + expect(manager.hasHAStateConditions()).toBeFalsy(); + }); + + it('should return hasHAStateConditions with HA state conditions', () => { + vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ + matches: false, + addEventListener: vi.fn(), + } as unknown as MediaQueryList); + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); + const manager = new ConditionsManager(api); + + manager.setConditionsFromConfig(); + + expect(manager.hasHAStateConditions()).toBeTruthy(); + }); + + it('should evaluate conditions with a view', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { view: ['foo'] }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ view: 'foo' }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('should evaluate conditions with fullscreen', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { fullscreen: true }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ fullscreen: true }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ fullscreen: false }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should evaluate conditions with expand', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { expand: true }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ expand: true }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ expand: false }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should evaluate conditions with camera', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { camera: ['bar'] }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ camera: 'bar' }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ camera: 'will-not-match' }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should evaluate conditions with ha state positive check', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + state: [ + { + entity: 'binary_sensor.foo', + state: 'on', + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should evaluate conditions with ha state negative check', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + state: [ + { + entity: 'binary_sensor.foo', + state_not: 'on', + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('should evaluate conditions with media_loaded', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { media_loaded: true }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ media_loaded: true }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ media_loaded: false }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should evaluate conditions with media query', () => { + vi.spyOn(window, 'matchMedia') + .mockReturnValueOnce({ matches: true }) + .mockReturnValueOnce({ matches: false }); + + const manager = new ConditionsManager(createCardAPI()); + const condition = { media_query: 'whatever' }; + expect(manager.evaluateCondition(condition)).toBeTruthy(); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('should trigger on changes to media query conditions', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ + matches: true, + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList); + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); + const callback = vi.fn(); + const manager = new ConditionsManager(api, callback); + + manager.setConditionsFromConfig(); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + + // Call the media query callback and use it to pretend a match happened. The + // callback is the 0th mock innvocation and the 1st argument. + addEventListener.mock.calls[0][1](); + + // This should result in a callback to our state listener. + expect(callback).toBeCalled(); + + // Remove the conditions, which should remove the media query listener. + manager.removeConditions(); + expect(removeEventListener).toBeCalled(); + }); + + it('should evaluate conditions with display mode', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { display_mode: 'grid' }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ displayMode: 'grid' }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ displayMode: 'single' }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); +}); diff --git a/tests/card-controller/config-manager.test.ts b/tests/card-controller/config-manager.test.ts new file mode 100644 index 00000000..6d063b27 --- /dev/null +++ b/tests/card-controller/config-manager.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ZodError } from 'zod'; +import { frigateCardConfigSchema } from '../../src/config/types'; +import { getOverriddenConfig } from '../../src/card-controller/conditions-manager'; +import { ConfigManager } from '../../src/card-controller/config-manager'; +import { InitializationAspect } from '../../src/card-controller/initialization-manager'; +import { createCardAPI, createConfig } from '../test-utils'; + +vi.mock('../../src/card-controller/conditions-manager.js'); + +describe('ConfigManager', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('should handle error when', () => { + it('no input', () => { + const manager = new ConfigManager(createCardAPI()); + expect(() => manager.setConfig()).toThrowError(/Invalid configuration/); + }); + + it('invalid configuration', () => { + const spy = vi.spyOn(frigateCardConfigSchema, 'safeParse').mockReturnValue({ + success: false, + error: new ZodError([]), + }); + + const manager = new ConfigManager(createCardAPI()); + expect(() => manager.setConfig({})).toThrowError( + 'Invalid configuration: No location hint available (bad or missing type?)', + ); + + spy.mockRestore(); + }); + + it('invalid configuration with hint', () => { + const manager = new ConfigManager(createCardAPI()); + expect(() => manager.setConfig({})).toThrowError( + 'Invalid configuration: [\n "cameras",\n "type"\n]', + ); + }); + + it('upgradeable', () => { + const manager = new ConfigManager(createCardAPI()); + expect(() => + manager.setConfig({ + cameras: [ + { + frigate: { + label: 'foo', + }, + }, + ], + }), + ).toThrowError( + 'An automated card configuration upgrade is ' + + 'available, please visit the visual card editor. ' + + 'Invalid configuration: [\n "type"\n]', + ); + }); + }); + + it('should have initial state', () => { + const manager = new ConfigManager(createCardAPI()); + + expect(manager.getConfig()).toBeNull(); + expect(manager.getNonOverriddenConfig()).toBeNull(); + expect(manager.getRawConfig()).toBeNull(); + }); + + it('should successfully parse basic config', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + + manager.setConfig(config); + + expect(manager.hasConfig()).toBeTruthy() + expect(manager.getRawConfig()).toBe(config); + + // Verify at least the camera is set. + expect(manager.getConfig()?.cameras[0].camera_entity).toBe('camera.office'); + + // Verify at least one default was set. + expect(manager.getConfig()?.menu.alignment).toBe('left'); + + // Verify appropriate API calls are made. + expect(api.getConditionsManager().setConditionsFromConfig).toBeCalled(); + expect(api.getConditionsManager().setState).toBeCalledWith({ + view: undefined, + displayMode: undefined, + camera: undefined, + }); + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getViewManager().reset).toBeCalled(); + expect(api.getMessageManager().reset).toBeCalled(); + expect(api.getAutomationsManager().setAutomationsFromConfig).toBeCalled(); + expect(api.getStyleManager().setPerformance).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should apply low performance defaults', () => { + const manager = new ConfigManager(createCardAPI()); + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + performance: { profile: 'low' }, + }; + + manager.setConfig(config); + + // Verify at least one low performance default. + expect(manager.getConfig()?.live.draggable).toBeFalsy(); + }); + + it('should skip identical configs', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + + manager.setConfig(config); + expect(api.getViewManager().reset).toBeCalled(); + + vi.mocked(api.getViewManager().reset).mockClear(); + + manager.setConfig(config); + expect(api.getViewManager().reset).not.toBeCalled(); + }); + + it('should get card wide config', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + debug: { + logging: true, + }, + performance: { + profile: 'low', + }, + }; + + manager.setConfig(config); + + expect(manager.getCardWideConfig()).toEqual({ + debug: { + logging: true, + }, + performance: { + features: { + animated_progress_indicator: false, + media_chunk_size: 10, + }, + profile: 'low', + style: { + border_radius: false, + box_shadow: false, + }, + }, + }); + }); + + it('should ignore overrides without a config', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + + manager.computeOverrideConfig(); + + expect(manager.getConfig()).toBeNull(); + expect(api.getStyleManager().setMinMaxHeight).not.toBeCalled(); + }); + + it('should ignore overrides with same config', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + vi.mocked(getOverriddenConfig).mockReturnValue(config); + + manager.setConfig(config); + expect(api.getStyleManager().setMinMaxHeight).toBeCalled(); + + vi.mocked(api.getStyleManager().setMinMaxHeight).mockClear(); + manager.computeOverrideConfig(); + + expect(api.getStyleManager().setMinMaxHeight).not.toBeCalled(); + }); + + it('should override', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config_1 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + manager.setConfig(config_1); + vi.mocked(api.getStyleManager().setMinMaxHeight).mockClear(); + + const config_2 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.kitchen' }], + }; + vi.mocked(getOverriddenConfig).mockReturnValue(config_2); + manager.computeOverrideConfig(); + + expect(api.getStyleManager().setMinMaxHeight).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig()); + }); + + describe('should uninitialize on override', () => { + it('cameras', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config_1 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); + + manager.setConfig(config_1); + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + + const config_2 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.kitchen' }], + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); + manager.computeOverrideConfig(); + + expect(api.getInitializationManager().uninitialize).toBeCalledWith( + InitializationAspect.CAMERAS, + ); + }); + + it('cameras_global', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config_1 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); + + manager.setConfig(config_1); + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + + const config_2 = { + ...config_1, + cameras_global: { + live_provider: 'jsmpeg' + } + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); + manager.computeOverrideConfig(); + + expect(api.getInitializationManager().uninitialize).toBeCalledWith( + InitializationAspect.CAMERAS, + ); + }); + + it('live.microphone.always_connected', () => { + const api = createCardAPI(); + const manager = new ConfigManager(api); + const config_1 = { + type: 'custom:frigate-card', + cameras: [{ camera_entity: 'camera.office' }], + live: { + microphone: { + always_connected: false + } + } + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1)); + + manager.setConfig(config_1); + expect(api.getInitializationManager().uninitialize).not.toBeCalled(); + + const config_2 = { + ...config_1, + live: { + microphone: { + always_connected: true + } + } + }; + vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2)); + manager.computeOverrideConfig(); + + expect(api.getInitializationManager().uninitialize).toBeCalledWith( + InitializationAspect.MICROPHONE_CONNECT, + ); + }); + }); +}); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts new file mode 100644 index 00000000..b8e6b87b --- /dev/null +++ b/tests/card-controller/controller.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CameraManager } from '../../src/camera-manager/manager'; +import { FrigateCardEditor } from '../../src/editor'; +import { ActionsManager } from '../../src/card-controller/actions-manager'; +import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager'; +import { AutomationsManager } from '../../src/card-controller/automations-manager'; +import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; +import { + CardElementManager, + CardHTMLElement, +} from '../../src/card-controller/card-element-manager'; +import { ConditionsManager } from '../../src/card-controller/conditions-manager'; +import { ConfigManager } from '../../src/card-controller/config-manager'; +import { CardController } from '../../src/card-controller/controller'; +import { DownloadManager } from '../../src/card-controller/download-manager'; +import { ExpandManager } from '../../src/card-controller/expand-manager'; +import { FullscreenManager } from '../../src/card-controller/fullscreen-manager'; +import { HASSManager } from '../../src/card-controller/hass-manager'; +import { InitializationManager } from '../../src/card-controller/initialization-manager'; +import { InteractionManager } from '../../src/card-controller/interaction-manager'; +import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager'; +import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; +import { MessageManager } from '../../src/card-controller/message-manager'; +import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; +import { QueryStringManager } from '../../src/card-controller/query-string-manager'; +import { StyleManager } from '../../src/card-controller/style-manager'; +import { TriggersManager } from '../../src/card-controller/triggers-manager'; +import { ViewManager } from '../../src/card-controller/view-manager'; +import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; +import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; + +vi.mock('../../src/camera-manager/manager'); +vi.mock('../../src/card-controller/actions-manager'); +vi.mock('../../src/card-controller/auto-update-manager'); +vi.mock('../../src/card-controller/automations-manager'); +vi.mock('../../src/card-controller/camera-url-manager'); +vi.mock('../../src/card-controller/card-element-manager'); +vi.mock('../../src/card-controller/conditions-manager'); +vi.mock('../../src/card-controller/config-manager'); +vi.mock('../../src/card-controller/download-manager'); +vi.mock('../../src/card-controller/expand-manager'); +vi.mock('../../src/card-controller/fullscreen-manager'); +vi.mock('../../src/card-controller/hass-manager'); +vi.mock('../../src/card-controller/initialization-manager'); +vi.mock('../../src/card-controller/interaction-manager'); +vi.mock('../../src/card-controller/media-info-manager'); +vi.mock('../../src/card-controller/media-player-manager'); +vi.mock('../../src/card-controller/message-manager'); +vi.mock('../../src/card-controller/microphone-manager'); +vi.mock('../../src/card-controller/query-string-manager'); +vi.mock('../../src/card-controller/style-manager'); +vi.mock('../../src/card-controller/triggers-manager'); +vi.mock('../../src/card-controller/view-manager'); +vi.mock('../../src/utils/ha/entity-registry'); +vi.mock('../../src/utils/ha/resolved-media'); + +const createCardElement = (): CardHTMLElement => { + const element = document.createElement('div') as unknown as CardHTMLElement; + element.addController = vi.fn(); + return element; +}; + +const createController = (): CardController => { + return new CardController(createCardElement(), vi.fn(), vi.fn(), vi.fn()); +}; + +// @vitest-environment jsdom +describe('CardController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should construct correctly', () => { + const element = createCardElement(); + const scrollCallback = vi.fn(); + const menuToggleCallback = vi.fn(); + const conditionListener = vi.fn(); + + const manager = new CardController( + element, + scrollCallback, + menuToggleCallback, + conditionListener, + ); + + expect(ConditionsManager).toBeCalledWith(manager, conditionListener); + expect(CardElementManager).toBeCalledWith( + manager, + element, + scrollCallback, + menuToggleCallback, + ); + }); + + describe('accessors', () => { + it('getActionsManager', () => { + expect(createController().getActionsManager()).toBe( + vi.mocked(ActionsManager).mock.instances[0], + ); + }); + + it('getAutomationsManager', () => { + expect(createController().getAutomationsManager()).toBe( + vi.mocked(AutomationsManager).mock.instances[0], + ); + }); + + it('getAutoUpdateManager', () => { + expect(createController().getAutoUpdateManager()).toBe( + vi.mocked(AutoUpdateManager).mock.instances[0], + ); + }); + + it('getCameraManager', () => { + expect(createController().getCameraManager()).toBe( + vi.mocked(CameraManager).mock.instances[0], + ); + }); + + it('getCameraURLManager', () => { + expect(createController().getCameraURLManager()).toBe( + vi.mocked(CameraURLManager).mock.instances[0], + ); + }); + + it('getCardElementManager', () => { + expect(createController().getCardElementManager()).toBe( + vi.mocked(CardElementManager).mock.instances[0], + ); + }); + + it('getConditionsManager', () => { + expect(createController().getConditionsManager()).toBe( + vi.mocked(ConditionsManager).mock.instances[0], + ); + }); + + it('getConfigElement', async () => { + expect((await CardController.getConfigElement()) instanceof FrigateCardEditor); + }); + + it('getConfigManager', () => { + expect(createController().getConfigManager()).toBe( + vi.mocked(ConfigManager).mock.instances[0], + ); + }); + + it('getDownloadManager', () => { + expect(createController().getDownloadManager()).toBe( + vi.mocked(DownloadManager).mock.instances[0], + ); + }); + + it('getEntityRegistryManager', () => { + expect(createController().getEntityRegistryManager()).toBe( + vi.mocked(EntityRegistryManager).mock.instances[0], + ); + }); + + it('getExpandManager', () => { + expect(createController().getExpandManager()).toBe( + vi.mocked(ExpandManager).mock.instances[0], + ); + }); + + it('getFullscreenManager', () => { + expect(createController().getFullscreenManager()).toBe( + vi.mocked(FullscreenManager).mock.instances[0], + ); + }); + + it('getHASSManager', () => { + expect(createController().getHASSManager()).toBe( + vi.mocked(HASSManager).mock.instances[0], + ); + }); + + it('getInitializationManager', () => { + expect(createController().getInitializationManager()).toBe( + vi.mocked(InitializationManager).mock.instances[0], + ); + }); + + it('getInteractionManager', () => { + expect(createController().getInteractionManager()).toBe( + vi.mocked(InteractionManager).mock.instances[0], + ); + }); + + it('getMediaLoadedInfoManager', () => { + expect(createController().getMediaLoadedInfoManager()).toBe( + vi.mocked(MediaLoadedInfoManager).mock.instances[0], + ); + }); + + it('getMediaPlayerManager', () => { + expect(createController().getMediaPlayerManager()).toBe( + vi.mocked(MediaPlayerManager).mock.instances[0], + ); + }); + + it('getMessageManager', () => { + expect(createController().getMessageManager()).toBe( + vi.mocked(MessageManager).mock.instances[0], + ); + }); + + it('getMicrophoneManager', () => { + expect(createController().getMicrophoneManager()).toBe( + vi.mocked(MicrophoneManager).mock.instances[0], + ); + }); + + it('getResolvedMediaCache', () => { + expect(createController().getResolvedMediaCache()).toBe( + vi.mocked(ResolvedMediaCache).mock.instances[0], + ); + }); + + describe('getStubConfig', () => { + it('with camera entities', () => { + expect( + CardController.getStubConfig(['camera.office', 'binary_sensor.motion']), + ).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + }); + }); + + it('without camera entities', () => { + expect(CardController.getStubConfig(['binary_sensor.motion'])).toEqual({ + cameras: [{ camera_entity: 'camera.demo' }], + }); + }); + }); + + it('getQueryStringManager', () => { + expect(createController().getQueryStringManager()).toBe( + vi.mocked(QueryStringManager).mock.instances[0], + ); + }); + + it('getStyleManager', () => { + expect(createController().getStyleManager()).toBe( + vi.mocked(StyleManager).mock.instances[0], + ); + }); + + it('getTriggersManager', () => { + expect(createController().getTriggersManager()).toBe( + vi.mocked(TriggersManager).mock.instances[0], + ); + }); + + it('getViewManager', () => { + expect(createController().getViewManager()).toBe( + vi.mocked(ViewManager).mock.instances[0], + ); + }); + }); + + describe('handlers', () => { + it('hostConnected', () => { + createController().hostConnected(); + expect( + vi.mocked(CardElementManager).mock.instances[0].elementConnected, + ).toBeCalled(); + }); + + it('hostDisconnected', () => { + createController().hostDisconnected(); + expect( + vi.mocked(CardElementManager).mock.instances[0].elementDisconnected, + ).toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/download-manager.test.ts b/tests/card-controller/download-manager.test.ts new file mode 100644 index 00000000..59427a12 --- /dev/null +++ b/tests/card-controller/download-manager.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { FrigateCardMediaPlayer } from '../../src/types'; +import { DownloadManager } from '../../src/card-controller/download-manager'; +import { downloadMedia, downloadURL } from '../../src/utils/download.js'; +import { + createCardAPI, + createHASS, + createMediaLoadedInfo, + createViewWithMedia, +} from '../test-utils'; + +vi.mock('../../src/utils/download.js'); + +describe('DownloadManager.downloadViewerMedia', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should download', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createViewWithMedia()); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = new DownloadManager(api); + + expect(await manager.downloadViewerMedia()).toBeTruthy(); + expect(downloadMedia).toBeCalledWith( + api.getHASSManager().getHASS(), + api.getCameraManager(), + api.getViewManager().getView()?.queryResults?.getResult(0), + ); + }); + + it('should not download due to exception thrown', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createViewWithMedia()); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = new DownloadManager(api); + + const error = new Error(); + vi.mocked(downloadMedia).mockRejectedValue(error); + + expect(await manager.downloadViewerMedia()).toBeFalsy(); + expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error); + }); + + it('should not download without hass', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(createViewWithMedia()); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + const manager = new DownloadManager(api); + + expect(await manager.downloadViewerMedia()).toBeFalsy(); + expect(downloadMedia).not.toBeCalled(); + }); +}); + +describe('DownloadManager.downloadScreenshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('with url', async () => { + const api = createCardAPI(); + const player = mock(); + player.getScreenshotURL.mockResolvedValue('http://screenshot'); + + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const manager = new DownloadManager(api); + await manager.downloadScreenshot(); + + expect(downloadURL).toBeCalledWith('http://screenshot', 'screenshot.jpg'); + }); + + it('without url', async () => { + const api = createCardAPI(); + const player = mock(); + player.getScreenshotURL.mockResolvedValue(null); + + vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue( + createMediaLoadedInfo({ + player: player, + }), + ); + const manager = new DownloadManager(api); + await manager.downloadScreenshot(); + + expect(downloadURL).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/expand-manager.test.ts b/tests/card-controller/expand-manager.test.ts new file mode 100644 index 00000000..cfbc514e --- /dev/null +++ b/tests/card-controller/expand-manager.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ExpandManager } from '../../src/card-controller/expand-manager'; +import { createCardAPI } from '../test-utils'; + +describe('ExpandManager', () => { + it('should construct', () => { + const api = createCardAPI(); + const manager = new ExpandManager(api); + expect(manager.isExpanded()).toBeFalsy(); + }); + + it('should set expanded', () => { + const api = createCardAPI(); + vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(true); + const manager = new ExpandManager(api); + + manager.setExpanded(true); + + expect(manager.isExpanded()).toBeTruthy(); + expect(api.getFullscreenManager().stopFullscreen).toBeCalled(); + expect(api.getConditionsManager().setState).toBeCalledWith({ expand: true }); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should not exit fullscreen when not in fullscreen', () => { + const api = createCardAPI(); + vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(false); + const manager = new ExpandManager(api); + + manager.setExpanded(true); + + expect(api.getFullscreenManager().stopFullscreen).not.toBeCalled(); + }); + + it('should toggle expanded', () => { + const api = createCardAPI(); + vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(false); + const manager = new ExpandManager(api); + + manager.toggleExpanded(); + expect(manager.isExpanded()).toBeTruthy(); + + manager.toggleExpanded(); + expect(manager.isExpanded()).toBeFalsy(); + }); +}); diff --git a/tests/card-controller/fullscreen-manager.test.ts b/tests/card-controller/fullscreen-manager.test.ts new file mode 100644 index 00000000..7599ae62 --- /dev/null +++ b/tests/card-controller/fullscreen-manager.test.ts @@ -0,0 +1,129 @@ +import screenfull from 'screenfull'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FullscreenManager } from '../../src/card-controller/fullscreen-manager'; +import { createCardAPI } from '../test-utils'; + +vi.mock('screenfull', () => ({ + default: { + exit: vi.fn(), + toggle: vi.fn(), + off: vi.fn(), + on: vi.fn(), + }, +})); + +const setScreenfulEnabled = (enabled: boolean): void => { + Object.defineProperty(screenfull, 'isEnabled', { value: enabled, writable: true }); +}; + +const setScreenfulFullscreen = (fullscreen: boolean): void => { + Object.defineProperty(screenfull, 'isFullscreen', { + value: fullscreen, + writable: true, + }); +}; + +// @vitest-environment jsdom +describe('FullscreenManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should correctly determine whether in fullscreen', () => { + const manager = new FullscreenManager(createCardAPI()); + + setScreenfulEnabled(true); + setScreenfulFullscreen(true); + expect(manager.isInFullscreen()).toBeTruthy(); + + setScreenfulFullscreen(false); + expect(manager.isInFullscreen()).toBeFalsy(); + }); + + it('should toggle fullscreen', () => { + const toggle = vi.mocked(screenfull.toggle); + const element = document.createElement('div') + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue( + element, + ); + const manager = new FullscreenManager(api); + + manager.toggleFullscreen(); + + expect(toggle).toBeCalledWith(element); + }); + + it('should stop fullscreen', () => { + const manager = new FullscreenManager(createCardAPI()); + const exit = vi.mocked(screenfull.exit); + + manager.stopFullscreen(); + + expect(exit).toBeCalled(); + }); + + it('should disconnect', () => { + const manager = new FullscreenManager(createCardAPI()); + const off = vi.mocked(screenfull.off); + + setScreenfulEnabled(true); + + manager.disconnect(); + + expect(off).toBeCalledWith('change', expect.anything()); + }); + + it('should not disconnect when screenfull disabled', () => { + const manager = new FullscreenManager(createCardAPI()); + const off = vi.mocked(screenfull.off); + + setScreenfulEnabled(false); + + manager.disconnect(); + + expect(off).not.toBeCalled(); + }); + + it('should connect', () => { + const manager = new FullscreenManager(createCardAPI()); + const on = vi.mocked(screenfull.on); + + setScreenfulEnabled(true); + + manager.connect(); + + expect(on).toBeCalledWith('change', expect.anything()); + }); + + it('should not connect when screenfull disabled', () => { + const manager = new FullscreenManager(createCardAPI()); + const on = vi.mocked(screenfull.on); + + setScreenfulEnabled(false); + + manager.connect(); + + expect(on).not.toBeCalled(); + }); + + it('should make correct api calls on fullscreen change', () => { + const api = createCardAPI(); + const manager = new FullscreenManager(api); + const on = vi.mocked(screenfull.on); + + setScreenfulEnabled(true); + setScreenfulFullscreen(true); + + manager.connect(); + + expect(on).toBeCalled(); + on.mock.calls[0][1](new Event('fullscreen')); + + expect(api.getExpandManager().setExpanded).toBeCalledWith(false); + expect(api.getConditionsManager().setState).toBeCalledWith({ + fullscreen: true, + }); + expect(api.getCardElementManager().update).toBeCalled(); + }); +}); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass-manager.test.ts new file mode 100644 index 00000000..a79812fa --- /dev/null +++ b/tests/card-controller/hass-manager.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HASSManager } from '../../src/card-controller/hass-manager'; +import { CardHASSAPI } from '../../src/card-controller/types'; +import { + createCameraConfig, + createCameraManager, + createCardAPI, + createConfig, + createHASS, + createStateEntity, + createView, +} from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); + +const createAPIWithoutMediaPlayers = (): CardHASSAPI => { + const api = createCardAPI(); + vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([]); + return api; +}; + +describe('HASSManager', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should have null hass on construction', () => { + const manager = new HASSManager(createCardAPI()); + expect(manager.getHASS()).toBeNull(); + }); + + it('should set light or dark mode upon setting hass', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + + manager.setHASS(createHASS()); + + expect(api.getStyleManager().setLightOrDarkMode).toBeCalled(); + }); + + describe('should set condition manager state', () => { + it('positively', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(true); + + const states = { 'switch.foo': createStateEntity() }; + const hass = createHASS(states); + + manager.setHASS(hass); + + expect(api.getConditionsManager().setState).toBeCalledWith( + expect.objectContaining({ + state: states, + }), + ); + }); + + it('negatively', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(false); + + manager.setHASS(createHASS()); + + expect(api.getConditionsManager().setState).not.toBeCalled(); + }); + }); + + it('should update triggered cameras', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + + const originalHASS = createHASS(); + manager.setHASS(originalHASS); + expect(api.getTriggersManager().updateTriggeredCameras).toBeCalledWith(null); + + manager.setHASS(createHASS()); + expect(api.getTriggersManager().updateTriggeredCameras).toBeCalledWith(originalHASS); + }); + + describe('should handle connection state change when', () => { + it('initially disconnected', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + + const disconnectedHASS = createHASS(); + disconnectedHASS.connected = false; + + manager.setHASS(disconnectedHASS); + + expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( + expect.objectContaining({ + message: 'Reconnecting', + icon: 'mdi:lan-disconnect', + type: 'connection', + dotdotdot: true, + }), + ); + }); + + it('disconnected', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + + manager.setHASS(createHASS()); + + const disconnectedHASS = createHASS(); + disconnectedHASS.connected = false; + manager.setHASS(disconnectedHASS); + + expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith( + expect.objectContaining({ + message: 'Reconnecting', + icon: 'mdi:lan-disconnect', + type: 'connection', + dotdotdot: true, + }), + ); + }); + + it('reconnected', () => { + const api = createAPIWithoutMediaPlayers(); + const manager = new HASSManager(api); + + const disconnectedHASS = createHASS(); + disconnectedHASS.connected = false; + manager.setHASS(disconnectedHASS); + + const reconnectedHASS = createHASS(); + manager.setHASS(reconnectedHASS); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + }); + + describe('should set default view when', () => { + it('selected camera trigger entity changes', () => { + const cameraManager = createCameraManager({ + configs: new Map([ + [ + 'camera.foo', + createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion'], + }, + }), + ], + ]), + }); + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.foo', + }), + ); + + const manager = new HASSManager(api); + const hass = createHASS({ + 'binary_sensor.motion': createStateEntity(), + }); + + manager.setHASS(hass); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('selected camera is unknown', () => { + const cameraManager = createCameraManager({ + configs: new Map([ + [ + 'camera.foo', + createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion'], + }, + }), + ], + ]), + }); + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.UNKNOWN', + }), + ); + + const manager = new HASSManager(api); + const hass = createHASS({ + 'binary_sensor.motion': createStateEntity(), + }); + + manager.setHASS(hass); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('view.update_entities changes', () => { + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + update_entities: ['sensor.force_default_view'], + }, + }), + ); + + const manager = new HASSManager(api); + const hass = createHASS({ + 'sensor.force_default_view': createStateEntity(), + }); + + manager.setHASS(hass); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + }); + + describe('should update card when', () => { + it('render entity changes', () => { + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + render_entities: ['sensor.force_update'], + }, + }), + ); + + const manager = new HASSManager(api); + const hass = createHASS({ + 'sensor.force_update': createStateEntity(), + }); + + manager.setHASS(hass); + + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('media player entity changes', () => { + const api = createCardAPI(); + vi.mocked(api.getMediaPlayerManager().getMediaPlayers).mockReturnValue([ + 'media_player.foo', + ]); + + const manager = new HASSManager(api); + const hass = createHASS({ + 'media_player.foo': createStateEntity(), + }); + + manager.setHASS(hass); + + expect(api.getCardElementManager().update).toBeCalled(); + }); + }); + + it('set view default is not called when there is card interaction', () => { + const api = createAPIWithoutMediaPlayers(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + update_entities: ['sensor.force_default_view'], + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); + + const manager = new HASSManager(api); + const hass = createHASS({ + 'sensor.force_default_view': createStateEntity(), + }); + + manager.setHASS(hass); + + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts new file mode 100644 index 00000000..c9f05c32 --- /dev/null +++ b/tests/card-controller/initialization-manager.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { loadLanguages } from '../../src/localize/localize'; +import { + InitializationAspect, + InitializationManager, +} from '../../src/card-controller/initialization-manager'; +import { sideLoadHomeAssistantElements } from '../../src/utils/ha'; +import { Initializer } from '../../src/utils/initializer/initializer'; +import { createCardAPI, createConfig, createHASS } from '../test-utils'; + +vi.mock('../../src/localize/localize.js'); +vi.mock('../../src/utils/ha/index.js'); + +// @vitest-environment jsdom +describe('InitializationManager', () => { + beforeEach(async () => { + vi.resetAllMocks(); + }); + + it('should not be initialized', () => { + const manager = new InitializationManager(createCardAPI()); + expect(manager.isInitializedMandatory()).toBeFalsy(); + }); + + describe('should initialize mandatory', () => { + it('without hass', async () => { + const manager = new InitializationManager(createCardAPI()); + expect(await manager.initializeMandatory()).toBeFalsy(); + }); + + it('without config', async () => { + const api = createCardAPI(); + const manager = new InitializationManager(api); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + expect(await manager.initializeMandatory()).toBeFalsy(); + }); + + it('successfully', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); + vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false); + vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(false); + const manager = new InitializationManager(api); + + expect(await manager.initializeMandatory()).toBeTruthy(); + + expect(loadLanguages).toBeCalled(); + expect(sideLoadHomeAssistantElements).toBeCalled(); + expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled(); + expect(api.getViewManager().setViewDefault).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('successfully with querystring view', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); + vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false); + vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(true); + const manager = new InitializationManager(api); + + expect(await manager.initializeMandatory()).toBeTruthy(); + + expect(api.getQueryStringManager().executeViewRelated).toBeCalled(); + }); + + it('with message set during initialization', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); + vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + vi.mocked(api.getQueryStringManager().hasViewRelatedActions).mockReturnValue(false); + const manager = new InitializationManager(api); + + expect(await manager.initializeMandatory()).toBeTruthy(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('with languages and side load elements in progress', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + initializer.initializeMultipleIfNecessary.mockResolvedValue(false); + + expect(await manager.initializeMandatory()).toBeFalsy(); + }); + + it('with cameras in progress', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().hasConfig).mockReturnValue(true); + + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + initializer.initializeMultipleIfNecessary.mockResolvedValue(true); + initializer.initializeIfNecessary.mockResolvedValue(false); + + expect(await manager.initializeMandatory()).toBeFalsy(); + }); + }); + + describe('should initialize background', () => { + it('without hass and config', async () => { + const manager = new InitializationManager(createCardAPI()); + expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy(); + }); + + it('successfully with minimal initializers', async () => { + const api = createCardAPI(); + const manager = new InitializationManager(api); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + menu: { + buttons: { + media_player: { + enabled: false, + }, + }, + }, + live: { + microphone: { + always_connected: false, + }, + }, + }), + ); + + expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); + expect(api.getMediaPlayerManager().initialize).not.toBeCalled(); + expect(api.getMicrophoneManager().connect).not.toBeCalled(); + expect(api.getCardElementManager().update).not.toBeCalled(); + }); + + it('successfully with all inititalizers', async () => { + const api = createCardAPI(); + const manager = new InitializationManager(api); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + menu: { + buttons: { + media_player: { + enabled: true, + }, + }, + }, + live: { + microphone: { + always_connected: true, + }, + }, + }), + ); + + expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy(); + expect(api.getMediaPlayerManager().initialize).toBeCalled(); + expect(api.getMicrophoneManager().connect).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('with media player and microphone connect in progress', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + menu: { + buttons: { + media_player: { + enabled: true, + }, + }, + }, + live: { + microphone: { + always_connected: true, + }, + }, + }), + ); + const initializer = mock(); + + const manager = new InitializationManager(api, initializer); + initializer.initializeMultipleIfNecessary.mockResolvedValue(false); + + expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy(); + }); + }); + + it('should uninitialize', () => { + const initializer = mock(); + const manager = new InitializationManager(createCardAPI(), initializer); + + manager.uninitialize(InitializationAspect.CAMERAS); + + expect(initializer.uninitialize).toBeCalledWith(InitializationAspect.CAMERAS); + }); +}); diff --git a/tests/card-controller/interaction-manager.test.ts b/tests/card-controller/interaction-manager.test.ts new file mode 100644 index 00000000..1f024dbf --- /dev/null +++ b/tests/card-controller/interaction-manager.test.ts @@ -0,0 +1,85 @@ +import add from 'date-fns/add'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InteractionManager } from '../../src/card-controller/interaction-manager'; +import { createCardAPI, createConfig } from '../test-utils'; + +vi.mock('lodash-es/throttle', () => ({ + default: vi.fn((fn) => fn), +})); + +// @vitest-environment jsdom +describe('InteractionManager', () => { + const start = new Date('2023-09-24T20:20:00'); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should take action when interaction is reported', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + timeout_seconds: 10, + }, + }), + ); + const manager = new InteractionManager(api); + vi.useFakeTimers(); + vi.setSystemTime(start); + + manager.reportInteraction(); + + expect(api.getTriggersManager().untrigger).toBeCalled(); + expect(manager.hasInteraction()).toBeTruthy(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false); + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('should not take action when triggered', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + timeout_seconds: 10, + }, + }), + ); + const manager = new InteractionManager(api); + vi.useFakeTimers(); + vi.setSystemTime(start); + + manager.reportInteraction(); + + vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true); + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + // First call is blocked by triggers (above), so interaction will report + // true but the default view will not have been set. + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should not take action when not configured', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + timeout_seconds: 0, + }, + }), + ); + const manager = new InteractionManager(api); + + manager.reportInteraction(); + + // First call is blocked by triggers (above), so interaction will report + // true but the default view will not have been set. + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/media-info-manager.test.ts b/tests/card-controller/media-info-manager.test.ts new file mode 100644 index 00000000..fc0e1c9f --- /dev/null +++ b/tests/card-controller/media-info-manager.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-manager'; +import { createCardAPI, createMediaLoadedInfo } from '../test-utils.js'; + +describe('MediaLoadedInfoManager', () => { + it('should set', () => { + const api = createCardAPI(); + const manager = new MediaLoadedInfoManager(api); + const mediaInfo = createMediaLoadedInfo(); + + manager.set(mediaInfo); + + expect(manager.has()).toBeTruthy(); + expect(manager.get()).toBe(mediaInfo); + expect(api.getConditionsManager().setState).toBeCalledWith( + expect.objectContaining({ media_loaded: true }), + ); + expect(api.getStyleManager().setExpandedMode).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should not set invalid media info', () => { + const api = createCardAPI(); + const manager = new MediaLoadedInfoManager(api); + const mediaInfo = createMediaLoadedInfo({ width: 0, height: 0 }); + + manager.set(mediaInfo); + + expect(manager.has()).toBeFalsy(); + expect(manager.get()).toBeNull(); + expect(api.getConditionsManager().setState).not.toBeCalled(); + }); + + it('should get last known', () => { + const api = createCardAPI(); + const manager = new MediaLoadedInfoManager(api); + const mediaInfo = createMediaLoadedInfo(); + + manager.set(mediaInfo); + + expect(manager.has()).toBeTruthy(); + + manager.clear(); + + expect(manager.has()).toBeFalsy(); + expect(manager.getLastKnown()).toBe(mediaInfo); + expect(api.getConditionsManager().setState).toBeCalledWith( + expect.objectContaining({ media_loaded: false }), + ); + }); +}); diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts new file mode 100644 index 00000000..389cc149 --- /dev/null +++ b/tests/card-controller/media-player-manager.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + TestViewMedia, + createCameraConfig, + createCameraManager, + createCardAPI, + createHASS, + createRegistryEntity, + createStateEntity, +} from '../test-utils'; +import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; +import { ExtendedHomeAssistant } from '../../src/types'; +import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../../src/const'; +import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; +import { mock } from 'vitest-mock-extended'; + +vi.mock('../../src/camera-manager/manager.js'); + +const createHASSWithMediaPlayers = (): ExtendedHomeAssistant => { + const attributesSupported = { + supported_features: MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, + }; + const attributesUnsupported = { + supported_features: 0, + }; + + return createHASS({ + 'media_player.ok1': createStateEntity({ + entity_id: 'media_player.ok1', + state: 'on', + attributes: attributesSupported, + }), + 'media_player.ok2': createStateEntity({ + entity_id: 'media_player.ok2', + state: 'on', + attributes: attributesSupported, + }), + 'media_player.ok3': createStateEntity({ + entity_id: 'media_player.ok3', + state: 'on', + attributes: attributesSupported, + }), + 'media_player.unavailable': createStateEntity({ + entity_id: 'media_player.sitting_room', + state: 'unavailable', + attributes: attributesSupported, + }), + 'media_player.unsupported': createStateEntity({ + entity_id: 'media_player.sitting_room', + state: 'on', + attributes: attributesUnsupported, + }), + 'switch.unrelated': createStateEntity({ + entity_id: 'switch.unrelated', + state: 'on', + }), + }); +}; + +describe('MediaPlayerManager', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('should initialize', () => { + it('correctly', async () => { + const entityRegistryManager = mock(); + entityRegistryManager.getEntities.mockResolvedValue( + new Map([ + ['media_player.ok1', createRegistryEntity({ hidden_by: '' })], + ['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })], + ]), + ); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASSWithMediaPlayers(), + ); + vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager); + const manager = new MediaPlayerManager(api); + + await manager.initialize(); + + expect(manager.getMediaPlayers()).toEqual([ + 'media_player.ok1', + 'media_player.ok3', + ]); + expect(manager.hasMediaPlayers()).toBeTruthy(); + }); + + it('without hass', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + const manager = new MediaPlayerManager(api); + + await manager.initialize(); + + expect(manager.getMediaPlayers()).toEqual([]); + expect(manager.hasMediaPlayers()).toBeFalsy(); + }); + + it('even if entity registry call fails', async () => { + const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + const entityRegistryManager = mock(); + entityRegistryManager.getEntities.mockRejectedValue(new Error('message')); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASSWithMediaPlayers(), + ); + vi.mocked(api.getEntityRegistryManager).mockReturnValue(entityRegistryManager); + const manager = new MediaPlayerManager(api); + + await manager.initialize(); + + expect(manager.getMediaPlayers()).toEqual([ + 'media_player.ok1', + 'media_player.ok2', + 'media_player.ok3', + ]); + expect(manager.hasMediaPlayers()).toBeTruthy(); + expect(spy).toBeCalled(); + }); + }); + + it('should stop', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const manager = new MediaPlayerManager(api); + + await manager.stop('media_player.foo'); + + expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( + 'media_player', + 'media_stop', + { + entity_id: 'media_player.foo', + }, + ); + }); + + describe('should play', () => { + describe('live', () => { + it('successfully', async () => { + const api = createCardAPI(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( + createCameraConfig({ + camera_entity: 'camera.foo', + }), + ); + vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ + title: 'camera title', + icon: 'icon', + }); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + const hass = createHASS({ + 'camera.foo': createStateEntity({ + attributes: { + entity_picture: 'http://thumbnail', + }, + }), + }); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + const manager = new MediaPlayerManager(api); + + await manager.playLive('media_player.foo', 'camera'); + + expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( + 'media_player', + 'play_media', + { + entity_id: 'media_player.foo', + media_content_id: 'media-source://camera/camera.foo', + media_content_type: 'application/vnd.apple.mpegurl', + extra: { + title: 'camera title', + thumb: 'http://thumbnail', + }, + }, + ); + }); + + it('without camera_entity', async () => { + const api = createCardAPI(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( + createCameraConfig({}), + ); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = new MediaPlayerManager(api); + + await manager.playLive('media_player.foo', 'camera'); + + expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); + }); + + it('without title and thumbnail', async () => { + const api = createCardAPI(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( + createCameraConfig({ + camera_entity: 'camera.foo', + }), + ); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = new MediaPlayerManager(api); + + await manager.playLive('media_player.foo', 'camera'); + + expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( + 'media_player', + 'play_media', + { + entity_id: 'media_player.foo', + media_content_id: 'media-source://camera/camera.foo', + media_content_type: 'application/vnd.apple.mpegurl', + extra: {}, + }, + ); + }); + }); + + describe('media', () => { + describe('successfully with', () => { + it.each([ + ['clip' as const, 'video' as const], + ['snapshot' as const, 'image' as const], + ])( + '%s', + async (mediaType: 'clip' | 'snapshot', contentType: 'video' | 'image') => { + const media = new TestViewMedia({ + title: 'media title', + thumbnail: 'http://thumbnail', + contentID: 'media-source://contentid', + mediaType: mediaType, + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const manager = new MediaPlayerManager(api); + + await manager.playMedia('media_player.foo', media); + + expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( + 'media_player', + 'play_media', + { + entity_id: 'media_player.foo', + media_content_id: 'media-source://contentid', + media_content_type: contentType, + extra: { + title: 'media title', + thumb: 'http://thumbnail', + }, + }, + ); + }, + ); + }); + + it('without hass', async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + const manager = new MediaPlayerManager(api); + const media = new TestViewMedia(); + + await manager.playMedia('media_player.foo', media); + + // No actual test can be performed here as nothing observable happens. + // This test serves only as code-coverage long-tail. + }); + }); + }); +}); diff --git a/tests/card-controller/message-manager.test.ts b/tests/card-controller/message-manager.test.ts new file mode 100644 index 00000000..da2db8e1 --- /dev/null +++ b/tests/card-controller/message-manager.test.ts @@ -0,0 +1,141 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { FrigateCardError, Message } from '../../src/types'; +import { MessageManager } from '../../src/card-controller/message-manager'; +import { createCardAPI } from '../test-utils'; + +const createMessage = (options?: Partial): Message => { + return { + message: options?.message ?? 'message', + type: options?.type ?? 'info', + ...(!!options?.icon && { icon: options.icon }), + ...(!!options?.context && { context: options.context }), + ...(!!options?.dotdotdot && { dotdotdot: options.dotdotdot }), + }; +}; + +describe('MessageManager', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('should construct', () => { + const manager = new MessageManager(createCardAPI()); + expect(manager.hasMessage()).toBeFalsy(); + expect(manager.getMessage()).toBeNull(); + expect(manager.hasErrorMessage()).toBeFalsy(); + }); + + it('should set info message', () => { + const api = createCardAPI(); + const manager = new MessageManager(api); + const message = createMessage(); + manager.setMessageIfHigherPriority(message); + expect(manager.hasMessage()).toBeTruthy(); + expect(manager.getMessage()).toBe(message); + expect(manager.hasErrorMessage()).toBeFalsy(); + + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getCardElementManager().scrollReset).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should set error message', () => { + const api = createCardAPI(); + const manager = new MessageManager(api); + const message = createMessage({ type: 'error' }); + manager.setMessageIfHigherPriority(message); + expect(manager.hasMessage()).toBeTruthy(); + expect(manager.getMessage()).toBe(message); + expect(manager.hasErrorMessage()).toBeTruthy(); + + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getCardElementManager().scrollReset).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should reset message', () => { + const api = createCardAPI(); + const manager = new MessageManager(api); + + manager.reset(); + expect(manager.hasMessage()).toBeFalsy(); + + const message = createMessage({ type: 'error' }); + manager.setMessageIfHigherPriority(message); + expect(manager.hasMessage()).toBeTruthy(); + + vi.mocked(api.getCardElementManager().update).mockClear(); + manager.reset(); + + expect(manager.hasMessage()).toBeFalsy(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should respect priority', () => { + const api = createCardAPI(); + const manager = new MessageManager(api); + + manager.reset(); + expect(manager.hasMessage()).toBeFalsy(); + + const errorMessage = createMessage({ type: 'error' }); + manager.setMessageIfHigherPriority(errorMessage); + + const infoMessage = createMessage({ type: 'info' }); + manager.setMessageIfHigherPriority(infoMessage); + + expect(manager.getMessage()).toBe(errorMessage); + + const connectionMessage = createMessage({ type: 'connection' }); + manager.setMessageIfHigherPriority(connectionMessage); + + expect(manager.getMessage()).toBe(connectionMessage); + }); + + it('should set FrigateCardError object', () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const api = createCardAPI(); + const manager = new MessageManager(api); + const context = { foo: 'bar' }; + + manager.setErrorIfHigherPriority( + new FrigateCardError('frigate card message', context), + ); + expect(manager.hasMessage()).toBeTruthy(); + expect(manager.getMessage()).toEqual({ + message: 'frigate card message', + type: 'error', + context: context, + }); + + expect(consoleSpy).toBeCalled(); + }); + + it('should set Error object', () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const api = createCardAPI(); + const manager = new MessageManager(api); + + manager.setErrorIfHigherPriority(new Error('generic error message')); + expect(manager.hasMessage()).toBeTruthy(); + expect(manager.getMessage()).toEqual({ + message: 'generic error message', + type: 'error', + }); + + expect(consoleSpy).toBeCalled(); + }); + + it('should not set unknown error type', () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const api = createCardAPI(); + const manager = new MessageManager(api); + + manager.setErrorIfHigherPriority('not_an_error_object'); + expect(manager.hasMessage()).toBeFalsy(); + expect(consoleSpy).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts new file mode 100644 index 00000000..9f6b4f78 --- /dev/null +++ b/tests/card-controller/microphone-manager.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; +import { createCardAPI, createConfig } from '../test-utils'; + +const navigatorMock = { + mediaDevices: { + getUserMedia: vi.fn(), + }, +}; + +// @vitest-environment jsdom +describe('MicrophoneManager', () => { + beforeEach(() => { + vi.stubGlobal('navigator', navigatorMock); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllGlobals; + }); + + const createMockStream = (mute?: boolean): MediaStream => { + const stream = mock(); + const track = mock(); + track.enabled = !mute; + stream.getTracks.mockImplementation(() => [track]); + return stream; + }; + + it('should be muted on creation', () => { + const manager = new MicrophoneManager(createCardAPI()); + expect(manager).toBeTruthy(); + expect(manager.isMuted()).toBeTruthy(); + }); + + it('should be undefined without creation', () => { + const manager = new MicrophoneManager(createCardAPI()); + expect(manager.getStream()).toBeUndefined(); + }); + + it('should connect', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + + const stream = createMockStream(); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(stream); + + await manager.connect(); + + expect(manager.isConnected()).toBeTruthy(); + expect(manager.getStream()).toBe(stream); + expect(manager.isMuted()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should be forbidden when permission denied', async () => { + // Don't actually log messages to the console during the test. + vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error()); + + await manager.connect(); + + expect(manager.isConnected()).toBeFalsy(); + expect(manager.isForbidden()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should mute and unmute', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + await manager.connect(); + expect(manager.isMuted()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + + manager.mute(); + expect(manager.isMuted()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(2); + + await manager.unmute(); + expect(manager.isMuted()).toBeFalsy(); + expect(api.getCardElementManager().update).toBeCalledTimes(3); + }); + + it('should not unmute when microphone forbidden', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(null); + + await manager.connect(); + + expect(manager.isMuted()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + + await manager.unmute(); + expect(manager.isMuted()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + }); + + it('should connect on unmute', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + expect(manager.isConnected()).toBeFalsy(); + + await manager.unmute(); + + expect(manager.isConnected()).toBeTruthy(); + expect(manager.isMuted()).toBeFalsy(); + + expect(api.getCardElementManager().update).toBeCalledTimes(2); + }); + + it('should disconnect', async () => { + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + await manager.connect(); + expect(manager.isConnected()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + + await manager.disconnect(); + expect(manager.isConnected()).toBeFalsy(); + expect(api.getCardElementManager().update).toBeCalledTimes(2); + }); + + it('should automatically disconnect', async () => { + vi.useFakeTimers(); + + const disconnectSeconds = 10; + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + always_connected: false, + disconnect_seconds: disconnectSeconds, + }, + }, + }), + ); + + await manager.connect(); + expect(manager.isConnected()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + + vi.advanceTimersByTime(disconnectSeconds * 1000); + + expect(manager.isConnected()).toBeFalsy(); + expect(api.getCardElementManager().update).toBeCalledTimes(2); + }); + + it('should not automatically disconnect when always connected', async () => { + vi.useFakeTimers(); + + const disconnectSeconds = 10; + const api = createCardAPI(); + const manager = new MicrophoneManager(api); + navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream()); + + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + live: { + microphone: { + always_connected: true, + disconnect_seconds: disconnectSeconds, + }, + }, + }), + ); + + await manager.connect(); + expect(manager.isConnected()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + + vi.advanceTimersByTime(disconnectSeconds * 1000); + + expect(manager.isConnected()).toBeTruthy(); + expect(api.getCardElementManager().update).toBeCalledTimes(1); + }); +}); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts new file mode 100644 index 00000000..330662d4 --- /dev/null +++ b/tests/card-controller/query-string-manager.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCardAPI } from '../test-utils'; +import { QueryStringManager } from '../../src/card-controller/query-string-manager'; +import { mock } from 'vitest-mock-extended'; + +const setQueryString = (qs: string): void => { + const location: Location = mock(); + location.search = qs; + global.window.location = location; +}; + +// @vitest-environment jsdom +describe('QueryStringManager', () => { + beforeEach(() => { + global.window.location = mock(); + }); + + it('should reject malformed query string', () => { + setQueryString('BOGUS_KEY=BOGUS_VALUE'); + const api = createCardAPI(); + vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(manager.hasViewRelatedActions()).toBeFalsy(); + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + describe('should execute view name action from query string', () => { + it.each([ + ['clip' as const], + ['clips' as const], + ['diagnostics' as const], + ['image' as const], + ['live' as const], + ['recording' as const], + ['recordings' as const], + ['snapshot' as const], + ['snapshots' as const], + ['timeline' as const], + ])('%s', (viewName: string) => { + setQueryString(`?frigate-card-action.id.${viewName}=`); + const api = createCardAPI(); + + // View actions do not need the card to have been updated. + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(manager.hasViewRelatedActions()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + viewName: viewName, + }); + }); + }); + + describe('should execute non-view action from query string', () => { + it.each([ + ['camera_ui' as const], + ['download' as const], + ['expand' as const], + ['menu_toggle' as const], + ])('%s', (action: string) => { + setQueryString(`?frigate-card-action.id.${action}=`); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(manager.hasViewRelatedActions()).toBeFalsy(); + expect(api.getActionsManager().executeAction).toBeCalledWith({ + action: 'fire-dom-event', + card_id: 'id', + frigate_card_action: action, + }); + }); + }); + + it('should execute view default action', () => { + setQueryString('?frigate-card-action.id.default='); + const api = createCardAPI(); + // View actions do not need the card to have been updated. + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false); + + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + + expect(manager.hasViewRelatedActions()).toBeTruthy(); + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('should execute camera_select action', () => { + setQueryString('?frigate-card-action.id.camera_select=camera.office'); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + cameraID: 'camera.office', + }); + + expect(manager.hasViewRelatedActions()).toBeTruthy(); + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + it('should execute live_substream_select action', () => { + setQueryString('?frigate-card-action.id.live_substream_select=camera.office_hd'); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + substream: 'camera.office_hd', + }); + + expect(manager.hasViewRelatedActions()).toBeTruthy(); + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + + describe('should ignore action without value', () => { + it.each([['camera_select' as const], ['live_substream_select' as const]])( + '%s', + (action: string) => { + setQueryString(`?frigate-card-action.id.${action}=`); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(manager.hasViewRelatedActions()).toBeFalsy(); + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }, + ); + }); + + it('should handle unknown action', () => { + const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + setQueryString('?frigate-card-action.id.not_an_action=value'); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(manager.hasViewRelatedActions()).toBeFalsy(); + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(consoleSpy).toBeCalled(); + }); + + describe('should execute view name action from query string', () => { + it.each([ + ['clip' as const], + ['clips' as const], + ['diagnostics' as const], + ['image' as const], + ['live' as const], + ['recording' as const], + ['recordings' as const], + ['snapshot' as const], + ['snapshots' as const], + ['timeline' as const], + ])('%s', (viewName: string) => { + setQueryString(`?frigate-card-action.id.${viewName}=`); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(manager.hasViewRelatedActions()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + viewName: viewName, + }); + }); + }); + + describe('should not execute non-view actions without an initial update', () => { + it.each([ + ['camera_ui' as const], + ['download' as const], + ['expand' as const], + ['menu_toggle' as const], + ])('%s', (action: string) => { + setQueryString(`?frigate-card-action.id.${action}=value`); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(false); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(api.getActionsManager().executeAction).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + }); + + describe('should handle conflicting but valid actions', () => { + it('view and default with camera and substream specified', () => { + setQueryString( + '?frigate-card-action.id.clips=' + + '&frigate-card-action.id.live_substream_select=camera.kitchen_hd' + + '&frigate-card-action.id.default=' + + '&frigate-card-action.id.camera_select=camera.kitchen', + ); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(api.getViewManager().setViewDefault).toBeCalledWith({ + cameraID: 'camera.kitchen', + substream: 'camera.kitchen_hd', + }); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('multiple cameras specified', () => { + setQueryString( + '?frigate-card-action.id.camera_select=camera.kitchen' + + '&frigate-card-action.id.camera_select=camera.office', + ); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeAll(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + cameraID: 'camera.office', + }); + }); + }); + + describe('should not execute view related actions', () => { + it.each([ + ['clip' as const], + ['clips' as const], + ['default' as const], + ['diagnostics' as const], + ['image' as const], + ['live' as const], + ['recording' as const], + ['recordings' as const], + ['snapshot' as const], + ['snapshots' as const], + ['timeline' as const], + ])('%s', (viewName: string) => { + setQueryString(`?frigate-card-action.id.${viewName}=`); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeNonViewRelated(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); + }); + + describe('should not execute non-view related actions', () => { + it.each([ + ['camera_ui' as const], + ['download' as const], + ['expand' as const], + ['menu_toggle' as const], + ])('%s', (viewName: string) => { + setQueryString(`?frigate-card-action.id.${viewName}=`); + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); + const manager = new QueryStringManager(api); + + manager.executeViewRelated(); + + expect(api.getActionsManager().executeAction).not.toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/style-manager.test.ts b/tests/card-controller/style-manager.test.ts new file mode 100644 index 00000000..475a3a9f --- /dev/null +++ b/tests/card-controller/style-manager.test.ts @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FrigateCardView } from '../../src/config/types'; +import { setPerformanceCSSStyles } from '../../src/performance'; +import { StyleManager } from '../../src/card-controller/style-manager'; +import { createCardAPI, createConfig, createHASS, createView } from '../test-utils'; + +vi.mock('../../src/performance'); + +// @vitest-environment jsdom +describe('StyleManager', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('setLightOrDarkMode', () => { + it('dark mode unspecified', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new StyleManager(api); + + manager.setLightOrDarkMode(); + + expect(element.getAttribute('dark')).toBeNull(); + }); + + it('dark mode explicitly off', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + dark_mode: 'off', + }, + }), + ); + const manager = new StyleManager(api); + + manager.setLightOrDarkMode(); + + expect(element.getAttribute('dark')).toBeNull(); + }); + + it('dark mode explicitly set', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + dark_mode: 'on', + }, + }), + ); + const manager = new StyleManager(api); + + manager.setLightOrDarkMode(); + + expect(element.getAttribute('dark')).not.toBeNull(); + }); + + it('dark mode auto without interaction', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + dark_mode: 'auto', + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false); + const manager = new StyleManager(api); + + manager.setLightOrDarkMode(); + + expect(element.getAttribute('dark')).not.toBeNull(); + }); + + it('dark mode auto with HA dark mode', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + dark_mode: 'auto', + }, + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true); + const hass = createHASS(); + hass.themes.darkMode = true; + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + const manager = new StyleManager(api); + + manager.setLightOrDarkMode(); + + expect(element.getAttribute('dark')).not.toBeNull(); + }); + }); + + describe('setExpandedMode', () => { + it('with no view or known media', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getMediaLoadedInfoManager().getLastKnown).mockReturnValue(null); + const manager = new StyleManager(api); + + manager.setExpandedMode(); + + expect(element.style.getPropertyValue('--frigate-card-expand-aspect-ratio')).toBe( + 'unset', + ); + expect(element.style.getPropertyValue('--frigate-card-expand-width')).toBe( + 'var(--frigate-card-expand-max-width)', + ); + expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBe( + 'var(--frigate-card-expand-max-height)', + ); + }); + + it('with view but without media', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const view = createView({ view: 'media', displayMode: 'single' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getMediaLoadedInfoManager().getLastKnown).mockReturnValue(null); + const manager = new StyleManager(api); + + manager.setExpandedMode(); + + expect(element.style.getPropertyValue('--frigate-card-expand-aspect-ratio')).toBe( + 'unset', + ); + expect(element.style.getPropertyValue('--frigate-card-expand-width')).toBe('none'); + expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBe( + 'none', + ); + }); + + it('with view and media', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const view = createView({ view: 'media', displayMode: 'single' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getMediaLoadedInfoManager().getLastKnown).mockReturnValue({ + width: 800, + height: 600, + }); + const manager = new StyleManager(api); + + manager.setExpandedMode(); + + expect(element.style.getPropertyValue('--frigate-card-expand-aspect-ratio')).toBe( + '800 / 600', + ); + expect(element.style.getPropertyValue('--frigate-card-expand-width')).toBe('none'); + expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBe( + 'none', + ); + }); + + it('with view and grid display mode', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const view = createView({ view: 'media', displayMode: 'grid' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getMediaLoadedInfoManager().getLastKnown).mockReturnValue({ + width: 800, + height: 600, + }); + const manager = new StyleManager(api); + + manager.setExpandedMode(); + + expect(element.style.getPropertyValue('--frigate-card-expand-aspect-ratio')).toBe( + '800 / 600', + ); + expect(element.style.getPropertyValue('--frigate-card-expand-width')).toBe( + 'var(--frigate-card-expand-max-width)', + ); + expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBe( + 'var(--frigate-card-expand-max-height)', + ); + }); + }); + + describe('setMinMaxHeight', () => { + it('without a config', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const manager = new StyleManager(api); + + manager.setMinMaxHeight(); + + expect(element.style.getPropertyValue('--frigate-card-max-height')).toBeFalsy(); + expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBeFalsy(); + }); + + it('with a config', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + dimensions: { + max_height: '800px', + min_height: '400px', + }, + }), + ); + const manager = new StyleManager(api); + + manager.setMinMaxHeight(); + + expect(element.style.getPropertyValue('--frigate-card-min-height')).toBe('400px'); + expect(element.style.getPropertyValue('--frigate-card-max-height')).toBe('800px'); + }); + }); + + it('setPerformance', () => { + const api = createCardAPI(); + const element = document.createElement('div'); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + const config = createConfig(); + vi.mocked(api.getConfigManager().getCardWideConfig).mockReturnValue({ + performance: config.performance, + }); + const manager = new StyleManager(api); + + manager.setPerformance(); + + expect(setPerformanceCSSStyles).toBeCalledWith(element, config.performance); + }); + + describe('getAspectRatioStyle', () => { + it('without config or view', () => { + const api = createCardAPI(); + const manager = new StyleManager(api); + expect(manager.getAspectRatioStyle()).toBe('16 / 9'); + }); + + it('should be auto with unconstrained aspect ratio', () => { + const api = createCardAPI(); + const view = createView({ view: 'media' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + dimensions: { + aspect_ratio_mode: 'unconstrained', + }, + }), + ); + const manager = new StyleManager(api); + expect(manager.getAspectRatioStyle()).toBe('auto'); + }); + + it('should be auto in fullscreen', () => { + const api = createCardAPI(); + const view = createView({ view: 'media' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(true); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('auto'); + }); + + it('should be auto when expanded', () => { + const api = createCardAPI(); + const view = createView({ view: 'media' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getExpandManager().isExpanded).mockReturnValue(true); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('auto'); + }); + + it('should be auto when there is yet to be a view', () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(null); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('auto'); + }); + + describe('should be auto when dynamic in certain views', () => { + it.each([ + ['clip' as const], + ['diagnostics' as const], + ['image' as const], + ['media' as const], + ['live' as const], + ['recording' as const], + ['snapshot' as const], + ['timeline' as const], + ])('%s', (viewName: FrigateCardView) => { + const api = createCardAPI(); + const view = createView({ view: viewName }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + dimensions: { + aspect_ratio_mode: 'dynamic', + }, + }), + ); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('auto'); + }); + }); + + describe('should be enforced when dynamic in certain views', () => { + it.each([['clips' as const], ['recordings' as const], ['snapshots' as const]])( + '%s', + (viewName: FrigateCardView) => { + const api = createCardAPI(); + const view = createView({ view: viewName }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + dimensions: { + aspect_ratio_mode: 'dynamic', + }, + }), + ); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('16 / 9'); + }, + ); + }); + + describe('should use media dimensions in dynamic', () => { + it.each([['clips' as const], ['recordings' as const], ['snapshots' as const]])( + '%s', + (viewName: FrigateCardView) => { + const api = createCardAPI(); + const view = createView({ view: viewName }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + dimensions: { + aspect_ratio_mode: 'dynamic', + }, + }), + ); + vi.mocked(api.getMediaLoadedInfoManager().getLastKnown).mockReturnValue({ + width: 800, + height: 600, + }); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('800 / 600'); + }, + ); + }); + + it('should respect default aspect ratio', () => { + const api = createCardAPI(); + const view = createView({ view: 'clips' }); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + dimensions: { + aspect_ratio_mode: 'dynamic', + aspect_ratio: '4:3', + }, + }), + ); + const manager = new StyleManager(api); + + expect(manager.getAspectRatioStyle()).toBe('4 / 3'); + }); + }); +}); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts new file mode 100644 index 00000000..7379f69e --- /dev/null +++ b/tests/card-controller/triggers-manager.test.ts @@ -0,0 +1,190 @@ +import add from 'date-fns/add'; +import { HassEntities } from 'home-assistant-js-websocket'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ScanOptions } from '../../src/config/types'; +import { TriggersManager } from '../../src/card-controller/triggers-manager'; +import { + createCameraConfig, + createCameraManager, + createCardAPI, + createConfig, + createHASS, + createStateEntity, + createView, +} from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); + +// Creating and mocking a trigger API is a lot of boilerplate, this convenience +// function reduces it. +const createTriggerAPI = (options?: { + config?: Partial; + hassStates?: HassEntities; + interaction?: boolean; +}) => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + scan: options?.config ?? { + enabled: true, + untrigger_reset: true, + untrigger_seconds: 10, + }, + }, + }), + ); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASS(options?.hassStates), + ); + vi.mocked(api.getCameraManager).mockReturnValue( + createCameraManager({ + configs: new Map([ + [ + 'camera_1', + createCameraConfig({ + triggers: { + entities: ['binary_sensor.motion'], + }, + }), + ], + ]), + }), + ); + vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue( + options?.interaction ?? false, + ); + + return api; +}; + +// @vitest-environment jsdom +describe('TriggersManager', () => { + const hassActiveState = { + 'binary_sensor.motion': createStateEntity({ state: 'on' }), + }; + const hassInactiveState = { + 'binary_sensor.motion': createStateEntity({ state: 'off' }), + }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + }); + + it('should not be triggered by default', () => { + const manager = new TriggersManager(createCardAPI()); + expect(manager.isTriggered()).toBeFalsy(); + }); + + it('should not trigger when scan mode disabled default', () => { + const api = createTriggerAPI({ + config: { enabled: false }, + hassStates: hassActiveState, + }); + const manager = new TriggersManager(api); + + manager.updateTriggeredCameras(null); + + expect(manager.isTriggered()).toBeFalsy(); + }); + + it('should trigger and untrigger based on entity state', () => { + const start = new Date('2023-10-01T17:14'); + vi.setSystemTime(start); + const api = createTriggerAPI({ + hassStates: hassActiveState, + }); + const manager = new TriggersManager(api); + + manager.updateTriggeredCameras(createHASS(hassInactiveState)); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + viewName: 'live', + cameraID: 'camera_1', + }); + + vi.mocked(api.getHASSManager().getHASS).mockReturnValue( + createHASS(hassInactiveState), + ); + + manager.updateTriggeredCameras(createHASS(hassActiveState)); + + // Intentional state update with no change. + manager.updateTriggeredCameras(createHASS(hassActiveState)); + + // Will still be triggered, but untrigger timer will be running. + expect(manager.isTriggered()).toBeTruthy(); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('should trigger and set view if current view is wrong', () => { + const api = createTriggerAPI({ + hassStates: hassActiveState, + }); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + // Correct camera, but wrong view. + view: 'clips', + camera: 'camera_1', + }), + ); + const manager = new TriggersManager(api); + manager.updateTriggeredCameras(null); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + viewName: 'live', + cameraID: 'camera_1', + }); + }); + + it('should trigger when entity state is active on startup', () => { + const api = createTriggerAPI({ + hassStates: hassActiveState, + }); + const manager = new TriggersManager(api); + expect(manager.isTriggered()).toBeFalsy(); + + manager.updateTriggeredCameras(null); + expect(manager.isTriggered()).toBeTruthy(); + }); + + it('should untrigger manually', () => { + const api = createTriggerAPI({ + hassStates: hassActiveState, + }); + const manager = new TriggersManager(api); + + // Untriggering when not triggered. + manager.untrigger(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + + manager.updateTriggeredCameras(null); + expect(manager.isTriggered()).toBeTruthy(); + + manager.untrigger(); + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('should take no actions when automated actions are not allowed', () => { + const api = createTriggerAPI({ + hassStates: hassActiveState, + // Interaction present. + interaction: true, + }); + const manager = new TriggersManager(api); + manager.updateTriggeredCameras(null); + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + + manager.untrigger(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts new file mode 100644 index 00000000..516a2ec3 --- /dev/null +++ b/tests/card-controller/view-manager.test.ts @@ -0,0 +1,698 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryType } from '../../src/camera-manager/types'; +import { FrigateCardView } from '../../src/config/types'; +import { getAllDependentCameras } from '../../src/utils/camera'; +import { ViewManager } from '../../src/card-controller/view-manager'; +import { EventMediaQueries } from '../../src/view/media-queries'; +import { + createCameraManager, + createCardAPI, + createConfig, + createHASS, + createView, + generateViewMediaArray, +} from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); +vi.mock('../../src/utils/camera'); + +describe('ViewManager.setView', () => { + it('should set view', () => { + const api = createCardAPI(); + const manager = new ViewManager(api); + + const view = createView({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + }); + manager.setView(view); + + expect(manager.getView()).toBe(view); + expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); + expect(api.getCardElementManager().scrollReset).toBeCalled(); + expect(api.getMessageManager().reset).toBeCalled(); + expect(api.getStyleManager().setExpandedMode).toBeCalled(); + expect(api.getConditionsManager()?.setState).toBeCalledWith({ + view: 'live', + camera: 'camera', + displayMode: 'grid', + }); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should set view with minor changes without media clearing or scroll', () => { + const api = createCardAPI(); + const manager = new ViewManager(api); + + const view_1 = createView({ + view: 'live', + camera: 'camera', + }); + manager.setView(view_1); + + vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear(); + vi.mocked(api.getCardElementManager().scrollReset).mockClear(); + + const view_2 = createView({ + view: 'live', + camera: 'camera', + displayMode: 'single', + }); + + manager.setView(view_2); + + expect(manager.getView()).toBe(view_2); + + // The new view is neither a major media change, nor a different view name, + // so media clearing and scrolling should not happen. + expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled(); + expect(api.getCardElementManager().scrollReset).not.toBeCalled(); + }); + + it('should set view with new context', () => { + const api = createCardAPI(); + const manager = new ViewManager(api); + const context = { thumbnails: { fetch: false } }; + + // Setting context with no existing view does nothing. + manager.setViewWithNewContext(context); + expect(manager.getView()).toBeNull(); + + const view = createView({ + view: 'live', + camera: 'camera', + }); + manager.setView(view); + manager.setViewWithNewContext(context); + + expect(manager.getView()?.camera).toBe('camera'); + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.context).toEqual(context); + }); +}); + +describe('ViewManager.reset', () => { + it('should reset', () => { + const manager = new ViewManager(createCardAPI()); + + const view = createView(); + manager.setView(view); + manager.reset(); + + expect(manager.getView()).toBeNull(); + }); +}); + +describe('ViewManager.setViewDefault', () => { + it('should set default view', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + + const manager = new ViewManager(api); + manager.setViewDefault(); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera'); + expect(api.getAutoUpdateManager().startDefaultViewTimer).toBeCalled(); + }); + + it('should not set default view without config', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null); + + const manager = new ViewManager(api); + manager.setViewDefault(); + + expect(manager.getView()).toBeNull(); + expect(api.getAutoUpdateManager().startDefaultViewTimer).not.toBeCalled(); + }); + + it('should cycle camera when configured', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + view: { + update_cycle_camera: true, + }, + }), + ); + const manager = new ViewManager(api); + + manager.setViewDefault(); + expect(manager.getView()?.camera).toBe('camera_1'); + + manager.setViewDefault(); + expect(manager.getView()?.camera).toBe('camera_2'); + + manager.setViewDefault(); + expect(manager.getView()?.camera).toBe('camera_1'); + + // When a parameter is specified, it will not cycle. + manager.setViewDefault({ cameraID: 'camera_1' }); + expect(manager.getView()?.camera).toBe('camera_1'); + }); + + it('should respect parameters', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera.kitchen', 'camera.office']), + ); + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + const manager = new ViewManager(api); + + manager.setViewDefault({ + cameraID: 'camera.office', + substream: 'camera.office_hd', + }); + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera.office'); + expect(manager.getView()?.context?.live?.overrides).toEqual( + new Map([['camera.office', 'camera.office_hd']]), + ); + }); +}); + +describe('ViewManager.setViewByParameters', () => { + it('should set view by parameters specifying camera and view', () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + cameraID: 'camera', + viewName: 'clips', + }); + + expect(manager.getView()?.view).toBe('clips'); + expect(manager.getView()?.camera).toBe('camera'); + }); + + it('should set view by parameters using existing view if unspecified', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + cameraID: 'camera_1', + viewName: 'clips', + }); + + manager.setViewByParameters({ + cameraID: 'camera_2', + }); + + expect(manager.getView()?.view).toBe('clips'); + expect(manager.getView()?.camera).toBe('camera_2'); + }); + + it('should set view by parameters using config as fallback', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + cameraID: 'camera_1', + // No prior view, and no specified view. This could happen during query + // string based initialization. + }); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera_1'); + }); + + it('should not set view by parameters without config', () => { + const manager = new ViewManager(createCardAPI()); + + manager.setViewByParameters({ + viewName: 'live', + }); + + expect(manager.getView()).toBeNull(); + }); + + it('should not set view by parameters without visible cameras', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue(new Set()); + + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + viewName: 'live', + }); + + expect(manager.getView()).toBeNull(); + }); + + describe('should set view by parameters and respect display mode in config for view', () => { + it.each([ + ['media' as const], + ['clip' as const], + ['recording' as const], + ['snapshot' as const], + ['live' as const], + ])('%s', (viewName: FrigateCardView) => { + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + vi.mocked(api.getConfigManager()).getConfig.mockReturnValue( + createConfig({ + media_viewer: { + display: { + mode: 'grid', + }, + }, + live: { + display: { + mode: 'grid', + }, + }, + }), + ); + const manager = new ViewManager(api); + + manager.setViewByParameters({ + cameraID: 'camera', + viewName: viewName, + }); + + expect(manager.getView()?.displayMode).toBe('grid'); + }); + }); + + describe('should set view by parameters and leave display mode unset for view', () => { + it.each([ + ['media' as const], + ['clip' as const], + ['recording' as const], + ['snapshot' as const], + ['live' as const], + ])('%s', (viewName: FrigateCardView) => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + const manager = new ViewManager(api); + + manager.setViewByParameters({ + cameraID: 'camera', + viewName: viewName, + }); + + expect(manager.getView()?.displayMode).toBe('single'); + }); + }); + + it('should set view by parameters using config as fallback', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(getAllDependentCameras).mockReturnValue( + new Set(['camera_1', 'camera_1_hd']), + ); + + const manager = new ViewManager(api); + manager.setViewByParameters({ + cameraID: 'camera_1', + viewName: 'live', + substream: 'camera_1_hd', + }); + + expect(manager.getView()?.view).toBe('live'); + expect(manager.getView()?.camera).toBe('camera_1'); + expect(manager.getView()?.context?.live?.overrides).toEqual( + new Map([['camera_1', 'camera_1_hd']]), + ); + }); +}); + +// @vitest-environment jsdom +describe('ViewManager.setViewWithNewDisplayMode', () => { + it('should set display mode', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + const manager = new ViewManager(api); + manager.setView(createView()); + + await manager.setViewWithNewDisplayMode('grid'); + + expect(manager.getView()?.displayMode).toBe('grid'); + }); + + it('should not set display mode without view', async () => { + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); + const manager = new ViewManager(api); + + manager.setViewWithNewDisplayMode('grid'); + + expect(manager.getView()).toBeNull(); + }); + + it('should set display mode to grid and create new query', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + + const hass = createHASS(); + vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); + + const media = generateViewMediaArray({ count: 5 }); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(media); + + const manager = new ViewManager(api); + const query = new EventMediaQueries([ + { type: QueryType.Event, cameraIDs: new Set(['camera_1']), hasClip: true }, + ]); + + manager.setView( + createView({ + camera: 'camera_1', + view: 'clip', + query: query, + }), + ); + + await manager.setViewWithNewDisplayMode('grid'); + + expect(manager.getView()?.queryResults?.getResults()).toBe(media); + expect(cameraManager.executeMediaQueries).toBeCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + type: 'event-query', + cameraIDs: new Set(['camera_1', 'camera_2']), + hasClip: true, + }), + ]), + ); + }); + + it('should set display mode to single and create new query', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + + const hass = createHASS(); + vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); + + const media = generateViewMediaArray({ count: 5 }); + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(media); + + const manager = new ViewManager(api); + const query = new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera_1', 'camera_2']), + hasClip: true, + }, + ]); + + manager.setView( + createView({ + view: 'clip', + camera: 'camera_2', + query: query, + }), + ); + + await manager.setViewWithNewDisplayMode('single'); + + expect(manager.getView()?.queryResults?.getResults()).toBe(media); + expect(cameraManager.executeMediaQueries).toBeCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + type: 'event-query', + cameraIDs: new Set(['camera_2']), + hasClip: true, + }), + ]), + ); + }); + + it('should set display mode to single and handle failed new query', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + + const manager = new ViewManager(api); + const query = new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera_1', 'camera_2']), + hasClip: true, + }, + ]); + + const originalView = createView({ + view: 'clip', + camera: 'camera_2', + query: query, + }); + manager.setView(originalView); + + // Query execution fails / returns null. + vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(null); + + await manager.setViewWithNewDisplayMode('single'); + + expect(manager.getView()).toBe(originalView); + }); + + it('should set display mode and handle empty new query results', async () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore().getVisibleCameraCount).mockReturnValue(2); + vi.mocked(cameraManager.getStore().getVisibleCameraIDs).mockReturnValue( + new Set(['camera_1', 'camera_2']), + ); + + const api = createCardAPI(); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + + const manager = new ViewManager(api); + + const query = new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: new Set(['camera_1']), + hasClip: true, + }, + ]); + const originalView = createView({ + view: 'clip', + camera: 'camera_2', + query: query, + }); + manager.setView(originalView); + + await manager.setViewWithNewDisplayMode('grid'); + + vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(null); + + // Empty queries will not be executed, so view will not be changed. + expect(manager.getView()?.displayMode).toBeNull(); + }); +}); + +describe('ViewManager.setViewWithSubstream', () => { + it('should set new equal view with no dependencies', () => { + const view = createView({ + view: 'live', + camera: 'camera', + }); + vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); + + const manager = new ViewManager(createCardAPI()); + manager.setView(view); + manager.setViewWithSubstream(); + + expect(manager.getView()?.camera).toBe(view.camera); + expect(manager.getView()?.view).toBe(view.view); + expect(manager.getView()?.context).toEqual(view.context); + }); + + it('should set new view with next substream', () => { + const view = createView({ + view: 'live', + camera: 'camera', + }); + vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); + + const manager = new ViewManager(createCardAPI()); + manager.setView(view); + manager.setViewWithSubstream(); + + expect(manager.getView()?.context?.live?.overrides).toEqual( + new Map([['camera', 'camera2']]), + ); + }); + + it('should set new view with next substream when view has invalid substream', () => { + const view = createView({ + view: 'live', + camera: 'camera', + context: { + live: { + overrides: new Map([['camera', 'camera-that-does-not-exist']]), + }, + }, + }); + vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); + + const manager = new ViewManager(createCardAPI()); + manager.setView(view); + manager.setViewWithSubstream(); + + expect(manager.getView()?.context?.live?.overrides).toEqual( + new Map([['camera', 'camera']]), + ); + }); + + it('should set new view with selected substream', () => { + const view = createView({ + view: 'live', + camera: 'camera', + }); + + const manager = new ViewManager(createCardAPI()); + manager.setView(view); + manager.setViewWithSubstream('substream'); + + expect(manager.getView()?.context?.live?.overrides).toEqual( + new Map([['camera', 'substream']]), + ); + }); + + it('should not set view with next substream without an existing view', () => { + const manager = new ViewManager(createCardAPI()); + manager.setViewWithSubstream(); + expect(manager.getView()).toBeNull(); + }); + + it('should not set view with selected substream without an existing view', () => { + const manager = new ViewManager(createCardAPI()); + manager.setViewWithSubstream('substream'); + expect(manager.getView()).toBeNull(); + }); + + it('should not set view without substream without an existing view', () => { + const manager = new ViewManager(createCardAPI()); + manager.setViewWithoutSubstream(); + expect(manager.getView()).toBeNull(); + }); + + it('should set new view without substream', () => { + const view = createView({ + view: 'live', + camera: 'camera', + context: { + live: { + overrides: new Map([['camera', 'camera']]), + }, + }, + }); + + const manager = new ViewManager(createCardAPI()); + manager.setView(view); + manager.setViewWithoutSubstream(); + + expect(manager.getView()?.context?.live?.overrides).toEqual(new Map()); + }); + + it('should set new view without substream', () => { + const view = createView({ + view: 'live', + camera: 'camera', + context: { + live: { + overrides: new Map([['camera-2', 'camera-3']]), + }, + }, + }); + + const manager = new ViewManager(createCardAPI()); + manager.setView(view); + manager.setViewWithoutSubstream(); + + expect(manager.getView()?.context?.live?.overrides).toEqual( + view.context?.live?.overrides, + ); + }); +}); + +describe('ViewManager.isViewSupportedByCamera', () => { + it.each([ + ['live' as const, true], + ['image' as const, true], + ['diagnostics' as const, true], + ['clip' as const, false], + ['clips' as const, false], + ['snapshot' as const, false], + ['snapshots' as const, false], + ['recording' as const, false], + ['recordings' as const, false], + ['timeline' as const, false], + ['media' as const, false], + ])('%s', (viewName: FrigateCardView, expected: boolean) => { + const api = createCardAPI(); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue({ + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + }); + vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); + const manager = new ViewManager(api); + + expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected); + }); +});