Add initial keyboard shortcut support.

This commit is contained in:
Dermot Duffy
2024-06-05 21:44:17 -07:00
parent 904f6d8142
commit 7ab545738d
186 changed files with 9564 additions and 3658 deletions
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { KeyAssignerController } from '../../src/components-lib/key-assigner-controller';
import { createLitElement } from '../test-utils';
// @vitest-environment jsdom
describe('KeyAssignerController', () => {
it('should be creatable', () => {
const controller = new KeyAssignerController(createLitElement());
expect(controller).toBeTruthy();
});
describe('should manage value', () => {
it('should have no value to start', () => {
const controller = new KeyAssignerController(createLitElement());
expect(controller.hasValue()).toBeFalsy();
});
it('should set value', () => {
const element = createLitElement();
const valueChangeHandler = vi.fn();
element.addEventListener('value-changed', valueChangeHandler);
const controller = new KeyAssignerController(element);
controller.setValue({ key: 'ArrowLeft' });
expect(controller.hasValue()).toBeTruthy();
expect(controller.getValue()).toEqual({ key: 'ArrowLeft' });
expect(element.requestUpdate).toBeCalled();
expect(valueChangeHandler).toBeCalledWith(
expect.objectContaining({
detail: { value: { key: 'ArrowLeft' } },
}),
);
// Set again with the same value.
controller.setValue({ key: 'ArrowLeft' });
expect(element.requestUpdate).toBeCalledTimes(1);
expect(valueChangeHandler).toBeCalledTimes(1);
});
});
describe('should manage assignment state', () => {
it('should not be assigned to start', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
expect(controller.isAssigning()).toBeFalsy();
expect(element.getAttribute('assigning')).toBeNull();
});
it('should toggle assigning', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
controller.toggleAssigning();
expect(element.requestUpdate).toBeCalled();
expect(controller.isAssigning()).toBeTruthy();
expect(element.getAttribute('assigning')).toBe('');
expect(controller.hasValue()).toBeFalsy();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
expect(controller.hasValue()).toBeTruthy();
expect(controller.isAssigning()).toBeFalsy();
controller.setValue(null);
// A key sent when not assigning will do nothing.
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
expect(controller.hasValue()).toBeFalsy();
});
it('should not assign when focus lost', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
controller.hostConnected();
controller.toggleAssigning();
expect(controller.isAssigning()).toBeTruthy();
element.dispatchEvent(new FocusEvent('blur'));
expect(controller.isAssigning()).toBeFalsy();
controller.hostDisconnected();
});
});
describe('should manage key down', () => {
it('should reject modifiers only', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
controller.toggleAssigning();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' }));
expect(controller.hasValue()).toBeFalsy();
});
it('should reject empty key', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
controller.toggleAssigning();
element.dispatchEvent(new KeyboardEvent('keydown', { key: '' }));
expect(controller.hasValue()).toBeFalsy();
});
it('should accept valid key', () => {
const element = createLitElement();
const controller = new KeyAssignerController(element);
controller.toggleAssigning();
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft',
ctrlKey: true,
shiftKey: true,
altKey: true,
metaKey: true,
}),
);
expect(controller.hasValue()).toBeTruthy();
expect(controller.getValue()).toEqual({
key: 'ArrowLeft',
ctrl: true,
shift: true,
alt: true,
meta: true,
});
expect(controller.isAssigning()).toBeFalsy();
});
});
});
@@ -18,7 +18,7 @@ import {
ViewDisplayMode,
} from '../../src/config/types';
import { FrigateCardMediaPlayer } from '../../src/types';
import { createFrigateCardSimpleAction } from '../../src/utils/action';
import { createGeneralAction } from '../../src/utils/action';
import { ViewMedia } from '../../src/view/media';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { View } from '../../src/view/view';
@@ -36,6 +36,7 @@ import {
createView,
TestViewMedia,
} from '../test-utils';
import { Capabilities } from '../../src/camera-manager/capabilities';
vi.mock('../../src/utils/media-player-controller.js');
vi.mock('../../src/card-controller/microphone-manager.js');
@@ -88,8 +89,8 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Frigate menu / Default view',
tap_action: createFrigateCardSimpleAction('menu_toggle'),
hold_action: createFrigateCardSimpleAction('diagnostics'),
tap_action: createGeneralAction('menu_toggle'),
hold_action: createGeneralAction('diagnostics'),
});
});
@@ -104,8 +105,8 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Frigate menu / Default view',
tap_action: createFrigateCardSimpleAction('default'),
hold_action: createFrigateCardSimpleAction('diagnostics'),
tap_action: createGeneralAction('default'),
hold_action: createGeneralAction('diagnostics'),
});
});
});
@@ -1355,13 +1356,14 @@ describe('MenuButtonController', () => {
describe('should have show ptz button', () => {
it('when the selected camera is not PTZ enabled', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
createCapabilities(),
);
const store = createStore([
{
cameraID: 'camera-1',
},
]);
const buttons = calculateButtons(controller, {
cameraManager: cameraManager,
cameraManager: createCameraManager(store),
view: createView({ view: 'live' }),
});
@@ -1373,13 +1375,15 @@ describe('MenuButtonController', () => {
});
it('when not in live view', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
createCapabilities({ ptz: { panTilt: ['relative'] } }),
);
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
},
]);
const buttons = calculateButtons(controller, {
cameraManager: cameraManager,
cameraManager: createCameraManager(store),
view: createView({ view: 'clips' }),
});
@@ -1391,11 +1395,16 @@ describe('MenuButtonController', () => {
});
it('when the selected camera is PTZ enabled', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
createCapabilities({ ptz: { panTilt: ['relative'] } }),
);
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
},
]);
const buttons = calculateButtons(controller, {
cameraManager: createCameraManager(store),
});
expect(buttons).toContainEqual({
enabled: false,
@@ -1406,25 +1415,28 @@ describe('MenuButtonController', () => {
},
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'show_ptz',
show_ptz: false,
frigate_card_action: 'ptz_controls',
enabled: false,
},
title: 'Show PTZ controls',
type: 'custom:frigate-card-menu-icon',
});
});
it('when the context has PTZ visiblity turned off', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
createCapabilities({ ptz: { panTilt: ['relative'] } }),
);
it('when the context has PTZ disabled', () => {
const store = createStore([
{
cameraID: 'camera-1',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
},
]);
const view = createView({
camera: 'camera-1',
context: { live: { ptzVisible: false } },
context: { ptzControls: { enabled: false } },
});
const buttons = calculateButtons(controller, {
cameraManager: cameraManager,
cameraManager: createCameraManager(store),
view: view,
});
@@ -1435,8 +1447,8 @@ describe('MenuButtonController', () => {
style: {},
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'show_ptz',
show_ptz: true,
frigate_card_action: 'ptz_controls',
enabled: true,
},
title: 'Show PTZ controls',
type: 'custom:frigate-card-menu-icon',
@@ -1444,24 +1456,16 @@ describe('MenuButtonController', () => {
});
it('when a substream is PTZ enabled', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera-1',
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
},
{ cameraID: 'camera-2' },
]),
);
vi.mocked(cameraManager.getCameraCapabilities).mockImplementation(
(cameraID: string) => {
if (cameraID === 'camera-2') {
return createCapabilities({ ptz: { panTilt: ['relative'] } });
}
return createCapabilities();
const store = createStore([
{
cameraID: 'camera-1',
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
},
);
{
cameraID: 'camera-2',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
},
]);
const view = createView({
camera: 'camera-1',
context: {
@@ -1471,7 +1475,7 @@ describe('MenuButtonController', () => {
},
});
const buttons = calculateButtons(controller, {
cameraManager: cameraManager,
cameraManager: createCameraManager(store),
view: view,
});
@@ -1484,8 +1488,8 @@ describe('MenuButtonController', () => {
},
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'show_ptz',
show_ptz: false,
frigate_card_action: 'ptz_controls',
enabled: false,
},
title: 'Show PTZ controls',
type: 'custom:frigate-card-menu-icon',
@@ -1493,7 +1497,7 @@ describe('MenuButtonController', () => {
});
});
describe('should have change zoom button', () => {
describe('should have ptz home button', () => {
it.each([
['live' as const, true, false],
['live' as const, undefined, false],
@@ -1521,13 +1525,23 @@ describe('MenuButtonController', () => {
results: [new TestViewMedia({ id: 'media-1' })],
selectedIndex: 0,
}),
context: {
zoom: {
[targetID]: {
isDefault: isDefault,
...(isDefault !== undefined && {
context: {
zoom: {
[targetID]: {
observed: {
isDefault: isDefault,
unzoomed: false,
zoom: 1,
pan: {
x: 50,
y: 50,
},
},
},
},
},
},
}),
});
const buttons = calculateButtons(controller, {
cameraManager: cameraManager,
@@ -1537,19 +1551,19 @@ describe('MenuButtonController', () => {
if (expectedResult) {
expect(buttons).toContainEqual({
enabled: true,
icon: 'mdi:magnify-close',
icon: 'mdi:home',
priority: 50,
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'change_zoom',
frigate_card_action: 'ptz_multi',
target_id: targetID,
},
title: 'Zoom to default',
title: 'PTZ Home',
type: 'custom:frigate-card-menu-icon',
});
} else {
expect(buttons).not.toContainEqual(
expect.objectContaining({ title: 'Set zoom to default' }),
expect.objectContaining({ title: 'Digital zoom to default' }),
);
}
},
+32 -28
View File
@@ -362,53 +362,60 @@ describe('MenuController', () => {
describe('should handle actions', () => {
it('should bail without config', () => {
const controller = new MenuController(createLitElement());
controller.actionHandler(createHASS(), createEvent('tap'));
controller.actionHandler(createEvent('tap'));
expect(vi.mocked(handleActionConfig)).not.toBeCalled();
});
it('should execute simple action in non-hidden menu', () => {
const host = createLitElement();
const hass = createHASS();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new MenuController(host);
controller.actionHandler(hass, createEvent('tap'), tapActionConfig);
expect(vi.mocked(handleActionConfig)).toBeCalledWith(
host,
hass,
tapActionConfig,
action,
controller.actionHandler(createEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
}),
);
expect(controller.isExpanded()).toBeFalsy();
});
it('should execute simple action in with config in event', () => {
const host = createLitElement();
const hass = createHASS();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new MenuController(host);
controller.actionHandler(hass, createEvent('tap', tapActionConfig));
expect(vi.mocked(handleActionConfig)).toBeCalledWith(
host,
hass,
tapActionConfig,
action,
controller.actionHandler(createEvent('tap', tapActionConfig));
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action], config: tapActionConfig },
}),
);
});
it('should execute simple array of actions in non-hidden menu', () => {
const host = createLitElement();
const hass = createHASS();
const handler = vi.fn();
host.addEventListener('frigate-card:action:execution-request', handler);
const controller = new MenuController(host);
controller.actionHandler(hass, createEvent('tap'), tapActionConfigMulti);
expect(vi.mocked(handleActionConfig)).toBeCalledTimes(3);
controller.actionHandler(createEvent('tap'), tapActionConfigMulti);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: { action: [action, action, action], config: tapActionConfigMulti },
}),
);
});
describe('should close menu', () => {
it('tap', () => {
const host = createLitElement();
const hass = createHASS();
const controller = new MenuController(host);
controller.setMenuConfig(
createMenuConfig({
@@ -419,13 +426,12 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(hass, createEvent('tap'), tapActionConfig);
controller.actionHandler(createEvent('tap'), tapActionConfig);
expect(controller.isExpanded()).toBeFalsy();
});
it('end_tap', () => {
const host = createLitElement();
const hass = createHASS();
const controller = new MenuController(host);
controller.setMenuConfig(
createMenuConfig({
@@ -436,7 +442,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(hass, createEvent('end_tap'), {
controller.actionHandler(createEvent('end_tap'), {
end_tap_action: action,
});
expect(controller.isExpanded()).toBeFalsy();
@@ -446,7 +452,6 @@ describe('MenuController', () => {
describe('should not close menu', () => {
it('start_tap with later action', () => {
const host = createLitElement();
const hass = createHASS();
const controller = new MenuController(host);
controller.setMenuConfig(
createMenuConfig({
@@ -457,7 +462,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(hass, createEvent('start_tap'), {
controller.actionHandler(createEvent('start_tap'), {
start_tap_action: action,
end_tap_action: action,
});
@@ -466,7 +471,6 @@ describe('MenuController', () => {
it('with a menu toggle action', () => {
const host = createLitElement();
const hass = createHASS();
const controller = new MenuController(host);
controller.setMenuConfig(
createMenuConfig({
@@ -477,7 +481,7 @@ describe('MenuController', () => {
controller.setExpanded(false);
expect(controller.isExpanded()).toBeFalsy();
controller.actionHandler(hass, createEvent('tap'), {
controller.actionHandler(createEvent('tap'), {
camera_entity: 'foo',
tap_action: menuToggleAction,
});
@@ -497,7 +501,7 @@ describe('MenuController', () => {
controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(hass, createEvent('end_tap'), tapActionConfig);
controller.actionHandler(createEvent('end_tap'), tapActionConfig);
expect(controller.isExpanded()).toBeTruthy();
});
});
-316
View File
@@ -1,316 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PTZController } from '../../src/components-lib/ptz-controller';
import {
FrigateCardPTZConfig,
frigateCardPTZSchema,
PTZControlAction,
} from '../../src/config/types';
import {
frigateCardHandleActionConfig,
getActionConfigGivenAction,
} from '../../src/utils/action.js';
import {
createCapabilities,
createCameraManager,
createHASS,
} from '../test-utils';
vi.mock('../../src/utils/action.js');
const createConfig = (config?: Partial<FrigateCardPTZConfig>): FrigateCardPTZConfig => {
return frigateCardPTZSchema.parse({
...config,
});
};
// @vitest-environment jsdom
describe('PTZController', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should be creatable', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller).toBeTruthy();
});
it('should get config creatable', () => {
const controller = new PTZController(document.createElement('div'));
const config = createConfig();
controller.setConfig(config);
expect(controller.getConfig()).toBe(config);
});
describe('should set element attributes', () => {
describe('orientation', () => {
describe('with config', () => {
it.each([['horizontal' as const], ['vertical' as const]])(
'%s',
(orientation: 'horizontal' | 'vertical') => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig(createConfig({ orientation: orientation }));
expect(element.getAttribute('data-orientation')).toBe(orientation);
},
);
});
it('without config', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig();
expect(element.getAttribute('data-orientation')).toBe('horizontal');
});
});
describe('position', () => {
describe('with config', () => {
it.each([
['top-left' as const],
['top-right' as const],
['bottom-left' as const],
['bottom-right' as const],
])(
'%s',
(position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig(createConfig({ position: position }));
expect(element.getAttribute('data-position')).toBe(position);
},
);
});
it('without config', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig();
expect(element.getAttribute('data-position')).toBe('bottom-right');
});
});
it('with style in config', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig(createConfig({ style: { transform: 'none', left: '50%' } }));
expect(element.getAttribute('style')).toBe('transform:none;left:50%');
});
});
describe('should respect mode', () => {
it('off', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller.shouldDisplay()).toBeFalsy();
});
it('off but forced on', () => {
const controller = new PTZController(document.createElement('div'));
controller.setForceVisibility(true);
// No actions, no rendering, no matter what.
expect(controller.shouldDisplay()).toBeFalsy();
});
it('on without actions', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'on' }));
expect(controller.shouldDisplay()).toBeFalsy();
});
it('on with actions', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'on', actions_left: {} }));
controller.setCamera(createCameraManager(), 'camera.office');
expect(controller.shouldDisplay()).toBeTruthy();
});
it('on with actions but forced off', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'on', actions_left: {} }));
controller.setCamera(createCameraManager(), 'camera.office');
controller.setForceVisibility(false);
expect(controller.shouldDisplay()).toBeFalsy();
});
});
describe('should get PTZ actions', () => {
it('without config', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller.getPTZActions('left')).toBeNull();
});
describe('using defaults', () => {
describe('with continous PTZ', () => {
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
['zoom_in' as const],
['zoom_out' as const],
])('%s', (actionName: PTZControlAction) => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig());
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
createCapabilities({
ptz: {
panTilt: ['continuous'],
zoom: ['continuous'],
},
}),
);
controller.setCamera(cameraManager, 'camera.office');
expect(controller.getPTZActions(actionName)).toEqual({
start_tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz',
ptz_action: actionName,
ptz_phase: 'start',
},
end_tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz',
ptz_action: actionName,
ptz_phase: 'stop',
},
});
});
});
describe('with relative PTZ', () => {
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
['zoom_in' as const],
['zoom_out' as const],
])('%s', (actionName: PTZControlAction) => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig());
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
createCapabilities({
ptz: {
panTilt: ['relative'],
zoom: ['relative'],
},
}),
);
controller.setCamera(cameraManager, 'camera.office');
expect(controller.getPTZActions(actionName)).toEqual({
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz',
ptz_action: actionName,
},
});
});
});
it('home', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig());
const cameraManager = createCameraManager();
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
createCapabilities({
ptz: {
presets: ['preset-foo'],
},
}),
);
controller.setCamera(cameraManager, 'camera.office');
expect(controller.getPTZActions('home')).toEqual({
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz',
ptz_action: 'preset',
ptz_preset: 'preset-foo',
},
});
});
});
describe('using config', () => {
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
['zoom_in' as const],
['zoom_out' as const],
])('configured %s', (actionName: PTZControlAction) => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(
createConfig({
[`actions_${actionName}`]: {
argument: actionName,
},
}),
);
controller.setCamera(createCameraManager(), 'camera.office');
expect(controller.getPTZActions(actionName)).toEqual({
argument: actionName,
});
});
it('configured home', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(
createConfig({
actions_home: {
argument: 'home',
},
}),
);
controller.setCamera(createCameraManager(), 'camera.office');
expect(controller.getPTZActions('home')).toEqual({
argument: 'home',
});
});
});
});
describe('should handle action', () => {
it('without hass or action', () => {
const controller = new PTZController(document.createElement('div'));
controller.setHASS();
controller.setCamera();
controller.handleAction(
new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }),
);
expect(frigateCardHandleActionConfig).not.toBeCalled();
});
it('with action', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
const hass = createHASS();
controller.setHASS(hass);
controller.setConfig(
createConfig({
[`actions_left`]: {},
}),
);
const tapAction = {
action: 'none' as const,
};
const actionsConfig = {
tap_action: tapAction,
};
vi.mocked(getActionConfigGivenAction).mockReturnValue(tapAction);
controller.handleAction(
new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }),
actionsConfig,
);
expect(frigateCardHandleActionConfig).toBeCalledWith(
element,
hass,
actionsConfig,
'tap',
actionsConfig.tap_action,
);
});
});
});
@@ -0,0 +1,378 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Capabilities } from '../../../src/camera-manager/capabilities';
import { PTZController } from '../../../src/components-lib/ptz/ptz-controller';
import { PTZControlAction } from '../../../src/config/ptz';
import { PTZControlsConfig, ptzControlsConfigSchema } from '../../../src/config/types';
import { createCameraManager, createCapabilities, createStore } from '../../test-utils';
const createConfig = (config?: Partial<PTZControlsConfig>): PTZControlsConfig => {
return ptzControlsConfigSchema.parse({
...config,
});
};
// @vitest-environment jsdom
describe('PTZController', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should be creatable', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller).toBeTruthy();
});
it('should get config', () => {
const controller = new PTZController(document.createElement('div'));
const config = createConfig();
controller.setConfig(config);
expect(controller.getConfig()).toBe(config);
});
describe('should set element attributes', () => {
describe('orientation', () => {
describe('with config', () => {
it.each([['horizontal' as const], ['vertical' as const]])(
'%s',
(orientation: 'horizontal' | 'vertical') => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig(createConfig({ orientation: orientation }));
expect(element.getAttribute('data-orientation')).toBe(orientation);
},
);
});
it('without config', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig();
expect(element.getAttribute('data-orientation')).toBe('horizontal');
});
});
describe('position', () => {
describe('with config', () => {
it.each([
['top-left' as const],
['top-right' as const],
['bottom-left' as const],
['bottom-right' as const],
])(
'%s',
(position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig(createConfig({ position: position }));
expect(element.getAttribute('data-position')).toBe(position);
},
);
});
it('without config', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig();
expect(element.getAttribute('data-position')).toBe('bottom-right');
});
});
it('with style in config', () => {
const element = document.createElement('div');
const controller = new PTZController(element);
controller.setConfig(createConfig({ style: { transform: 'none', left: '50%' } }));
expect(element.getAttribute('style')).toBe('transform:none;left:50%');
});
});
describe('should respect mode', () => {
it('off', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller.shouldDisplay()).toBeFalsy();
});
it('forced on', () => {
const controller = new PTZController(document.createElement('div'));
controller.setForceVisibility(true);
expect(controller.shouldDisplay()).toBeTruthy();
});
it('forced off', () => {
const controller = new PTZController(document.createElement('div'));
controller.setForceVisibility(false);
expect(controller.shouldDisplay()).toBeFalsy();
});
it('configured on', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'on' }));
expect(controller.shouldDisplay()).toBeTruthy();
});
it('configured off', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'off' }));
expect(controller.shouldDisplay()).toBeFalsy();
});
it('auto without capability', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'auto' }));
controller.setCamera(createCameraManager(), 'camera.office');
expect(controller.shouldDisplay()).toBeFalsy();
});
it('auto with capability', () => {
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
},
]);
const cameraManager = createCameraManager(store);
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
createCapabilities({
ptz: {
left: ['relative'],
},
}),
);
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig({ mode: 'auto' }));
controller.setCamera(cameraManager, 'camera.office');
expect(controller.shouldDisplay()).toBeTruthy();
});
});
describe('should get PTZ actions', () => {
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
['zoom_in' as const],
['zoom_out' as const],
])('%s', (actionName: PTZControlAction) => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig());
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: ['relative'],
right: ['relative'],
up: ['relative'],
down: ['relative'],
zoomIn: ['relative'],
zoomOut: ['relative'],
},
}),
},
]);
const cameraManager = createCameraManager(store);
controller.setCamera(cameraManager, 'camera.office');
expect(controller.getPTZActions()?.[actionName]).toEqual({
start_tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz_multi',
ptz_action: actionName,
ptz_phase: 'start',
},
end_tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz_multi',
ptz_action: actionName,
ptz_phase: 'stop',
},
});
});
it('home', () => {
const controller = new PTZController(document.createElement('div'));
controller.setConfig(createConfig());
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: ['relative'],
right: ['relative'],
up: ['relative'],
down: ['relative'],
zoomIn: ['relative'],
zoomOut: ['relative'],
},
}),
},
]);
const cameraManager = createCameraManager(store);
controller.setCamera(cameraManager, 'camera.office');
expect(controller.getPTZActions()['home']).toEqual({
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'ptz_multi',
},
});
});
});
describe('should handle action', () => {
it('successfully', () => {
const action = {
action: 'more-info' as const,
};
const config = {
tap_action: action,
camera_entity: 'camera.office',
};
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('frigate-card:action:execution-request', handler);
const controller = new PTZController(element);
controller.setCamera();
controller.handleAction(
new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }),
config,
);
expect(handler).toBeCalledWith(
expect.objectContaining({
detail: {
action: action,
config: config,
},
}),
);
});
it('should not call action without actions config', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('frigate-card:action:execution-request', handler);
const controller = new PTZController(element);
controller.setCamera();
controller.handleAction(
new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }),
);
expect(handler).not.toBeCalled();
});
it('should not call action without hass', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('frigate-card:action:execution-request', handler);
const controller = new PTZController(element);
controller.setCamera();
controller.handleAction(
new CustomEvent<{ action: string }>('@action', { detail: { action: 'tap' } }),
);
expect(handler).not.toBeCalled();
});
});
describe('should identify useful actions', () => {
it('without a camera', () => {
const controller = new PTZController(document.createElement('div'));
expect(controller.hasUsefulAction()).toEqual({
pt: true,
z: true,
home: true,
});
});
it('without camera PTZ capabilities', () => {
const controller = new PTZController(document.createElement('div'));
const store = createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
ptz: {},
}),
},
]);
const cameraManager = createCameraManager(store);
controller.setCamera(cameraManager, 'camera.office');
expect(controller.hasUsefulAction()).toEqual({
pt: true,
z: true,
home: true,
});
});
it('with camera pan and tilt capabilities', () => {
const controller = new PTZController(document.createElement('div'));
const store = createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
ptz: {
left: ['relative'],
right: ['relative'],
up: ['relative'],
down: ['relative'],
},
}),
},
]);
controller.setCamera(createCameraManager(store), 'camera.office');
expect(controller.hasUsefulAction()).toEqual({
pt: true,
z: false,
home: false,
});
});
it('with camera zoom capabilities', () => {
const controller = new PTZController(document.createElement('div'));
const store = createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
ptz: {
zoomIn: ['relative'],
zoomOut: ['relative'],
},
}),
},
]);
controller.setCamera(createCameraManager(store), 'camera.office');
expect(controller.hasUsefulAction()).toEqual({
pt: false,
z: true,
home: false,
});
});
it('with camera presets', () => {
const controller = new PTZController(document.createElement('div'));
const store = createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
ptz: {
presets: ['door'],
},
}),
},
]);
controller.setCamera(createCameraManager(store), 'camera.office');
expect(controller.hasUsefulAction()).toEqual({
pt: false,
z: false,
home: true,
});
});
});
});
@@ -5,7 +5,7 @@ import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller
import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils';
vi.mock('@dermotduffy/panzoom');
vi.mock('lodash-es/debounce', () => ({
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
}));
@@ -311,8 +311,8 @@ describe('ZoomController', () => {
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const defaultFunc = vi.fn();
element.addEventListener('frigate-card:zoom:default', defaultFunc);
const changeFunc = vi.fn();
element.addEventListener('frigate-card:zoom:change', changeFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
createAndRegisterZoom(element);
@@ -327,8 +327,11 @@ describe('ZoomController', () => {
},
});
element.dispatchEvent(ev_1);
expect(defaultFunc).toBeCalledWith(
expect.objectContaining({ detail: { isDefault: false } }),
expect(changeFunc).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ isDefault: false }),
}),
);
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
@@ -341,8 +344,11 @@ describe('ZoomController', () => {
},
});
element.dispatchEvent(ev_2);
expect(defaultFunc).toBeCalledWith(
expect.objectContaining({ detail: { isDefault: true } }),
expect(changeFunc).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ isDefault: true }),
}),
);
});
@@ -350,12 +356,12 @@ describe('ZoomController', () => {
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const defaultFunc = vi.fn();
element.addEventListener('frigate-card:zoom:default', defaultFunc);
const changeFunc = vi.fn();
element.addEventListener('frigate-card:zoom:change', changeFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
const controller = createAndRegisterZoom(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } });
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
@@ -367,8 +373,11 @@ describe('ZoomController', () => {
},
});
element.dispatchEvent(ev_1);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: false } }),
expect(changeFunc).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ isDefault: false }),
}),
);
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
@@ -381,8 +390,11 @@ describe('ZoomController', () => {
},
});
element.dispatchEvent(ev_2);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: true } }),
expect(changeFunc).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ isDefault: true }),
}),
);
});
@@ -390,8 +402,8 @@ describe('ZoomController', () => {
const element = document.createElement('div');
setElementToDefaultCardSize(element);
const defaultFunc = vi.fn();
element.addEventListener('frigate-card:zoom:default', defaultFunc);
const changeFunc = vi.fn();
element.addEventListener('frigate-card:zoom:change', changeFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
const controller = createAndRegisterZoom(element);
@@ -406,11 +418,14 @@ describe('ZoomController', () => {
},
});
element.dispatchEvent(ev_1);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: false } }),
expect(changeFunc).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ isDefault: false }),
}),
);
controller.setDefaultConfig({});
controller.setDefaultSettings({});
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
detail: {
@@ -422,8 +437,11 @@ describe('ZoomController', () => {
},
});
element.dispatchEvent(ev_2);
expect(defaultFunc).toHaveBeenLastCalledWith(
expect.objectContaining({ detail: { isDefault: true } }),
expect(changeFunc).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: expect.objectContaining({ isDefault: true }),
}),
);
});
});
@@ -438,7 +456,7 @@ describe('ZoomController', () => {
setElementToDefaultCardSize(element);
const controller = new ZoomController(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } });
// Controller was not activated, config setting will not update pan/zoom.
expect(panzoom.zoom).not.toBeCalled();
@@ -448,16 +466,16 @@ describe('ZoomController', () => {
expect(Panzoom).toBeCalledWith(
expect.anything(),
expect.objectContaining({
contain: "outside",
contain: 'outside',
cursor: undefined,
maxScale: 10,
minScale: 1,
noBind: true,
touchAction: "",
touchAction: '',
startScale: 2,
startX: 115.62,
startY: 63.6525,
})
}),
);
});
@@ -469,12 +487,15 @@ describe('ZoomController', () => {
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } });
triggerResizeObserver();
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { animate: false });
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, {
animate: true,
duration: 100,
});
});
it('with set of config when a default is already set', () => {
@@ -487,23 +508,25 @@ describe('ZoomController', () => {
const controller = createAndRegisterZoom(element);
// This call will do nothing since this is what zoom/pan already are.
controller.setDefaultConfig({ zoom: 1, pan: { x: 0, y: 0 } });
controller.setDefaultSettings({ zoom: 1, pan: { x: 0, y: 0 } });
expect(panzoom.zoom).not.toBeCalled();
expect(panzoom.pan).not.toBeCalled();
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } });
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
animate: false,
animate: true,
duration: 100,
});
controller.setConfig({ zoom: 3, pan: { x: 5, y: 6 } });
controller.setSettings({ zoom: 3, pan: { x: 5, y: 6 } });
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 3, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 147.6, 81.18, {
animate: false,
animate: true,
duration: 100,
});
});
@@ -516,30 +539,31 @@ describe('ZoomController', () => {
const controller = createAndRegisterZoom(element);
controller.setConfig({ zoom: 1 });
controller.setSettings({ zoom: 1 });
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({ pan: { x: 50, y: 50 } });
controller.setSettings({ pan: { x: 50, y: 50 } });
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({ zoom: 1, pan: { x: 50, y: 50 } });
controller.setSettings({ zoom: 1, pan: { x: 50, y: 50 } });
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({});
controller.setSettings({});
expect(panzoom.zoom).not.toHaveBeenCalled();
controller.setConfig({ zoom: 2 });
controller.setSettings({ zoom: 2 });
expect(panzoom.zoom).toBeCalledTimes(1);
expect(panzoom.pan).toBeCalledTimes(1);
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 0, 0, {
animate: false,
animate: true,
duration: 100,
});
vi.mocked(panzoom.getScale).mockReturnValue(2);
vi.mocked(panzoom.getPan).mockReturnValue({ x: 0, y: 0 });
controller.setConfig({ zoom: 2 });
controller.setSettings({ zoom: 2 });
expect(panzoom.zoom).toBeCalledTimes(1);
expect(panzoom.pan).toBeCalledTimes(1);
@@ -553,15 +577,16 @@ describe('ZoomController', () => {
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
controller.setDefaultSettings({ zoom: 2, pan: { x: 3, y: 4 } });
mockClear(panzoom);
controller.setConfig({});
controller.setSettings({});
// Should fall back to default.
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, {
animate: false,
animate: true,
duration: 100,
});
});
@@ -573,11 +598,12 @@ describe('ZoomController', () => {
setElementToDefaultCardSize(element);
const controller = createAndRegisterZoom(element);
controller.setConfig({ zoom: 2, pan: { x: 3, y: 4 } });
controller.setSettings({ zoom: 2, pan: { x: 3, y: 4 } });
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
animate: false,
animate: true,
duration: 100,
});
vi.mocked(panzoom.getScale).mockReturnValue(2);
@@ -588,7 +614,8 @@ describe('ZoomController', () => {
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 2, { animate: false });
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 57.81, 31.82625, {
animate: false,
animate: true,
duration: 100,
});
});
@@ -1,14 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import {
generateViewContextForZoomChange,
handleZoomDefaultEvent,
generateViewContextForZoom,
handleZoomSettingsObservedEvent,
} from '../../../src/components-lib/zoom/zoom-view-context';
describe('generateViewContextForZoomChangeRequest', () => {
it('with config', () => {
describe('generateViewContextForZoom', () => {
it('with observed', () => {
expect(
generateViewContextForZoomChange('target', {
zoom: {
generateViewContextForZoom('target', {
observed: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
).toEqual({
zoom: {
target: {
observed: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
requested: null,
},
},
});
});
it('with requested', () => {
expect(
generateViewContextForZoom('target', {
requested: {
pan: { x: 1, y: 2 },
zoom: 3,
},
@@ -16,7 +41,7 @@ describe('generateViewContextForZoomChangeRequest', () => {
).toEqual({
zoom: {
target: {
zoom: {
requested: {
pan: { x: 1, y: 2 },
zoom: 3,
},
@@ -24,50 +49,23 @@ describe('generateViewContextForZoomChangeRequest', () => {
},
});
});
it('without config', () => {
expect(generateViewContextForZoomChange('target')).toEqual({
zoom: {
target: {
zoom: null,
},
},
});
});
});
describe('generateViewContextForZoomDefault', () => {
it('default', () => {
expect(generateViewContextForZoomChange('target', { isDefault: true })).toEqual({
zoom: {
target: {
isDefault: true,
zoom: null,
},
},
});
});
it('not default', () => {
expect(generateViewContextForZoomChange('target', { isDefault: false })).toEqual({
zoom: {
target: {
isDefault: false,
zoom: null,
},
},
});
});
});
// @vitest-environment jsdom
it('handleZoomDefaultEvent', () => {
it('handleZoomSettingsObservedEvent', () => {
const element = document.createElement('div');
const callback = vi.fn();
element.addEventListener('frigate-card:view:change-context', callback);
handleZoomDefaultEvent(
handleZoomSettingsObservedEvent(
element,
new CustomEvent('frigate-card:zoom:default', { detail: { isDefault: true } }),
new CustomEvent('frigate-card:zoom:change', {
detail: {
pan: { x: 1, y: 2 },
zoom: 3,
isDefault: true,
unzoomed: true,
},
}),
'target',
);
expect(callback).toBeCalledWith(
@@ -75,8 +73,8 @@ it('handleZoomDefaultEvent', () => {
detail: {
zoom: {
target: {
zoom: null,
isDefault: true,
observed: { pan: { x: 1, y: 2 }, zoom: 3, isDefault: true, unzoomed: true },
requested: null,
},
},
},