Files
advanced-camera-card/tests/components-lib/menu-button-controller.test.ts
T
Dermot Duffy abcba884e5 feat: Add 'call' support to improve 2-way audio experience (#2486)
Draws significant inspiration (and direct styling) from
https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank
you @Maudfer !

BREAKING CHANGE:

The microphone condition previously bundled two unrelated signals —
whether a two-way-audio session was connected and whether the microphone
was muted. Connection state is now its own dedicated call condition, and
microphone is reserved purely for mute state. Configs are upgraded
automatically (the card rewrites affected conditions under overrides,
elements, and automations). If you maintain config by hand, convert as
follows:

If you only used connected:

# Before
```yaml
condition: microphone
connected: true
```

# After
```yaml
condition: call
call: true
```
If you used both connected and muted — they must be split into two
conditions, since they no longer live together:

# Before
```yaml
condition: microphone
connected: true
muted: false
```

# After
```yaml
condition: and
conditions:
  - condition: call
    call: true
  - condition: microphone
    muted: false
```
2026-06-30 17:45:13 -07:00

2809 lines
85 KiB
TypeScript

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 { CallManager } from '../../src/card-controller/call/manager.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 { PIPManager } from '../../src/card-controller/pip-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<FoldersManager>(),
{
...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>(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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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<ViewManager>();
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 call button', () => {
it('with no view', () => {
const buttons = calculateButtons(controller, {
cameraManager: createCameraManager(),
view: null,
});
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Start 2-way audio call' }),
]),
);
});
it('with a non-live view', () => {
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
);
const buttons = calculateButtons(controller, {
cameraManager,
view: createView({ camera: 'camera-1', view: 'clips' }),
});
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Start 2-way audio call' }),
]),
);
});
it('when no camera supports 2-way audio', () => {
const cameraManager = createCameraManager(
createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]),
);
const buttons = calculateButtons(controller, { cameraManager });
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ title: 'Start 2-way audio call' }),
]),
);
});
it('with a single 2-way-audio target', () => {
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
);
const buttons = calculateButtons(controller, { cameraManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:phone',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Start 2-way audio call',
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'call_start',
},
});
});
it('with multiple 2-way-audio targets', () => {
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ '2-way-audio': true }),
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
},
{
cameraID: 'camera-2',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
);
const buttons = calculateButtons(controller, { cameraManager });
expect(buttons).toContainEqual(
expect.objectContaining({
icon: 'mdi:phone',
title: 'Start 2-way audio call',
type: 'custom:advanced-camera-card-menu-submenu',
items: [
expect.objectContaining({
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'call_start',
camera: 'camera-1',
},
}),
expect.objectContaining({
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'call_start',
camera: 'camera-1',
stream: 'camera-2',
},
}),
],
}),
);
});
it('when a call is active', () => {
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
);
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
const buttons = calculateButtons(controller, {
cameraManager,
callManager,
view: createView({ camera: 'camera-1' }),
});
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:phone-hangup',
enabled: true,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'End 2-way audio call',
style: {
animation: 'pulse 3s infinite',
color: 'var(--advanced-camera-card-menu-button-critical-color)',
},
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'call_end',
},
});
});
});
describe('should have microphone button', () => {
it('when camera has 2-way-audio capability', () => {
const microphoneManager = mock<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
});
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<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
});
expect(buttons).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]),
);
});
it('is not shown without an active call', () => {
const microphoneManager = mock<MicrophoneManager>();
vi.mocked(microphoneManager.isForbidden).mockReturnValue(false);
vi.mocked(microphoneManager.isMuted).mockReturnValue(false);
vi.mocked(microphoneManager.isSupported).mockReturnValue(true);
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(false);
const cameraManager = createCameraManager(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ '2-way-audio': true }),
},
]),
);
const buttons = calculateButtons(controller, {
cameraManager,
microphoneManager,
callManager,
});
expect(buttons).not.toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]),
);
});
it('with forbidden microphone', () => {
const microphoneManager = mock<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
});
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<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
});
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<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
});
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<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
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<MicrophoneManager>();
const callManager = mock<CallManager>();
vi.mocked(callManager.isActive).mockReturnValue(true);
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,
callManager,
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<FullscreenManager>();
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<FullscreenManager>();
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<FullscreenManager>();
vi.mocked(fullscreenManager.isSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { fullscreenManager });
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Fullscreen' }),
);
});
});
describe('should have pip button', () => {
it('when not in PIP mode', () => {
const pipManager = mock<PIPManager>();
vi.mocked(pipManager.isInPIP).mockReturnValue(false);
vi.mocked(pipManager.isAvailable).mockReturnValue(true);
const buttons = calculateButtons(controller, { pipManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:picture-in-picture-bottom-right',
enabled: false,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Picture in Picture',
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'pip',
},
style: {},
});
});
it('when in PIP mode', () => {
const pipManager = mock<PIPManager>();
vi.mocked(pipManager.isInPIP).mockReturnValue(true);
vi.mocked(pipManager.isAvailable).mockReturnValue(true);
const buttons = calculateButtons(controller, { pipManager });
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
permanent: false,
icon: 'mdi:picture-in-picture-bottom-right-outline',
enabled: false,
priority: 50,
type: 'custom:advanced-camera-card-menu-icon',
title: 'Picture in Picture',
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'pip',
},
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
});
});
it('when not supported', () => {
const pipManager = mock<PIPManager>();
vi.mocked(pipManager.isAvailable).mockReturnValue(false);
const buttons = calculateButtons(controller, { pipManager });
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Picture in Picture' }),
);
});
it('when no pipManager provided', () => {
const buttons = calculateButtons(controller, {});
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Picture in Picture' }),
);
});
});
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<MediaPlayerManager>();
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<MediaPlayerManager>();
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<MediaPlayerController>();
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>();
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<MediaPlayerController>();
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>();
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 not have mute button without audio', () => {
const mediaPlayerController = mock<MediaPlayerController>();
const buttons = calculateButtons(controller, {
currentMediaLoadedInfo: createMediaLoadedInfo({
capabilities: {
hasAudio: false,
},
mediaPlayerController,
}),
});
expect(buttons).not.toContainEqual(
expect.objectContaining({
title: 'Mute / Unmute',
}),
);
});
it('should have screenshot button', () => {
const buttons = calculateButtons(controller, {
currentMediaLoadedInfo: createMediaLoadedInfo({
mediaPlayerController: mock<MediaPlayerController>(),
}),
});
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>();
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>();
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>();
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<FoldersManager>();
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<FoldersManager>();
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<FullscreenManager>();
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>();
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>();
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>();
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();
});
});