import { isEqual } from 'lodash-es'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { Capabilities } from '../../src/camera-manager/capabilities.js'; import { CameraManager } from '../../src/camera-manager/manager.js'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js'; import { FoldersManager } from '../../src/card-controller/folders/manager.js'; import { FolderQuery } from '../../src/card-controller/folders/types'; import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js'; import { ViewManager } from '../../src/card-controller/view/view-manager.js'; import { MenuButtonController, MenuButtonControllerOptions, } from '../../src/components-lib/menu-button-controller.js'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const.js'; import { ViewDisplayMode } from '../../src/config/schema/common/display.js'; import { MenuItem } from '../../src/config/schema/elements/custom/menu/types.js'; import { AdvancedCameraCardConfig } from '../../src/config/schema/types.js'; import { HomeAssistant } from '../../src/ha/types.js'; import { QuerySource } from '../../src/query-source'; import { MediaPlayerController, PTZMovementType } from '../../src/types.js'; import { createGeneralAction, createViewAction } from '../../src/utils/action.js'; import { ViewMedia, ViewMediaType } from '../../src/view/item.js'; import { QueryResults } from '../../src/view/query-results.js'; import { UnifiedQuery } from '../../src/view/unified-query.js'; import { getCameraIDsWithCapabilityForView, isViewSupported, } from '../../src/view/view-support.js'; import { View } from '../../src/view/view.js'; import { createCameraConfig, createCameraManager, createCapabilities, createConfig, createFolder, createHASS, createMediaCapabilities, createMediaLoadedInfo, createStateEntity, createStore, createView, TestViewMedia, } from '../test-utils.js'; vi.mock('../../src/view/view-support.js'); vi.mock('../../src/utils/media-player-controller.js'); vi.mock('../../src/card-controller/microphone-manager.js'); const calculateButtons = ( controller: MenuButtonController, options?: MenuButtonControllerOptions & { hass?: HomeAssistant; config?: AdvancedCameraCardConfig; cameraManager?: CameraManager; foldersManager?: FoldersManager; view?: View | null; viewManager?: ViewManager; }, ): MenuItem[] => { let cameraManager: CameraManager | null = options?.cameraManager ?? null; if (!cameraManager) { cameraManager = createCameraManager(); } return controller.calculateButtons( options?.hass ?? createHASS(), options?.config ?? createConfig(), cameraManager, options?.foldersManager ?? mock(), { ...options, view: options?.view === undefined ? createView({ camera: 'camera-1' }) : options.view, }, ); }; // @vitest-environment jsdom describe('MenuButtonController', () => { let controller: MenuButtonController; const dynamicButton: MenuItem = { type: 'custom:advanced-camera-card-menu-icon', icon: 'mdi:alpha-a-circle', title: 'Dynamic button', }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(isViewSupported).mockReturnValue(true); controller = new MenuButtonController(); }); describe('should have iris menu button', () => { it('with hidden menu style', () => { const buttons = calculateButtons(controller); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, icon: 'iris', enabled: true, permanent: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Iris / Default View / Unhide menu', tap_action: createGeneralAction('menu_toggle'), hold_action: createViewAction('diagnostics'), }); }); it('without hidden menu style', () => { const buttons = calculateButtons(controller, { config: createConfig({ menu: { style: 'overlay' } }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, icon: 'iris', enabled: true, permanent: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Iris / Default View / Unhide menu', tap_action: createGeneralAction('default'), hold_action: createViewAction('diagnostics'), }); }); }); describe('should have cameras menu', () => { it('should have cameras menu with multiple cameras', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ menu: true }) }, { cameraID: 'camera-2', capabilities: createCapabilities({ menu: true }) }, { cameraID: 'camera-3', capabilities: createCapabilities({ menu: false }) }, ]), ); vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ title: 'title', icon: { icon: 'icon', }, }); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:video-switch', enabled: true, priority: 50, type: 'custom:advanced-camera-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', advanced_camera_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', advanced_camera_card_action: 'camera_select', camera: 'camera-2', }, }, ], }); }); it('should not have cameras menu with <= 1 camera', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ menu: true }) }, { cameraID: 'camera-3', capabilities: createCapabilities({ menu: false }) }, ]), ); vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ title: 'title', icon: { icon: 'icon', }, }); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).not.toEqual( expect.arrayContaining([ expect.objectContaining({ title: 'Cameras', }), ]), ); }); }); describe('should have substream button', () => { it('with no view', () => { const buttons = calculateButtons(controller, { cameraManager: createCameraManager(), view: null, }); expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'Substream(s)', }), ); }); it('with no dependency', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ substream: true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'Substream(s)', }), ); }); 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', capabilities: createCapabilities({ substream: true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:video-input-component', style: {}, title: 'Substream(s)', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_on', }, }); }); 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', capabilities: createCapabilities({ substream: true }), }, ]), ); 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({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:video-input-component', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, title: 'Substream(s)', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_off', }, }); }); it('with substream unselected 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'] }, }), capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-2', config: createCameraConfig({ camera_entity: 'camera.2', }), capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-3', config: createCameraConfig({ camera_entity: 'camera.3', }), capabilities: createCapabilities({ substream: true }), }, ]), ); // 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: 'icon', entity: 'entity', }, } : null; }, ); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:video-input-component', title: 'Substream(s)', style: {}, enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-submenu', items: [ { enabled: true, icon: 'icon', entity: 'entity', state_color: true, title: 'title', selected: true, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_select', camera: 'camera-1', }, }, { enabled: true, icon: undefined, entity: undefined, state_color: true, title: undefined, selected: false, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_select', camera: 'camera-2', }, }, { enabled: true, icon: undefined, entity: undefined, state_color: true, title: undefined, selected: false, tap_action: { action: 'fire-dom-event', advanced_camera_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'] }, }), capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-2', config: createCameraConfig({ camera_entity: 'camera.2', }), capabilities: createCapabilities({ substream: true }), }, { cameraID: 'camera-3', config: createCameraConfig({ camera_entity: 'camera.3', }), capabilities: createCapabilities({ substream: true }), }, ]), ); 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({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:video-input-component', title: 'Substream(s)', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-submenu', items: [ { enabled: true, icon: undefined, entity: undefined, state_color: true, title: undefined, selected: false, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_select', camera: 'camera-1', }, }, { enabled: true, icon: undefined, entity: undefined, 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', advanced_camera_card_action: 'live_substream_select', camera: 'camera-2', }, }, { enabled: true, icon: undefined, entity: undefined, state_color: true, title: undefined, selected: false, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live_substream_select', camera: 'camera-3', }, }, ], }); }); }); describe('should have live menu button', () => { it('when in live view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:cctv', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Live view', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, }); }); it('when not in live view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:cctv', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Live view', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'live' }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'Live view', }), ); }); }); describe('should have clips menu button', () => { it('when in clips view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'clips' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:filmstrip', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Clips gallery', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'clips' }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'clip' }, }); }); it('when not in clips view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:filmstrip', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Clips gallery', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'clips' }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'clip' }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).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(isViewSupported).mockImplementation((view) => view !== 'reviews'); const buttons = calculateButtons(controller, { view: createView({ view: 'snapshots' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:camera', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Snapshots gallery', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'snapshots', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'snapshot', }, }); }); it('when not in snapshots view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:camera', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Snapshots gallery', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'snapshots', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'snapshot', }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).not.toEqual( expect.arrayContaining([ expect.objectContaining({ title: 'Snapshots gallery' }), ]), ); }); }); describe('should have reviews menu button', () => { it('when in reviews view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'reviews' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:play-box-edit-outline', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Reviews gallery', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'reviews', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'review', }, }); }); it('when not in reviews view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:play-box-edit-outline', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Reviews gallery', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'reviews', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'review', }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).not.toEqual( expect.arrayContaining([expect.objectContaining({ title: 'Reviews gallery' })]), ); }); }); describe('should have gallery menu button', () => { it('when in gallery view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'gallery' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:play-box-multiple', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Gallery', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'gallery', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'media', }, }); }); it('when not in gallery view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:play-box-multiple', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Gallery', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'gallery', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'media', }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).not.toEqual( expect.arrayContaining([expect.objectContaining({ title: 'Gallery' })]), ); }); }); describe('should have recordings menu button', () => { it('when in recordings view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'recordings' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:album', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Recordings gallery', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'recordings', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'recording', }, }); }); it('when not in recordings view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:album', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Recordings gallery', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'recordings', }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'recording', }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).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 viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'image' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:image', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Static image', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'image' }, }); }); it('when not in image view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:image', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Static image', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'image' }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { viewManager: viewManager, }); expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'Static image', }), ); }); }); describe('should have timeline button', () => { it('when in timeline view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'timeline' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:chart-gantt', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Timeline view', style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'timeline', }, }); }); it('when not in timeline view', () => { const viewManager = mock(); vi.mocked(isViewSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { view: createView({ view: 'live' }), viewManager: viewManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:chart-gantt', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Timeline view', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'timeline', }, }); }); it('when not supported', () => { const viewManager = mock(); vi.mocked(isViewSupported).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({ view: 'media', queryResults: new QueryResults({ results: [ new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1', }), ], selectedIndex: 0, }), }); const buttons = calculateButtons(controller, { cameraManager: cameraManager, view: view, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:download', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Download', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'download', }, }); }); 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', }); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( createMediaCapabilities({ canDownload: true }), ); const view = createView({ queryResults: new QueryResults({ results: [new ViewMedia(ViewMediaType.Clip, { cameraID: 'camera-1' })], selectedIndex: 0, }), }); const buttons = calculateButtons(controller, { cameraManager: cameraManager, view: view, }); expect(buttons).not.toEqual( expect.arrayContaining([expect.objectContaining({ title: 'Download' })]), ); }); it('not in a non-media view', () => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue( createMediaCapabilities({ canDownload: true }), ); const view = createView({ view: 'live', queryResults: new QueryResults({ results: [new ViewMedia(ViewMediaType.Clip, { cameraID: '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({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:web', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Camera user interface', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'camera_ui' }, }); }); describe('should have microphone button', () => { it('when camera has 2-way-audio capability', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); const cameraManager = createCameraManager( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ '2-way-audio': true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:microphone', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Microphone', style: { animation: 'pulse 3s infinite', color: 'var(--advanced-camera-card-menu-button-critical-color)', }, start_tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'microphone_unmute', }, end_tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'microphone_mute', }, }); }); it('when camera does not have 2-way-audio capability', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); const cameraManager = createCameraManager( createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, }); expect(buttons).not.toEqual( expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]), ); }); it('with forbidden microphone', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(true); const cameraManager = createCameraManager( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ '2-way-audio': true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:microphone-message-off', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Microphone', style: {}, }); }); it('with muted microphone', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); const cameraManager = createCameraManager( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ '2-way-audio': true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:microphone-off', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Microphone', style: {}, start_tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'microphone_unmute', }, end_tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'microphone_mute', }, }); }); it('with unsupported microphone', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(false); const cameraManager = createCameraManager( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ '2-way-audio': true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:microphone-message-off', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Microphone', style: {}, }); }); it('with muted toggle type microphone', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); const cameraManager = createCameraManager( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ '2-way-audio': true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, config: createConfig({ menu: { buttons: { microphone: { type: 'toggle' } } }, }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:microphone-off', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Microphone', style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'microphone_unmute', }, }); }); it('with unmuted toggle type microphone', () => { const microphoneManager = mock(); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); const cameraManager = createCameraManager( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ '2-way-audio': true }), }, ]), ); const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, config: createConfig({ menu: { buttons: { microphone: { type: 'toggle' } } }, }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:microphone', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Microphone', style: { animation: 'pulse 3s infinite', color: 'var(--advanced-camera-card-menu-button-critical-color)', }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'microphone_mute', }, }); }); }); describe('should have fullscreen button', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('when not in fullscreen mode', () => { // Need to write a readonly property. vi.stubGlobal('navigator', { userAgent: 'foo' }); const fullscreenManager = mock(); vi.mocked(fullscreenManager.isInFullscreen).mockReturnValue(false); vi.mocked(fullscreenManager.isSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { fullscreenManager }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:fullscreen', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Fullscreen', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'fullscreen', }, style: {}, }); }); it('when in fullscreen mode', () => { vi.stubGlobal('navigator', { userAgent: 'foo' }); const fullscreenManager = mock(); vi.mocked(fullscreenManager.isInFullscreen).mockReturnValue(true); vi.mocked(fullscreenManager.isSupported).mockReturnValue(true); const buttons = calculateButtons(controller, { fullscreenManager }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:fullscreen-exit', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Fullscreen', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'fullscreen', }, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); }); it('when not supported', () => { // Need to write a readonly property. vi.stubGlobal('navigator', { userAgent: 'foo' }); const fullscreenManager = mock(); vi.mocked(fullscreenManager.isSupported).mockReturnValue(false); const buttons = calculateButtons(controller, { fullscreenManager }); expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'Fullscreen' }), ); }); }); describe('should have expand button', () => { it('when not expanded', () => { const buttons = calculateButtons(controller, { inExpandedMode: false }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:arrow-expand-all', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Expand', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'expand' }, style: {}, }); }); it('when expanded', () => { const buttons = calculateButtons(controller, { inExpandedMode: true }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:arrow-collapse-all', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Expand', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'expand' }, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); }); }); 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({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:cast', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-submenu', title: 'Send to media player', items: [ { enabled: true, selected: false, entity: 'media_player.tv', state_color: false, title: 'media_player.tv', disabled: false, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'media_player', media_player: 'media_player.tv', media_player_action: 'play', }, hold_action: { action: 'fire-dom-event', advanced_camera_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({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:cast', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-submenu', title: 'Send to media player', items: [ { enabled: true, selected: false, entity: 'not_a_real_player', state_color: false, title: 'not_a_real_player', disabled: true, }, ], }); }); }); it('should have pause button', () => { const mediaPlayerController = mock(); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { supportsPause: true, }, mediaPlayerController, }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:pause', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Play / Pause', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'pause' }, }); }); it('should have play button', () => { const mediaPlayerController = mock(); mediaPlayerController.isPaused.mockReturnValue(true); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { supportsPause: true, }, mediaPlayerController, }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:play', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Play / Pause', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'play' }, }); }); it('should have mute button', () => { const mediaPlayerController = mock(); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { hasAudio: true, }, mediaPlayerController, }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:volume-high', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Mute / Unmute', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'mute' }, }); }); it('should have unmute button', () => { const mediaPlayerController = mock(); mediaPlayerController.isMuted.mockReturnValue(true); const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ capabilities: { hasAudio: true, }, mediaPlayerController, }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:volume-off', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Mute / Unmute', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'unmute' }, }); }); it('should have screenshot button', () => { const buttons = calculateButtons(controller, { currentMediaLoadedInfo: createMediaLoadedInfo({ mediaPlayerController: mock(), }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:monitor-screenshot', enabled: false, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: 'Screenshot', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'screenshot', }, }); }); describe('should have grid button when display mode is', () => { it.each([['single' as const], ['grid' as const]])( '%s', (displayMode: ViewDisplayMode) => { const view = createView({ camera: 'camera-1', view: 'live', displayMode: displayMode, }); const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( createStore([ { cameraID: 'camera-1', capabilities: createCapabilities({ live: true }) }, { cameraID: 'camera-2', capabilities: createCapabilities({ live: true }) }, ]), ); vi.mocked(getCameraIDsWithCapabilityForView).mockReturnValue( new Set(['camera-1', 'camera-2']), ); expect( calculateButtons(controller, { cameraManager: cameraManager, view: view }), ).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-icon', title: displayMode === 'grid' ? 'Show single media viewer' : 'Show media viewer for each camera in a grid', style: displayMode === 'grid' ? { color: 'var(--advanced-camera-card-menu-button-active-color)' } : {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'display_mode_select', display_mode: displayMode === 'single' ? 'grid' : 'single', }, }); }, ); }); describe('should have show ptz button', () => { it('should not show when not in live view', () => { const store = createStore([ { cameraID: 'camera-1', capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const buttons = calculateButtons(controller, { cameraManager: createCameraManager(store), view: createView({ view: 'clips' }), }); expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'Show PTZ controls', }), ); }); it('should show when in live view', () => { const store = createStore([ { cameraID: 'camera-1', capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const buttons = calculateButtons(controller, { cameraManager: createCameraManager(store), view: createView({ view: 'live' }), }); expect(buttons).toContainEqual( expect.objectContaining({ title: 'Show PTZ controls', }), ); }); it('should show when the context has PTZ enabled', () => { const store = createStore([ { cameraID: 'camera-1', capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const view = createView({ camera: 'camera-1', context: { ptzControls: { enabled: true } }, }); const buttons = calculateButtons(controller, { cameraManager: createCameraManager(store), view: view, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, style: { color: 'var(--advanced-camera-card-menu-button-active-color)', }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'ptz_controls', enabled: false, }, title: 'Show PTZ controls', type: 'custom:advanced-camera-card-menu-icon', }); }); it('should show when the context has PTZ disabled', () => { const store = createStore([ { cameraID: 'camera-1', capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const view = createView({ camera: 'camera-1', context: { ptzControls: { enabled: false } }, }); const buttons = calculateButtons(controller, { cameraManager: createCameraManager(store), view: view, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'ptz_controls', enabled: true, }, title: 'Show PTZ controls', type: 'custom:advanced-camera-card-menu-icon', }); }); it('should detect current status without context in auto mode', () => { const store = createStore([ { cameraID: 'camera-1', capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const view = createView({ camera: 'camera-1', }); const buttons = calculateButtons(controller, { cameraManager: createCameraManager(store), config: createConfig({ live: { controls: { ptz: { mode: 'auto' } } } }), view: view, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, style: { color: 'var(--advanced-camera-card-menu-button-active-color)', }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'ptz_controls', enabled: false, }, title: 'Show PTZ controls', type: 'custom:advanced-camera-card-menu-icon', }); }); it('should show when a substream is PTZ enabled', () => { const store = createStore([ { cameraID: 'camera-1', config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), }, { cameraID: 'camera-2', capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); const view = createView({ camera: 'camera-1', context: { live: { overrides: new Map([['camera-1', 'camera-2']]), }, }, }); const buttons = calculateButtons(controller, { cameraManager: createCameraManager(store), view: view, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, enabled: false, icon: 'mdi:pan', priority: 50, style: { color: 'var(--advanced-camera-card-menu-button-active-color)', }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'ptz_controls', enabled: false, }, title: 'Show PTZ controls', type: 'custom:advanced-camera-card-menu-icon', }); }); }); describe('should have ptz home button', () => { it.each([ ['live' as const, true, false], ['live' as const, undefined, false], ['live' as const, false, true], ['media' as const, true, false], ['media' as const, undefined, false], ['media' as const, false, true], ])( '%s view with isDefault %s', ( viewName: AdvancedCameraCardView, isDefault: boolean | undefined, expectedResult: boolean, ) => { const cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore).mockReturnValue( createStore([{ cameraID: 'camera-1' }]), ); const targetID = viewName === 'live' ? 'camera-1' : 'media-1'; const view = createView({ view: viewName, camera: 'camera-1', queryResults: new QueryResults({ results: [new TestViewMedia({ id: 'media-1' })], selectedIndex: 0, }), ...(isDefault !== undefined && { context: { zoom: { [targetID]: { observed: { isDefault: isDefault, unzoomed: false, zoom: 1, pan: { x: 50, y: 50, }, }, }, }, }, }), }); const buttons = calculateButtons(controller, { cameraManager: cameraManager, view: view, }); if (expectedResult) { expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, enabled: false, icon: 'mdi:home', priority: 50, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'ptz_multi', target_id: targetID, }, title: 'PTZ Home', type: 'custom:advanced-camera-card-menu-icon', }); } else { expect(buttons).not.toContainEqual( expect.objectContaining({ title: 'PTZ Home' }), ); } }, ); }); describe('should have folders button', () => { it('should have no folders button without folders', () => { const foldersManager = mock(); foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue(new Map().entries()); const buttons = calculateButtons(controller, { foldersManager, }); expect(buttons).not.toEqual( expect.arrayContaining([ expect.objectContaining({ icon: 'mdi:folder', }), ]), ); }); it('should have a folders button for a single folder outside the folder view', () => { const foldersManager = mock(); foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue( new Map([['folder-0', createFolder({ id: 'folder-0' })]]).entries(), ); const buttons = calculateButtons(controller, { foldersManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:folder', enabled: true, priority: 50, style: {}, type: 'custom:advanced-camera-card-menu-icon', title: 'Folders', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folders' }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder' }, }); }); it('should have a folders button for a single folder inside the folder view', () => { const folder = createFolder({ id: 'folder-0' }); const foldersManager = mock(); foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue( new Map([['folder-0', folder]]).entries(), ); const folderNode: FolderQuery = { source: QuerySource.Folder, folder: folder, path: [{ ha: { id: 'one' } }], }; const buttons = calculateButtons(controller, { foldersManager, view: createView({ view: 'folder', query: new UnifiedQuery().addNode(folderNode), }), }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:folder', enabled: true, priority: 50, style: { color: 'var(--advanced-camera-card-menu-button-active-color)', }, type: 'custom:advanced-camera-card-menu-icon', title: 'Folders', tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folders' }, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder' }, }); }); it('should have a folders submenu for multiple folder with a selected folder', () => { const foldersManager = mock(); const selectedFolder = createFolder({ id: 'folder-selected' }); const folders = new Map([ ['folder-0', createFolder({ id: 'folder-0' })], ['folder-selected', selectedFolder], ]); foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue(folders.entries()); const selectedFolderNode: FolderQuery = { source: QuerySource.Folder, folder: selectedFolder, path: [{ ha: { id: 'id' } }], }; const view = createView({ view: 'folder', query: new UnifiedQuery().addNode(selectedFolderNode), }); const buttons = calculateButtons(controller, { foldersManager, view, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:folder-multiple', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-submenu', title: 'Folders', style: { color: 'var(--advanced-camera-card-menu-button-active-color)', }, items: [ { enabled: true, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder', folder: 'folder-0', }, icon: 'mdi:folder', selected: false, style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folders', folder: 'folder-0', }, title: 'folder-0', }, { enabled: true, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder', folder: 'folder-selected', }, icon: 'mdi:folder', selected: true, style: { color: 'var(--advanced-camera-card-menu-button-active-color)', }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folders', folder: 'folder-selected', }, title: 'folder-selected', }, ], }); }); it('should have a folders submenu for multiple folder without a selected folder', () => { const foldersManager = mock(); const folders = new Map([ ['folder-0', createFolder({ id: 'folder-0' })], ['folder-1', createFolder({ id: 'folder-1' })], ]); foldersManager.hasFolders.mockReturnValue(true); foldersManager.getFolders.mockReturnValue(folders.entries()); const buttons = calculateButtons(controller, { foldersManager, }); expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, permanent: false, icon: 'mdi:folder-multiple', enabled: true, priority: 50, type: 'custom:advanced-camera-card-menu-submenu', title: 'Folders', style: {}, items: [ { enabled: true, icon: 'mdi:folder', hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder', folder: 'folder-0', }, selected: false, style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folders', folder: 'folder-0', }, title: 'folder-0', }, { enabled: true, hold_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folder', folder: 'folder-1', }, icon: 'mdi:folder', selected: false, style: {}, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'folders', folder: 'folder-1', }, title: 'folder-1', }, ], }); }); }); describe('should handle dynamic buttons', () => { it('successfully', () => { const button: MenuItem = { ...dynamicButton, style: {}, }; controller.addDynamicMenuButton(button); 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('with stock HA action', () => { const button: MenuItem = { ...dynamicButton, tap_action: { action: 'navigate', navigation_path: 'foo' }, }; controller.addDynamicMenuButton(button); expect(calculateButtons(controller)).toContainEqual({ ...button, style: {}, }); }); it('with non-advanced-camera-card fire-dom-event action', () => { const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event' }, }; controller.addDynamicMenuButton(button); controller.addDynamicMenuButton(dynamicButton); expect(calculateButtons(controller)).toContainEqual({ ...button, style: {}, }); }); it('with advanced camera card view action', () => { const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'clips' }, }; const view = createView({ view: 'clips' }); controller.addDynamicMenuButton(button); expect(calculateButtons(controller, { view: view })).toContainEqual({ ...button, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); }); it('with advanced camera card default action', () => { const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'default' }, }; const cameraManager = createCameraManager(createStore([{ cameraID: 'camera-1' }])); controller.addDynamicMenuButton(button); expect(calculateButtons(controller, { cameraManager })).toContainEqual({ ...button, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); }); it('with fullscreen action', () => { const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'fullscreen', }, }; controller.addDynamicMenuButton(button); const fullscreenManager = mock(); vi.mocked(fullscreenManager.isInFullscreen).mockReturnValue(true); vi.mocked(fullscreenManager.isSupported).mockReturnValue(true); expect(calculateButtons(controller, { fullscreenManager })).toContainEqual({ ...button, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); }); it('with camera_select action', () => { const button: MenuItem = { ...dynamicButton, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'camera_select', camera: 'foo', }, }; const view = createView({ camera: 'foo' }); controller.addDynamicMenuButton(button); expect(calculateButtons(controller, { view: view })).toContainEqual({ ...button, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, }); }); it('with array of actions', () => { const button: MenuItem = { ...dynamicButton, tap_action: [ { action: 'fire-dom-event' }, { action: 'fire-dom-event', advanced_camera_card_action: 'clips' }, ], }; const view = createView({ camera: 'clips' }); controller.addDynamicMenuButton(button); expect(calculateButtons(controller, { view: view })).toContainEqual({ ...button, style: {}, }); }); }); describe('should have set review button', () => { it('when unreviewed', () => { const selectedItem = new TestViewMedia({ mediaType: ViewMediaType.Review, reviewed: false, }); const queryResults = mock(); queryResults.getSelectedResult.mockReturnValue(selectedItem); const view = createView({ view: 'media', queryResults: queryResults, }); const buttons = calculateButtons(controller, { view: view }); expect(buttons).toContainEqual( expect.objectContaining({ icon: 'mdi:check-circle-outline', title: 'Mark as reviewed', }), ); }); it('when already reviewed', () => { const selectedItem = new TestViewMedia({ mediaType: ViewMediaType.Review, reviewed: true, }); const queryResults = mock(); queryResults.getSelectedResult.mockReturnValue(selectedItem); const view = createView({ view: 'media', queryResults: queryResults, }); const buttons = calculateButtons(controller, { view: view }); expect(buttons).toContainEqual( expect.objectContaining({ icon: 'mdi:check-circle', title: 'Mark as unreviewed', }), ); }); it('when isReviewed returns null', () => { const selectedItem = new TestViewMedia({ mediaType: ViewMediaType.Review, reviewed: null, }); const queryResults = mock(); queryResults.getSelectedResult.mockReturnValue(selectedItem); const view = createView({ view: 'media', queryResults: queryResults, }); const buttons = calculateButtons(controller, { view: view }); expect(buttons).not.toEqual( expect.arrayContaining([ expect.objectContaining({ icon: 'mdi:check-circle', }), ]), ); expect(buttons).not.toEqual( expect.arrayContaining([ expect.objectContaining({ icon: 'mdi:check-circle-outline', }), ]), ); }); }); it('should handle view without camera', () => { const buttons = calculateButtons(controller, { view: createView({ camera: null }), }); expect(buttons).toBeDefined(); }); });