From 6ed1c90554ee265f0c8e9dfeaeeb2887027fe4c0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 Mar 2024 19:57:14 -0800 Subject: [PATCH] Ensure timeline menu buttons shows up when it should. --- src/card-controller/actions-manager.ts | 9 +- src/card-controller/view-manager.ts | 28 +- src/card.ts | 1 + src/components-lib/menu-button-controller.ts | 36 +- tests/card-controller/actions-manager.test.ts | 31 +- tests/card-controller/view-manager.test.ts | 41 +- .../menu-button-controller.test.ts | 2132 +++++++++-------- 7 files changed, 1208 insertions(+), 1070 deletions(-) diff --git a/src/card-controller/actions-manager.ts b/src/card-controller/actions-manager.ts index a40ab535..89060699 100644 --- a/src/card-controller/actions-manager.ts +++ b/src/card-controller/actions-manager.ts @@ -3,7 +3,6 @@ import { ActionsConfig, ActionType, FrigateCardCustomAction, - FRIGATE_CARD_VIEW_DEFAULT, } from '../config/types.js'; import { convertActionToFrigateCardCustomAction, @@ -177,14 +176,10 @@ export class ActionsManager { 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, + viewName: targetViewName, cameraID: selectCameraID, + failSafe: true, }); } break; diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index a9a6e13f..463e626f 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -190,7 +190,17 @@ export class ViewManager { } public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean { - const capabilities = this._api.getCameraManager().getCameraCapabilities(cameraID); + const dependentCamerasCapabilities = this._api + .getCameraManager() + .getAggregateCameraCapabilities( + this._api.getCameraManager().getStore().getAllDependentCameras(cameraID), + ); + const allCamerasCapabilities = this._api + .getCameraManager() + .getAggregateCameraCapabilities( + this._api.getCameraManager().getStore().getCameraIDs(), + ); + switch (view) { case 'live': case 'image': @@ -198,20 +208,22 @@ export class ViewManager { return true; case 'clip': case 'clips': - return !!capabilities?.supportsClips; + return !!dependentCamerasCapabilities?.supportsClips; case 'snapshot': case 'snapshots': - return !!capabilities?.supportsSnapshots; + return !!dependentCamerasCapabilities?.supportsSnapshots; case 'recording': case 'recordings': - return !!capabilities?.supportsRecordings; + return !!dependentCamerasCapabilities?.supportsRecordings; case 'timeline': - return !!capabilities?.supportsTimeline; + // Show the timeline if any camera supports it, even cameras unrelated + // to the currently selected camera. + return !!allCamerasCapabilities?.supportsTimeline; case 'media': return ( - !!capabilities?.supportsClips || - !!capabilities?.supportsSnapshots || - !!capabilities?.supportsRecordings + !!dependentCamerasCapabilities?.supportsClips || + !!dependentCamerasCapabilities?.supportsSnapshots || + !!dependentCamerasCapabilities?.supportsRecordings ); } } diff --git a/src/card.ts b/src/card.ts index e4973c99..beb6ed21 100644 --- a/src/card.ts +++ b/src/card.ts @@ -209,6 +209,7 @@ class FrigateCard extends LitElement { showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(), mediaPlayerController: this._controller.getMediaPlayerManager(), microphoneManager: this._controller.getMicrophoneManager(), + viewManager: this._controller.getViewManager(), }, )} .entityRegistryManager=${this._controller.getEntityRegistryManager()} diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 56639fb6..cfe3c385 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -3,10 +3,11 @@ import { StyleInfo } from 'lit/directives/style-map'; import { CameraManager } from '../camera-manager/manager'; import { MediaPlayerManager } from '../card-controller/media-player-manager'; import { MicrophoneManager } from '../card-controller/microphone-manager'; +import { ViewManager } from '../card-controller/view-manager'; import { + FRIGATE_CARD_VIEWS_USER_SPECIFIED, FrigateCardConfig, FrigateCardCustomAction, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, MenuItem, } from '../config/types'; import { FRIGATE_BUTTON_MENU_ICON } from '../const'; @@ -31,6 +32,7 @@ export interface MenuButtonControllerOptions { inExpandedMode?: boolean; microphoneManager?: MicrophoneManager | null; mediaPlayerController?: MediaPlayerManager | null; + viewManager?: ViewManager | null; } export class MenuButtonController { @@ -62,18 +64,19 @@ export class MenuButtonController { ): MenuItem[] { const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs(); const selectedCameraID = view.camera; + const substreamAwareCameraID = + view.context?.live?.overrides?.get(selectedCameraID) ?? selectedCameraID; const selectedCameraConfig = cameraManager .getStore() .getCameraConfig(selectedCameraID); const allSelectedCameraIDs = cameraManager .getStore() .getAllDependentCameras(selectedCameraID); - const selectedMedia = view.queryResults?.getSelectedResult(); - const selectedCameraCapabilities = - cameraManager.getCameraCapabilities(selectedCameraID); - const aggregateCapabilities = - cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs); + const substreamAwareCameraCapabilities = + cameraManager.getCameraCapabilities(substreamAwareCameraID); + + const selectedMedia = view.queryResults?.getSelectedResult(); const mediaCapabilities = selectedMedia ? cameraManager?.getMediaCapabilities(selectedMedia) : null; @@ -125,7 +128,6 @@ export class MenuButtonController { if (selectedCameraID && allSelectedCameraIDs && view.is('live')) { const dependencies = [...allSelectedCameraIDs]; - const override = view.context?.live?.overrides?.get(selectedCameraID); if (dependencies.length === 2) { // If there are only two dependencies (the main camera, and 1 other) @@ -133,7 +135,9 @@ export class MenuButtonController { buttons.push({ icon: 'mdi:video-input-component', style: - override && override !== selectedCameraID ? this._getEmphasizedStyle() : {}, + substreamAwareCameraID !== selectedCameraID + ? this._getEmphasizedStyle() + : {}, title: localize('config.menu.buttons.substreams'), ...config.menu.buttons.substreams, type: 'custom:frigate-card-menu-icon', @@ -166,7 +170,9 @@ export class MenuButtonController { icon: 'mdi:video-input-component', title: localize('config.menu.buttons.substreams'), style: - override && override !== selectedCameraID ? this._getEmphasizedStyle() : {}, + substreamAwareCameraID !== selectedCameraID + ? this._getEmphasizedStyle() + : {}, ...config.menu.buttons.substreams, type: 'custom:frigate-card-menu-submenu', items: menuItems, @@ -183,7 +189,7 @@ export class MenuButtonController { tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction, }); - if (aggregateCapabilities?.supportsClips) { + if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'clips')) { buttons.push({ icon: 'mdi:filmstrip', ...config.menu.buttons.clips, @@ -195,7 +201,7 @@ export class MenuButtonController { }); } - if (aggregateCapabilities?.supportsSnapshots) { + if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'snapshots')) { buttons.push({ icon: 'mdi:camera', ...config.menu.buttons.snapshots, @@ -211,7 +217,7 @@ export class MenuButtonController { }); } - if (aggregateCapabilities?.supportsRecordings) { + if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'recordings')) { buttons.push({ icon: 'mdi:album', ...config.menu.buttons.recordings, @@ -236,9 +242,7 @@ export class MenuButtonController { tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction, }); - // Don't show the timeline button unless there's at least one non-birdseye - // camera with a Frigate camera name. - if (aggregateCapabilities?.supportsTimeline) { + if (options?.viewManager?.isViewSupportedByCamera(selectedCameraID, 'timeline')) { buttons.push({ icon: 'mdi:chart-gantt', ...config.menu.buttons.timeline, @@ -419,7 +423,7 @@ export class MenuButtonController { }); } - if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) { + if (hasUsablePTZ(substreamAwareCameraCapabilities, config.live.controls.ptz)) { const isOn = view.context?.live?.ptzVisible === false ? false diff --git a/tests/card-controller/actions-manager.test.ts b/tests/card-controller/actions-manager.test.ts index f864302c..02742759 100644 --- a/tests/card-controller/actions-manager.test.ts +++ b/tests/card-controller/actions-manager.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { afterAll, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { @@ -382,6 +383,7 @@ describe('ActionsManager.executeAction', () => { expect.objectContaining({ viewName: 'live', cameraID: 'camera', + failSafe: true, }), ); }); @@ -409,6 +411,7 @@ describe('ActionsManager.executeAction', () => { expect.objectContaining({ viewName: 'timeline', cameraID: 'camera', + failSafe: true, }), ); }); @@ -442,6 +445,7 @@ describe('ActionsManager.executeAction', () => { expect.objectContaining({ viewName: 'clips', cameraID: 'camera', + failSafe: true, }), ); }); @@ -467,6 +471,7 @@ describe('ActionsManager.executeAction', () => { expect.objectContaining({ viewName: 'live', cameraID: 'camera', + failSafe: true, }), ); }); @@ -503,32 +508,6 @@ describe('ActionsManager.executeAction', () => { 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.executeFrigateAction( - 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 () => { diff --git a/tests/card-controller/view-manager.test.ts b/tests/card-controller/view-manager.test.ts index 883954c7..738b5919 100644 --- a/tests/card-controller/view-manager.test.ts +++ b/tests/card-controller/view-manager.test.ts @@ -7,6 +7,7 @@ import { EventMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; import { + createAggregateCameraCapabilities, createCameraCapabilities, createCameraConfig, createCameraManager, @@ -15,7 +16,7 @@ import { createHASS, createStore, createView, - generateViewMediaArray + generateViewMediaArray, } from '../test-utils'; vi.mock('../../src/utils/media-to-view'); @@ -218,8 +219,8 @@ describe('ViewManager.setViewByParameters', () => { }, ]), ); - vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( - createCameraCapabilities({ + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsClips: true, }), ); @@ -247,8 +248,8 @@ describe('ViewManager.setViewByParameters', () => { }, ]), ); - vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( - createCameraCapabilities({ + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsClips: true, }), ); @@ -336,10 +337,12 @@ describe('ViewManager.setViewByParameters', () => { ]), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(createCameraCapabilities({ - supportsSnapshots: false, - })); - + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ + supportsSnapshots: false, + }), + ); + const manager = new ViewManager(api); manager.setViewByParameters({ viewName: 'snapshots', @@ -360,16 +363,18 @@ describe('ViewManager.setViewByParameters', () => { ]), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); - vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue(createCameraCapabilities({ - supportsSnapshots: false, - })); - + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ + supportsSnapshots: false, + }), + ); + const manager = new ViewManager(api); manager.setViewByParameters({ viewName: 'snapshots', failSafe: true, }); - + expect(manager.hasView()).toBeTruthy(); expect(manager.getView()?.view).toBe('live'); }); @@ -392,8 +397,8 @@ describe('ViewManager.setViewByParameters', () => { }, ]), ); - vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( - createCameraCapabilities({ + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsClips: true, supportsRecordings: true, supportsSnapshots: true, @@ -443,8 +448,8 @@ describe('ViewManager.setViewByParameters', () => { }, ]), ); - vi.mocked(api.getCameraManager().getCameraCapabilities).mockReturnValue( - createCameraCapabilities({ + vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue( + createAggregateCameraCapabilities({ supportsClips: true, supportsRecordings: true, supportsSnapshots: true, diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 87f2c9ba..e72138f4 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -6,6 +6,7 @@ import { CameraManager } from '../../src/camera-manager/manager'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; +import { ViewManager } from '../../src/card-controller/view-manager'; import { MenuButtonController, MenuButtonControllerOptions, @@ -17,7 +18,6 @@ import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; import { - createAggregateCameraCapabilities, createCameraCapabilities, createCameraConfig, createCameraManager, @@ -41,6 +41,7 @@ const calculateButtons = ( config?: FrigateCardConfig; cameraManager?: CameraManager; view?: View; + viewManager?: ViewManager; }, ): MenuItem[] => { let cameraManager: CameraManager | null = options?.cameraManager ?? null; @@ -71,618 +72,697 @@ describe('MenuButtonController', () => { controller = new MenuButtonController(); }); - it('should have frigate menu button with hidden menu style', () => { - const buttons = calculateButtons(controller); - expect(buttons).toContainEqual({ - icon: 'frigate', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Frigate menu / Default view', - tap_action: createFrigateCardSimpleAction('menu_toggle'), - hold_action: createFrigateCardSimpleAction('diagnostics'), + describe('should have frigate menu button', () => { + it('with hidden menu style', () => { + const buttons = calculateButtons(controller); + + expect(buttons).toContainEqual({ + icon: 'frigate', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Frigate menu / Default view', + tap_action: createFrigateCardSimpleAction('menu_toggle'), + hold_action: createFrigateCardSimpleAction('diagnostics'), + }); + }); + + it('without hidden menu style', () => { + const buttons = calculateButtons(controller, { + config: createConfig({ menu: { style: 'overlay' } }), + }); + + expect(buttons).toContainEqual({ + icon: 'frigate', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Frigate menu / Default view', + tap_action: createFrigateCardSimpleAction('default'), + hold_action: createFrigateCardSimpleAction('diagnostics'), + }); }); }); - it('should have frigate menu button without hidden menu style', () => { - const buttons = calculateButtons(controller, { - config: createConfig({ menu: { style: 'overlay' } }), + describe('should have cameras menu', () => { + it('with multiple cameras', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), + ); + vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ + title: 'title', + icon: 'icon', + }); + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + + expect(buttons).toContainEqual({ + icon: 'mdi:video-switch', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-submenu', + title: 'Cameras', + items: [ + { + enabled: true, + icon: 'icon', + entity: undefined, + state_color: true, + title: 'title', + selected: true, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'camera-1', + }, + }, + { + enabled: true, + icon: 'icon', + entity: undefined, + state_color: true, + title: 'title', + selected: false, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'camera-2', + }, + }, + ], + }); }); - expect(buttons).toContainEqual({ - icon: 'frigate', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Frigate menu / Default view', - tap_action: createFrigateCardSimpleAction('default'), - hold_action: createFrigateCardSimpleAction('diagnostics'), + + it('without a visible camera', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { cameraID: 'camera-1', config: createCameraConfig({ hide: true }) }, + ]), + ); + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: 'Cameras', + }), + ]), + ); }); }); - it('should have cameras menu', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), - ); - vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ - title: 'title', - icon: 'icon', + describe('should have substream button', () => { + it('with single dependency', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, + ]), + ); + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + + expect(buttons).toContainEqual({ + icon: 'mdi:video-input-component', + style: {}, + title: 'Substream(s)', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_on', + }, + }); }); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).toContainEqual({ - icon: 'mdi:video-switch', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-submenu', - title: 'Cameras', - items: [ - { - enabled: true, - icon: 'icon', - entity: undefined, - state_color: true, - title: 'title', - selected: true, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'camera_select', - camera: 'camera-1', + it('with substream selected and single dependency', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, + ]), + ); + const view = createView({ + camera: 'camera-1', + context: { + live: { + overrides: new Map([['camera-1', 'camera-2']]), }, }, - { - enabled: true, - icon: 'icon', - entity: undefined, - state_color: true, - title: 'title', - selected: false, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'camera_select', - camera: 'camera-2', + }); + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + view: view, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:video-input-component', + style: { color: 'var(--primary-color, white)' }, + title: 'Substream(s)', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_off', + }, + }); + }); + + it('with substream selected and multiple dependencies', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ + camera_entity: 'camera.1', + dependencies: { cameras: ['camera-2', 'camera-3'] }, + }), + }, + { + cameraID: 'camera-2', + config: createCameraConfig({ + camera_entity: 'camera.2', + }), + }, + { + cameraID: 'camera-3', + config: createCameraConfig({ + camera_entity: 'camera.3', + }), + }, + ]), + ); + + // Return different metadata depending on the camera to test multiple code + // paths. + mock(cameraManager).getCameraMetadata.mockImplementation( + (cameraID: string): CameraManagerCameraMetadata | null => { + return cameraID === 'camera-1' + ? { + title: 'title', + icon: 'icon', + } + : null; + }, + ); + + const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + + expect(buttons).toContainEqual({ + icon: 'mdi:video-input-component', + title: 'Substream(s)', + style: {}, + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-submenu', + items: [ + { + enabled: true, + icon: 'icon', + entity: 'camera.1', + state_color: true, + title: 'title', + selected: true, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'camera-1', + }, + }, + { + enabled: true, + icon: undefined, + entity: 'camera.2', + state_color: true, + title: undefined, + selected: false, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'camera-2', + }, + }, + { + enabled: true, + icon: undefined, + entity: 'camera.3', + state_color: true, + title: undefined, + selected: false, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'camera-3', + }, + }, + ], + }); + }); + + it('with substream selected and with multiple dependencies', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ + camera_entity: 'camera.1', + dependencies: { cameras: ['camera-2', 'camera-3'] }, + }), + }, + { + cameraID: 'camera-2', + config: createCameraConfig({ + camera_entity: 'camera.2', + }), + }, + { + cameraID: 'camera-3', + config: createCameraConfig({ + camera_entity: 'camera.3', + }), + }, + ]), + ); + const view = createView({ + camera: 'camera-1', + context: { + live: { + overrides: new Map([['camera-1', 'camera-2']]), }, }, - ], + }); + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + view: view, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:video-input-component', + title: 'Substream(s)', + style: { color: 'var(--primary-color, white)' }, + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-submenu', + items: [ + { + enabled: true, + icon: undefined, + entity: 'camera.1', + state_color: true, + title: undefined, + selected: false, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'camera-1', + }, + }, + { + enabled: true, + icon: undefined, + entity: 'camera.2', + state_color: true, + title: undefined, + // camera-2 is selected in this test scenario because of the view + // override. + selected: true, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'camera-2', + }, + }, + { + enabled: true, + icon: undefined, + entity: 'camera.3', + state_color: true, + title: undefined, + selected: false, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'live_substream_select', + camera: 'camera-3', + }, + }, + ], + }); }); }); - it('should not have a cameras menu without a visible camera', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { cameraID: 'camera-1', config: createCameraConfig({ hide: true }) }, - ]), - ); + describe('should have live menu button', () => { + it('when in live view', () => { + const buttons = calculateButtons(controller); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - title: 'Cameras', + expect(buttons).toContainEqual({ + icon: 'mdi:cctv', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Live view', + style: { color: 'var(--primary-color, white)' }, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' }, + }); + }); + + it('when not in live view', () => { + const view = createView({ view: 'clips' }); + const buttons = calculateButtons(controller, { view: view }); + + expect(buttons).toContainEqual({ + icon: 'mdi:cctv', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Live view', + style: {}, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' }, + }); + }); + }); + + describe('should have clips menu button', () => { + it('when in clips view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'clips' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:filmstrip', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Clips gallery', + style: { color: 'var(--primary-color, white)' }, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, + hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' }, + }); + }); + + it('when not in clips view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:filmstrip', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Clips gallery', + style: {}, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, + hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' }, + }); + }); + + it('when not supported', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Clips gallery' })]), + ); + }); + }); + + describe('should have snapshots menu button', () => { + it('when in snapshots view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'snapshots' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:camera', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Snapshots gallery', + style: { color: 'var(--primary-color, white)' }, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' }, + hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' }, + }); + }); + + it('when not in snapshots view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:camera', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Snapshots gallery', + style: {}, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' }, + hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' }, + }); + }); + + it('when not supported', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Snapshots gallery' }), + ]), + ); + }); + }); + + describe('should have recordings menu button', () => { + it('when in recordings view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'recordings' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:album', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Recordings gallery', + style: { color: 'var(--primary-color, white)' }, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' }, + hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' }, + }); + }); + + it('when not in recordings view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:album', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Recordings gallery', + style: {}, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' }, + hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' }, + }); + }); + + it('when not supported', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + const buttons = calculateButtons(controller, { + viewManager: viewManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Recordings gallery' }), + ]), + ); + }); + }); + + describe('should have image menu button', () => { + it('when in image view', () => { + const buttons = calculateButtons(controller, { + view: createView({ view: 'image' }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:image', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Static image', + style: { color: 'var(--primary-color, white)' }, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' }, + }); + }); + + it('when not in image view', () => { + const buttons = calculateButtons(controller); + + expect(buttons).toContainEqual({ + icon: 'mdi:image', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Static image', + style: {}, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' }, + }); + }); + }); + + describe('should have timeline button', () => { + it('when in timeline view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'timeline' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:chart-gantt', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Timeline view', + style: { color: 'var(--primary-color, white)' }, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' }, + }); + }); + + it('when not in timeline view', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(true); + const buttons = calculateButtons(controller, { + view: createView({ view: 'live' }), + viewManager: viewManager, + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:chart-gantt', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Timeline view', + style: {}, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' }, + }); + }); + + it('when not supported', () => { + const viewManager = mock(); + vi.mocked(viewManager.isViewSupportedByCamera).mockReturnValue(false); + const buttons = calculateButtons(controller, { + view: createView({ view: 'live' }), + viewManager: viewManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Timeline view' })]), + ); + }); + }); + + describe('should have download button', () => { + it('when media available', () => { + vi.stubGlobal('navigator', { userAgent: 'foo' }); + + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( + createMediaCapabilities({ canDownload: true }), + ); + const view = createView({ + queryResults: new MediaQueriesResults({ + results: [new ViewMedia('clip', 'camera-1')], + selectedIndex: 0, }), - ]), - ); - }); + }); + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + view: view, + }); - it('should have substream button with single dependency', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), - }, - { cameraID: 'camera-2' }, - ]), - ); - - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).toContainEqual({ - icon: 'mdi:video-input-component', - style: {}, - title: 'Substream(s)', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_on', - }, - }); - }); - - it('should have substream button selected with single dependency', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), - }, - { cameraID: 'camera-2' }, - ]), - ); - - const view = createView({ - camera: 'camera-1', - context: { - live: { - overrides: new Map([['camera-1', 'camera-2']]), - }, - }, + expect(buttons).toContainEqual({ + icon: 'mdi:download', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Download', + tap_action: { action: 'fire-dom-event', frigate_card_action: 'download' }, + }); }); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: view, - }); - expect(buttons).toContainEqual({ - icon: 'mdi:video-input-component', - style: { color: 'var(--primary-color, white)' }, - title: 'Substream(s)', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_off', - }, - }); - }); + it('not when being casted', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Fuchsia) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/114.0.0.0 Safari/537.36 CrKey/1.56.500000', + }); - it('should have substream menu without substream on with multiple dependencies', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ - camera_entity: 'camera.1', - dependencies: { cameras: ['camera-2', 'camera-3'] }, - }), - }, - { - cameraID: 'camera-2', - config: createCameraConfig({ - camera_entity: 'camera.2', - }), - }, - { - cameraID: 'camera-3', - config: createCameraConfig({ - camera_entity: 'camera.3', - }), - }, - ]), - ); + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( + createMediaCapabilities({ canDownload: true }), + ); + const view = createView({ + queryResults: new MediaQueriesResults({ + results: [new ViewMedia('clip', 'camera-1')], + selectedIndex: 0, + }), + }); + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + view: view, + }); - // Return different metadata depending on the camera to test multiple code - // paths. - mock(cameraManager).getCameraMetadata.mockImplementation( - (cameraID: string): CameraManagerCameraMetadata | null => { - return cameraID === 'camera-1' - ? { - title: 'title', - icon: 'icon', - } - : null; - }, - ); - - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).toContainEqual({ - icon: 'mdi:video-input-component', - title: 'Substream(s)', - style: {}, - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-submenu', - items: [ - { - enabled: true, - icon: 'icon', - entity: 'camera.1', - state_color: true, - title: 'title', - selected: true, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_select', - camera: 'camera-1', - }, - }, - { - enabled: true, - icon: undefined, - entity: 'camera.2', - state_color: true, - title: undefined, - selected: false, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_select', - camera: 'camera-2', - }, - }, - { - enabled: true, - icon: undefined, - entity: 'camera.3', - state_color: true, - title: undefined, - selected: false, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_select', - camera: 'camera-3', - }, - }, - ], + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Download' })]), + ); }); }); - it('should have substream menu with substream on with multiple dependencies', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ - camera_entity: 'camera.1', - dependencies: { cameras: ['camera-2', 'camera-3'] }, - }), - }, - { - cameraID: 'camera-2', - config: createCameraConfig({ - camera_entity: 'camera.2', - }), - }, - { - cameraID: 'camera-3', - config: createCameraConfig({ - camera_entity: 'camera.3', - }), - }, - ]), - ); - - const view = createView({ - camera: 'camera-1', - context: { - live: { - overrides: new Map([['camera-1', 'camera-2']]), - }, - }, - }); - - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: view, - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:video-input-component', - title: 'Substream(s)', - style: { color: 'var(--primary-color, white)' }, - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-submenu', - items: [ - { - enabled: true, - icon: undefined, - entity: 'camera.1', - state_color: true, - title: undefined, - selected: false, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_select', - camera: 'camera-1', - }, - }, - { - enabled: true, - icon: undefined, - entity: 'camera.2', - state_color: true, - title: undefined, - // camera-2 is selected in this test scenario because of the view - // override. - selected: true, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_select', - camera: 'camera-2', - }, - }, - { - enabled: true, - icon: undefined, - entity: 'camera.3', - state_color: true, - title: undefined, - selected: false, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'live_substream_select', - camera: 'camera-3', - }, - }, - ], - }); - }); - - it('should have styled live menu button in live view', () => { - const buttons = calculateButtons(controller); - expect(buttons).toContainEqual({ - icon: 'mdi:cctv', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Live view', - style: { color: 'var(--primary-color, white)' }, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' }, - }); - }); - - it('should have unstyled live menu button in non-live views', () => { - const view = createView({ view: 'clips' }); - const buttons = calculateButtons(controller, { view: view }); - expect(buttons).toContainEqual({ - icon: 'mdi:cctv', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Live view', - style: {}, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' }, - }); - }); - - it('should have styled clips menu button in clips view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsClips: true }), - ); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: createView({ view: 'clips' }), - }); - expect(buttons).toContainEqual({ - icon: 'mdi:filmstrip', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Clips gallery', - style: { color: 'var(--primary-color, white)' }, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, - hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' }, - }); - }); - - it('should have unstyled clips menu button in non-clips view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsClips: true }), - ); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).toContainEqual({ - icon: 'mdi:filmstrip', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Clips gallery', - style: {}, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, - hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' }, - }); - }); - - it('should have styled snapshots menu button in snapshots view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsSnapshots: true }), - ); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: createView({ view: 'snapshots' }), - }); - expect(buttons).toContainEqual({ - icon: 'mdi:camera', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Snapshots gallery', - style: { color: 'var(--primary-color, white)' }, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' }, - hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' }, - }); - }); - - it('should have unstyled snapshots menu button in non-snapshots view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsSnapshots: true }), - ); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).toContainEqual({ - icon: 'mdi:camera', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Snapshots gallery', - style: {}, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' }, - hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' }, - }); - }); - - it('should have styled recordings menu button in recordings view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsRecordings: true }), - ); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: createView({ view: 'recordings' }), - }); - expect(buttons).toContainEqual({ - icon: 'mdi:album', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Recordings gallery', - style: { color: 'var(--primary-color, white)' }, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' }, - hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' }, - }); - }); - - it('should have unstyled recordings menu button in non-recordings view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsRecordings: true }), - ); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); - expect(buttons).toContainEqual({ - icon: 'mdi:album', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Recordings gallery', - style: {}, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' }, - hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' }, - }); - }); - - it('should have styled image menu button in image view', () => { - const buttons = calculateButtons(controller, { - view: createView({ view: 'image' }), - }); - expect(buttons).toContainEqual({ - icon: 'mdi:image', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Static image', - style: { color: 'var(--primary-color, white)' }, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' }, - }); - }); - - it('should have unstyled image menu button in non-image view', () => { - const buttons = calculateButtons(controller); - expect(buttons).toContainEqual({ - icon: 'mdi:image', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Static image', - style: {}, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' }, - }); - }); - - it('should have styled timeline menu button in timeline view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsTimeline: true }), - ); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: createView({ view: 'timeline' }), - }); - expect(buttons).toContainEqual({ - icon: 'mdi:chart-gantt', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Timeline view', - style: { color: 'var(--primary-color, white)' }, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' }, - }); - }); - - it('should have unstyled timeline menu button in non-timeline view', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue( - createAggregateCameraCapabilities({ supportsTimeline: true }), - ); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - }); - expect(buttons).toContainEqual({ - icon: 'mdi:chart-gantt', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Timeline view', - style: {}, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' }, - }); - }); - - it('should have download menu button', () => { - vi.stubGlobal('navigator', { userAgent: 'foo' }); - - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( - createMediaCapabilities({ canDownload: true }), - ); - const view = createView({ - queryResults: new MediaQueriesResults({ - results: [new ViewMedia('clip', 'camera-1')], - selectedIndex: 0, - }), - }); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: view, - }); - expect(buttons).toContainEqual({ - icon: 'mdi:download', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Download', - tap_action: { action: 'fire-dom-event', frigate_card_action: 'download' }, - }); - }); - - it('should not have download menu button when being casted', () => { - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (Fuchsia) AppleWebKit/537.36 (KHTML, like Gecko) ' + - 'Chrome/114.0.0.0 Safari/537.36 CrKey/1.56.500000', - }); - - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( - createMediaCapabilities({ canDownload: true }), - ); - const view = createView({ - queryResults: new MediaQueriesResults({ - results: [new ViewMedia('clip', 'camera-1')], - selectedIndex: 0, - }), - }); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - view: view, - }); - expect(buttons).not.toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Download' })]), - ); - }); - it('should have camera UI button', () => { const buttons = calculateButtons(controller, { showCameraUIButton: true, }); + expect(buttons).toContainEqual({ icon: 'mdi:web', enabled: true, @@ -693,322 +773,330 @@ describe('MenuButtonController', () => { }); }); - it('should have microphone button', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, - }, - }), - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:microphone', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Microphone', - style: { - animation: 'pulse 3s infinite', - color: 'var(--error-color, white)', - }, - start_tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'microphone_unmute', - }, - end_tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'microphone_mute', - }, - }); - }); - - it('should not have microphone button when media does not support it', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: false, - }, - }), - }); - - expect(buttons).not.toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]), - ); - }); - - it('should have microphone button when microphone forbidden', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isForbidden.mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, - }, - }), - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:microphone-message-off', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Microphone', - style: {}, - }); - }); - - it('should have microphone button when microphone muted', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isMuted.mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, - }, - }), - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:microphone-off', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Microphone', - style: {}, - start_tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'microphone_unmute', - }, - end_tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'microphone_mute', - }, - }); - }); - - it('should have microphone button when microphone muted with toggle type', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isMuted.mockReturnValue(true); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, - }, - }), - config: createConfig({ - menu: { buttons: { microphone: { type: 'toggle' } } }, - }), - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:microphone-off', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Microphone', - style: {}, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'microphone_unmute', - }, - }); - }); - - it('should have microphone button when microphone unmuted with toggle type', () => { - const microphoneManager = new MicrophoneManager(createCardAPI()); - mock(microphoneManager).isMuted.mockReturnValue(false); - const buttons = calculateButtons(controller, { - microphoneManager: microphoneManager, - currentMediaLoadedInfo: createMediaLoadedInfo({ - capabilities: { - supports2WayAudio: true, - }, - }), - config: createConfig({ - menu: { buttons: { microphone: { type: 'toggle' } } }, - }), - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:microphone', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Microphone', - style: { - animation: 'pulse 3s infinite', - color: 'var(--error-color, white)', - }, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'microphone_mute', - }, - }); - }); - - it('should have fullscreen button', () => { - // Need to write a readonly property. - vi.stubGlobal('navigator', { userAgent: 'foo' }); - const buttons = calculateButtons(controller, { inFullscreenMode: false }); - - expect(buttons).toContainEqual({ - icon: 'mdi:fullscreen', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Fullscreen', - tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, - style: {}, - }); - }); - - it('should have unfullscreen', () => { - vi.stubGlobal('navigator', { userAgent: 'foo' }); - const buttons = calculateButtons(controller, { inFullscreenMode: true }); - - expect(buttons).toContainEqual({ - icon: 'mdi:fullscreen-exit', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Fullscreen', - tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, - style: { color: 'var(--primary-color, white)' }, - }); - }); - - it('should have expand button', () => { - const buttons = calculateButtons(controller, { inExpandedMode: false }); - - expect(buttons).toContainEqual({ - icon: 'mdi:arrow-expand-all', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Expand', - tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' }, - style: {}, - }); - }); - - it('should have unexpand button', () => { - const buttons = calculateButtons(controller, { inExpandedMode: true }); - - expect(buttons).toContainEqual({ - icon: 'mdi:arrow-collapse-all', - enabled: false, - priority: 50, - type: 'custom:frigate-card-menu-icon', - title: 'Expand', - tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' }, - style: { color: 'var(--primary-color, white)' }, - }); - }); - - it('should have media players button', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ - camera_entity: 'camera.1', - }), - }, - ]), - ); - - const mediaPlayerController = mock(); - mediaPlayerController.hasMediaPlayers.mockReturnValue(true); - mediaPlayerController.getMediaPlayers.mockReturnValue(['media_player.tv']); - - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - mediaPlayerController: mediaPlayerController, - hass: createHASS({ - 'media_player.tv': createStateEntity({ entity_id: 'media_player.tv' }), - }), - }); - - expect(buttons).toContainEqual({ - icon: 'mdi:cast', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-submenu', - title: 'Send to media player', - items: [ - { - enabled: true, - selected: false, - icon: 'mdi:cast', - entity: 'media_player.tv', - state_color: false, - title: 'media_player.tv', - disabled: false, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'media_player', - media_player: 'media_player.tv', - media_player_action: 'play', - }, - hold_action: { - action: 'fire-dom-event', - frigate_card_action: 'media_player', - media_player: 'media_player.tv', - media_player_action: 'stop', + describe('should have microphone button', () => { + it('with suitable loaded media', () => { + const microphoneManager = new MicrophoneManager(createCardAPI()); + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: true, }, + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:microphone', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Microphone', + style: { + animation: 'pulse 3s infinite', + color: 'var(--error-color, white)', }, - ], + start_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'microphone_unmute', + }, + end_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'microphone_mute', + }, + }); + }); + + it('without suitable loaded media', () => { + const microphoneManager = new MicrophoneManager(createCardAPI()); + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: false, + }, + }), + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]), + ); + }); + + it('with forbidden microphone', () => { + const microphoneManager = new MicrophoneManager(createCardAPI()); + mock(microphoneManager).isForbidden.mockReturnValue(true); + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: true, + }, + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:microphone-message-off', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Microphone', + style: {}, + }); + }); + + it('with muted microphone', () => { + const microphoneManager = new MicrophoneManager(createCardAPI()); + mock(microphoneManager).isMuted.mockReturnValue(true); + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: true, + }, + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:microphone-off', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Microphone', + style: {}, + start_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'microphone_unmute', + }, + end_tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'microphone_mute', + }, + }); + }); + + it('with muted toggle type microphone', () => { + const microphoneManager = new MicrophoneManager(createCardAPI()); + mock(microphoneManager).isMuted.mockReturnValue(true); + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: true, + }, + }), + config: createConfig({ + menu: { buttons: { microphone: { type: 'toggle' } } }, + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:microphone-off', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Microphone', + style: {}, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'microphone_unmute', + }, + }); + }); + + it('with unmuted toggle type microphone', () => { + const microphoneManager = new MicrophoneManager(createCardAPI()); + mock(microphoneManager).isMuted.mockReturnValue(false); + const buttons = calculateButtons(controller, { + microphoneManager: microphoneManager, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: true, + }, + }), + config: createConfig({ + menu: { buttons: { microphone: { type: 'toggle' } } }, + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:microphone', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Microphone', + style: { + animation: 'pulse 3s infinite', + color: 'var(--error-color, white)', + }, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'microphone_mute', + }, + }); }); }); - it('should disable media players button when entity not found', () => { - const cameraManager = createCameraManager(); - vi.mocked(cameraManager.getStore).mockReturnValue( - createStore([ - { - cameraID: 'camera-1', - config: createCameraConfig({ - camera_entity: 'camera.1', - }), - }, - ]), - ); - const mediaPlayerController = mock(); - mediaPlayerController.hasMediaPlayers.mockReturnValue(true); - mediaPlayerController.getMediaPlayers.mockReturnValue(['not_a_real_player']); + describe('should have fullscreen button', () => { + it('when not in fullscreen mode', () => { + // Need to write a readonly property. + vi.stubGlobal('navigator', { userAgent: 'foo' }); + const buttons = calculateButtons(controller, { inFullscreenMode: false }); - const buttons = calculateButtons(controller, { - cameraManager: cameraManager, - mediaPlayerController: mediaPlayerController, - hass: createHASS(), + expect(buttons).toContainEqual({ + icon: 'mdi:fullscreen', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Fullscreen', + tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, + style: {}, + }); }); - expect(buttons).toContainEqual({ - icon: 'mdi:cast', - enabled: true, - priority: 50, - type: 'custom:frigate-card-menu-submenu', - title: 'Send to media player', - items: [ - { - enabled: true, - selected: false, - icon: 'mdi:bookmark', - entity: 'not_a_real_player', - state_color: false, - title: 'not_a_real_player', - disabled: true, - }, - ], + it('when in fullscreen mode', () => { + vi.stubGlobal('navigator', { userAgent: 'foo' }); + const buttons = calculateButtons(controller, { inFullscreenMode: true }); + + expect(buttons).toContainEqual({ + icon: 'mdi:fullscreen-exit', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Fullscreen', + tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, + style: { color: 'var(--primary-color, white)' }, + }); + }); + }); + + describe('should have expand button', () => { + it('when not expanded', () => { + const buttons = calculateButtons(controller, { inExpandedMode: false }); + + expect(buttons).toContainEqual({ + icon: 'mdi:arrow-expand-all', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Expand', + tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' }, + style: {}, + }); + }); + + it('when expanded', () => { + const buttons = calculateButtons(controller, { inExpandedMode: true }); + + expect(buttons).toContainEqual({ + icon: 'mdi:arrow-collapse-all', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Expand', + tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' }, + style: { color: 'var(--primary-color, white)' }, + }); + }); + }); + + describe('should have media players button', () => { + it('with media players', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ + camera_entity: 'camera.1', + }), + }, + ]), + ); + + const mediaPlayerController = mock(); + mediaPlayerController.hasMediaPlayers.mockReturnValue(true); + mediaPlayerController.getMediaPlayers.mockReturnValue(['media_player.tv']); + + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + mediaPlayerController: mediaPlayerController, + hass: createHASS({ + 'media_player.tv': createStateEntity({ entity_id: 'media_player.tv' }), + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:cast', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-submenu', + title: 'Send to media player', + items: [ + { + enabled: true, + selected: false, + icon: 'mdi:cast', + entity: 'media_player.tv', + state_color: false, + title: 'media_player.tv', + disabled: false, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player: 'media_player.tv', + media_player_action: 'play', + }, + hold_action: { + action: 'fire-dom-event', + frigate_card_action: 'media_player', + media_player: 'media_player.tv', + media_player_action: 'stop', + }, + }, + ], + }); + }); + + it('when entity not found', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ + camera_entity: 'camera.1', + }), + }, + ]), + ); + const mediaPlayerController = mock(); + mediaPlayerController.hasMediaPlayers.mockReturnValue(true); + mediaPlayerController.getMediaPlayers.mockReturnValue(['not_a_real_player']); + + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + mediaPlayerController: mediaPlayerController, + hass: createHASS(), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:cast', + enabled: true, + priority: 50, + type: 'custom:frigate-card-menu-submenu', + title: 'Send to media player', + items: [ + { + enabled: true, + selected: false, + icon: 'mdi:bookmark', + entity: 'not_a_real_player', + state_color: false, + title: 'not_a_real_player', + disabled: true, + }, + ], + }); }); }); @@ -1124,6 +1212,7 @@ describe('MenuButtonController', () => { vi.mocked(cameraManager.getStore).mockReturnValue( createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]), ); + expect( calculateButtons(controller, { cameraManager: cameraManager, view: view }), ).toContainEqual({ @@ -1154,6 +1243,7 @@ describe('MenuButtonController', () => { ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + expect(buttons).not.toContainEqual({ enabled: false, icon: 'mdi:pan', @@ -1176,8 +1266,8 @@ describe('MenuButtonController', () => { vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue( createCameraCapabilities({ ptz: {} }), ); - const buttons = calculateButtons(controller, { cameraManager: cameraManager }); + expect(buttons).toContainEqual({ enabled: false, icon: 'mdi:pan', @@ -1204,11 +1294,11 @@ describe('MenuButtonController', () => { camera: 'camera-1', context: { live: { ptzVisible: false } }, }); - const buttons = calculateButtons(controller, { cameraManager: cameraManager, view: view, }); + expect(buttons).toContainEqual({ enabled: false, icon: 'mdi:pan', @@ -1223,129 +1313,181 @@ describe('MenuButtonController', () => { type: 'custom:frigate-card-menu-icon', }); }); - }); - it('should handle dynamic buttons', () => { - const button: MenuItem = { - ...dynamicButton, - style: {}, - }; - controller.addDynamicMenuButton(button); - expect( - calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) - .length, - ).toBe(1); + it('when a substream is PTZ enabled', () => { + const cameraManager = createCameraManager(); + vi.mocked(cameraManager.getStore).mockReturnValue( + createStore([ + { + cameraID: 'camera-1', + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { cameraID: 'camera-2' }, + ]), + ); + vi.mocked(cameraManager.getCameraCapabilities).mockImplementation( + (cameraID: string) => { + if (cameraID === 'camera-2') { + return createCameraCapabilities({ ptz: {} }); + } + return createCameraCapabilities(); + }, + ); + const view = createView({ + camera: 'camera-1', + context: { + live: { + overrides: new Map([['camera-1', 'camera-2']]), + }, + }, + }); + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + view: view, + }); - // Adding it again will have no effect. - controller.addDynamicMenuButton(button); - expect( - calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) - .length, - ).toBe(1); - - controller.removeDynamicMenuButton(button); - expect(calculateButtons(controller)).not.toContainEqual(button); - }); - - it('should not set style for dynamic button with stock action', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: { action: 'navigate', navigation_path: 'foo' }, - }; - controller.addDynamicMenuButton(button); - - expect(calculateButtons(controller)).toContainEqual({ - ...button, - style: {}, + expect(buttons).toContainEqual({ + enabled: false, + icon: 'mdi:pan', + priority: 50, + style: { + color: 'var(--primary-color, white)', + }, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'show_ptz', + show_ptz: false, + }, + title: 'Show PTZ controls', + type: 'custom:frigate-card-menu-icon', + }); }); }); - it('should not set style for dynamic button with non-Frigate fire-dom-event action', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: { action: 'fire-dom-event' }, - }; - controller.addDynamicMenuButton(button); + describe('should handle dynamic buttons', () => { + it('successfully', () => { + const button: MenuItem = { + ...dynamicButton, + style: {}, + }; + controller.addDynamicMenuButton(button); - controller.addDynamicMenuButton(dynamicButton); - expect(calculateButtons(controller)).toContainEqual({ - ...button, - style: {}, + expect( + calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) + .length, + ).toBe(1); + + // Adding it again will have no effect. + controller.addDynamicMenuButton(button); + expect( + calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton)) + .length, + ).toBe(1); + + controller.removeDynamicMenuButton(button); + expect(calculateButtons(controller)).not.toContainEqual(button); }); - }); - it('should set style for dynamic button with Frigate view action', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, - }; + it('with stock HA action', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: { action: 'navigate', navigation_path: 'foo' }, + }; + controller.addDynamicMenuButton(button); - const view = createView({ view: 'clips' }); - controller.addDynamicMenuButton(button); - expect(calculateButtons(controller, { view: view })).toContainEqual({ - ...button, - style: { color: 'var(--primary-color, white)' }, + expect(calculateButtons(controller)).toContainEqual({ + ...button, + style: {}, + }); }); - }); - it('should set style for dynamic button with Frigate default action', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'default' }, - }; + it('with non-Frigate fire-dom-event action', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: { action: 'fire-dom-event' }, + }; + controller.addDynamicMenuButton(button); + controller.addDynamicMenuButton(dynamicButton); - controller.addDynamicMenuButton(button); - expect(calculateButtons(controller)).toContainEqual({ - ...button, - style: { color: 'var(--primary-color, white)' }, + expect(calculateButtons(controller)).toContainEqual({ + ...button, + style: {}, + }); }); - }); - it('should set style for dynamic button with fullscreen action', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, - }; + it('with Frigate view action', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' }, + }; + const view = createView({ view: 'clips' }); + controller.addDynamicMenuButton(button); - controller.addDynamicMenuButton(button); - expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({ - ...button, - style: { color: 'var(--primary-color, white)' }, + expect(calculateButtons(controller, { view: view })).toContainEqual({ + ...button, + style: { color: 'var(--primary-color, white)' }, + }); }); - }); - it('should set style for dynamic button with camera_select action', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: { - action: 'fire-dom-event', - frigate_card_action: 'camera_select', - camera: 'foo', - }, - }; + it('with Frigate default action', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'default' }, + }; + controller.addDynamicMenuButton(button); - const view = createView({ camera: 'foo' }); - controller.addDynamicMenuButton(button); - expect(calculateButtons(controller, { view: view })).toContainEqual({ - ...button, - style: { color: 'var(--primary-color, white)' }, + expect(calculateButtons(controller)).toContainEqual({ + ...button, + style: { color: 'var(--primary-color, white)' }, + }); }); - }); - it('should set style for dynamic button with array of actions', () => { - const button: MenuItem = { - ...dynamicButton, - tap_action: [ - { action: 'fire-dom-event' }, - { action: 'fire-dom-event', frigate_card_action: 'clips' }, - ], - }; + it('with fullscreen action', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, + }; + controller.addDynamicMenuButton(button); - const view = createView({ camera: 'clips' }); - controller.addDynamicMenuButton(button); - expect(calculateButtons(controller, { view: view })).toContainEqual({ - ...button, - style: {}, + expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({ + ...button, + style: { color: 'var(--primary-color, white)' }, + }); + }); + + it('with camera_select action', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: { + action: 'fire-dom-event', + frigate_card_action: 'camera_select', + camera: 'foo', + }, + }; + const view = createView({ camera: 'foo' }); + controller.addDynamicMenuButton(button); + + expect(calculateButtons(controller, { view: view })).toContainEqual({ + ...button, + style: { color: 'var(--primary-color, white)' }, + }); + }); + + it('with array of actions', () => { + const button: MenuItem = { + ...dynamicButton, + tap_action: [ + { action: 'fire-dom-event' }, + { action: 'fire-dom-event', frigate_card_action: 'clips' }, + ], + }; + const view = createView({ camera: 'clips' }); + controller.addDynamicMenuButton(button); + + expect(calculateButtons(controller, { view: view })).toContainEqual({ + ...button, + style: {}, + }); }); }); });