Files
advanced-camera-card/tests/card-controller/actions/actions/ptz.test.ts
T

816 lines
23 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Capabilities } from '../../../../src/camera-manager/capabilities';
import { PTZAction } from '../../../../src/card-controller/actions/actions/ptz';
import type { Action } from '../../../../src/card-controller/actions/types';
import { PTZMovementType } from '../../../../src/types';
import { createCameraManager, createStore } from '../../../camera-manager/test-utils';
import { createCameraConfig } from '../../../config/test-utils';
import { createCardAPI } from '../../../test-utils';
import { createView } from '../../../view/test-utils';
describe('should handle ptz action', () => {
it('should execute simple action', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.office',
}),
);
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
camera: 'camera.office',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'left',
{
phase: undefined,
preset: undefined,
},
);
});
describe('without explicit camera', () => {
it('when current camera supports PTZ', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'left',
{
phase: undefined,
preset: undefined,
},
);
});
it('when substream supports PTZ', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
config: createCameraConfig({
dependencies: { cameras: ['camera.office_hd'] },
}),
},
{
cameraID: 'camera.office_hd',
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.office',
context: {
live: {
overrides: new Map([['camera.office', 'camera.office_hd']]),
},
},
}),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office_hd',
'left',
{
phase: undefined,
preset: undefined,
},
);
});
it('when no camera supports PTZ', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).not.toHaveBeenCalled();
});
});
it('when there is no view', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).not.toHaveBeenCalled();
});
describe('when there is no action', () => {
it('should call first preset', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { presets: ['home'] } }),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'preset',
{
phase: undefined,
preset: 'home',
},
);
});
it('should call configured home preset in preference to first preset', async () => {
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2525
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
config: createCameraConfig({
ptz: {
presets: {
home: {
action: 'perform-action',
perform_action: 'button.press',
data: { entity_id: 'button.office_guard' },
},
},
},
}),
// An engine (e.g. Reolink) auto-detects presets that would otherwise
// shadow the configured home action.
capabilities: new Capabilities({
ptz: { presets: ['Staw', 'Piwnica'] },
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'preset',
{
phase: undefined,
preset: 'home',
},
);
});
it('should call first preset when no home preset is configured', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
config: createCameraConfig({
ptz: {
presets: {
window: {
action: 'perform-action',
perform_action: 'button.press',
data: { entity_id: 'button.office_window' },
},
},
},
}),
capabilities: new Capabilities({
ptz: { presets: ['Staw', 'Piwnica'] },
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'preset',
{
phase: undefined,
preset: 'Staw',
},
);
});
it('should not call preset when there are no presets', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
presets: [],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).not.toHaveBeenCalled();
});
});
it('should execute preset', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
presets: ['window'],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'preset',
ptz_preset: 'window',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'preset',
{
phase: undefined,
preset: 'window',
},
);
});
it('should execute action with phase', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Continuous],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'start',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'left',
{
phase: 'start',
},
);
});
// @vitest-environment jsdom
describe('when relative is requested but unsupported', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('should emulate relative', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Continuous],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const action = new PTZAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
},
);
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'left',
{
phase: 'start',
},
);
vi.runOnlyPendingTimers();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'left',
{
phase: 'stop',
},
);
});
it('should honor stop', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Continuous],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const context = {};
const action = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
});
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
action.stop();
vi.runOnlyPendingTimers();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
});
});
describe('when continuous is requested but unsupported', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('should emulate continuous', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const context = {};
const startAction = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'start',
});
await startAction.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'left',
{
phase: undefined,
},
);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
const stopAction = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'stop',
});
await stopAction.execute(api);
// There should be no additional calls.
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
});
it('should honor stop', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
},
}),
},
]);
const cameraManager = createCameraManager(store);
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const context = {};
const action = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'start',
});
await action.execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
// Emulate the stop being called while the action is running, but before
// the *next* timer is scheduled.
let resolve: () => void = () => {};
const promise: Promise<void> = new Promise((_resolve) => {
resolve = _resolve;
});
vi.mocked(cameraManager.executePTZAction).mockReturnValueOnce(promise);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
action.stop();
resolve();
await vi.runOnlyPendingTimersAsync();
// There should be no additional calls.
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
});
it('should only move for the latest of two concurrent starts', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
up: [PTZMovementType.Relative],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const context = {};
const createStartAction = (ptzAction: 'left' | 'up'): PTZAction =>
new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: ptzAction,
ptz_phase: 'start',
});
await Promise.all([
createStartAction('left').execute(api),
createStartAction('up').execute(api),
]);
// The 'up' start replaces the 'left' start before the 'left' start has
// made its first move, so only the 'up' start moves.
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledWith(
'camera.office',
'up',
{ preset: undefined },
);
const stopAction = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'up',
ptz_phase: 'stop',
});
await stopAction.execute(api);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
});
it('should continue movement when a stop for a different movement arrives', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
up: [PTZMovementType.Relative],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const context = {};
await new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'start',
}).execute(api);
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(1);
// A stop for a movement that is not in progress leaves the 'left'
// movement running.
await new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'up',
ptz_phase: 'stop',
}).execute(api);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
expect(api.getCameraManager().executePTZAction).toHaveBeenLastCalledWith(
'camera.office',
'left',
{ preset: undefined },
);
await new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'stop',
}).execute(api);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
});
it('should not stop an in-progress action that is not a PTZ action', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const incumbent = mock<Action>();
const context = { ptz: { 'camera.office': { inProgressAction: incumbent } } };
const stopAction = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'stop',
});
await stopAction.execute(api);
expect(incumbent.stop).not.toHaveBeenCalled();
});
it('should continue movement when a start is concurrent with a stop', async () => {
const api = createCardAPI();
const store = createStore([
{
cameraID: 'camera.office',
capabilities: new Capabilities({
ptz: {
left: [PTZMovementType.Relative],
up: [PTZMovementType.Relative],
},
}),
},
]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ camera: 'camera.office' }),
);
const context = {};
await new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'start',
}).execute(api);
const stopAction = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'left',
ptz_phase: 'stop',
});
const upAction = new PTZAction(context, {
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz',
ptz_action: 'up',
ptz_phase: 'start',
});
// The left key is released as the up key is pressed.
await Promise.all([stopAction.execute(api), upAction.execute(api)]);
// One move for the left start, one for the up start.
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
await vi.runOnlyPendingTimersAsync();
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
expect(api.getCameraManager().executePTZAction).toHaveBeenLastCalledWith(
'camera.office',
'up',
{ preset: undefined },
);
});
});
});