Add initial support for automations.

This commit is contained in:
Dermot Duffy
2023-05-04 19:54:41 -07:00
parent b048524d84
commit fd74c1c855
17 changed files with 1279 additions and 470 deletions
+123
View File
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { AutomationsController, AutomationsControllerError } from '../src/automations';
import { ConditionController } from '../src/conditions';
import { automationsSchema, FrigateCardError } from '../src/types';
import { frigateCardHandleAction } from '../src/utils/action.js';
import { createHASS } from './test-utils';
vi.mock('../src/utils/action.js');
describe('AutomationsController', () => {
const actions = [
{
action: 'custom:frigate-card-action',
frigate_card_action: 'clips',
},
];
const conditions = { fullscreen: true };
afterEach(() => {
vi.clearAllMocks();
});
it('should do nothing without automations', () => {
const automationController = new AutomationsController(undefined);
automationController.execute(
mock<HTMLElement>(),
createHASS(),
new ConditionController(),
);
expect(frigateCardHandleAction).not.toBeCalled();
});
it('should execute actions', () => {
const automations = automationsSchema.parse([
{
conditions: conditions,
actions: actions,
},
]);
const automationController = new AutomationsController(automations);
const conditionController = new ConditionController();
const element = mock<HTMLElement>();
const hass = createHASS();
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).not.toBeCalled();
conditionController.setState({ fullscreen: true });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
// Automation will not re-fire when condition continues to evaluate the
// same.
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
conditionController.setState({ fullscreen: false });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
conditionController.setState({ fullscreen: true });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(2);
});
it('should execute actions_not', () => {
const automations = automationsSchema.parse([
{
conditions: conditions,
actions_not: actions,
},
]);
const automationController = new AutomationsController(automations);
automationController.execute(
mock<HTMLElement>(),
createHASS(),
new ConditionController(),
);
expect(frigateCardHandleAction).toBeCalled();
});
it('should prevent automation loops', () => {
const automations = automationsSchema.parse([
{
conditions: { fullscreen: true },
actions: actions,
},
{
conditions: { fullscreen: false },
actions: actions,
},
]);
const automationController = new AutomationsController(automations);
const conditionController = new ConditionController();
const element = mock<HTMLElement>();
const hass = createHASS();
// Create a setup where one automation action causes another...
let fullscreen = true;
vi.mocked(frigateCardHandleAction).mockImplementation(() => {
fullscreen = !fullscreen;
conditionController.setState({ fullscreen: fullscreen });
automationController.execute(element, hass, conditionController);
});
conditionController.setState({ fullscreen: fullscreen });
expect(() =>
automationController.execute(element, hass, conditionController),
).toThrowError(/Too many nested automation calls/);
expect(frigateCardHandleAction).toBeCalledTimes(10);
});
it('should be able to construct error', () => {
const error = new AutomationsControllerError('message');
expect(error).toBeTruthy();
expect(error instanceof FrigateCardError).toBeTruthy();
});
});
+14 -39
View File
@@ -1,17 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
import { Engine } from '../../src/camera-manager/types.js';
import { CardWideConfig } from '../../src/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { CameraConfig, cameraConfigSchema, CardWideConfig } from '../../src/types.js';
import { HomeAssistant } from 'custom-card-helpers';
import { Engine } from '../../src/camera-manager/types.js';
import { Entity } from '../../src/utils/ha/entity-registry/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
import { HassEntities } from 'home-assistant-js-websocket';
import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/entity-registry/cache');
@@ -28,32 +25,6 @@ const createFactory = (options?: {
);
};
const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
return cameraConfigSchema.parse(config);
};
const createHASS = (states?: HassEntities): HomeAssistant => {
const hass = mock<HomeAssistant>();
if (states) {
hass.states = states;
}
return hass;
};
const createEntity = (entity: Partial<Entity>): Entity => {
return {
...entity,
config_entry_id: entity.config_entry_id ?? null,
device_id: entity.device_id ?? null,
disabled_by: entity.disabled_by ?? null,
entity_id: entity.entity_id ?? 'entity_id',
hidden_by: entity.hidden_by ?? null,
platform: entity.platform ?? 'platform',
translation_key: entity.translation_key ?? null,
unique_id: entity.unique_id ?? 'unique_id',
};
};
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
it('should get frigate engine from config', async () => {
const config = createCameraConfig({ engine: 'frigate' });
@@ -79,7 +50,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
);
expect(
await createFactory({
@@ -94,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
);
expect(
@@ -109,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
);
expect(
await createFactory({
+350
View File
@@ -0,0 +1,350 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import {
ConditionController,
ConditionEvaluateRequestEvent,
evaluateConditionViaEvent,
getOverriddenConfig,
getOverridesByKey,
} from '../src/conditions';
import { createCondition, createConfig, createStateEntity } from './test-utils';
// @vitest-environment jsdom
describe('ConditionEvaluateRequestEvent', () => {
it('should construct', () => {
const condition = createCondition({ fullscreen: true });
const event = new ConditionEvaluateRequestEvent(condition, {
bubbles: true,
composed: true,
});
expect(event.type).toBe('frigate-card:condition:evaluate');
expect(event.condition).toBe(condition);
expect(event.bubbles).toBeTruthy();
expect(event.composed).toBeTruthy();
});
});
describe('evaluateConditionViaEvent', () => {
it('should evaluate true without condition', () => {
const element = document.createElement('div');
expect(evaluateConditionViaEvent(element)).toBeTruthy();
});
it('should dispatch event with condition and evaluate true', () => {
const element = document.createElement('div');
const condition = createCondition({ fullscreen: true });
const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
expect(ev.condition).toBe(condition);
ev.evaluation = true;
});
element.addEventListener('frigate-card:condition:evaluate', handler);
expect(evaluateConditionViaEvent(element, condition)).toBeTruthy();
expect(handler).toBeCalled();
});
it('should dispatch event with condition and evaluate false', () => {
const element = document.createElement('div');
const condition = createCondition({ fullscreen: true });
const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
expect(ev.condition).toBe(condition);
ev.evaluation = false;
});
element.addEventListener('frigate-card:condition:evaluate', handler);
expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
expect(handler).toBeCalled();
});
it('should dispatch event evaluate false if no evaluation', () => {
const element = document.createElement('div');
const condition = createCondition({ fullscreen: true });
const handler = vi.fn();
element.addEventListener('frigate-card:condition:evaluate', handler);
expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
expect(handler).toBeCalled();
});
});
describe('getOverriddenConfig', () => {
const config = {
menu: {
style: 'none',
},
};
const overrides = [
{
overrides: {
menu: {
style: 'above',
},
},
conditions: {
fullscreen: true,
},
},
];
it('should not override config', () => {
const controller = new ConditionController();
expect(getOverriddenConfig(controller, config, overrides)).toBe(config);
});
it('should override config', () => {
const controller = new ConditionController();
controller.setState({ fullscreen: true });
expect(getOverriddenConfig(controller, config, overrides)).toEqual({
menu: {
style: 'above',
},
});
});
});
describe('getOverridesByKey', () => {
const condition = {
fullscreen: true,
};
const override = {
menu: {
style: 'above',
},
};
const overrides = [
{
overrides: override,
conditions: condition,
},
];
it('should get overrides', () => {
expect(getOverridesByKey('menu', overrides)).toEqual([
{ conditions: condition, overrides: { style: 'above' } },
]);
});
it('should get no overrides', () => {
expect(getOverridesByKey('live', overrides)).toEqual([]);
});
it('should get no overrides when undefined', () => {
expect(getOverridesByKey('live')).toEqual([]);
});
});
describe('ConditionController', () => {
const config = {
type: 'custom:frigate-card',
cameras: [],
elements: [
{
type: 'custom:frigate-card-conditional',
conditions: {
fullscreen: true,
},
elements: [
{
type: 'custom:nested-unknown-object',
unknown_key: {
type: 'custom:frigate-card-conditional',
conditions: {
media_query: 'media query goes here',
},
elements: [],
},
},
],
},
],
overrides: [
{
overrides: {
menu: {
style: 'overlay',
},
},
conditions: {
fullscreen: true,
state: [
{
entity: 'binary_sensor.foo',
state: 'on',
},
],
},
},
],
};
afterEach(() => {
vi.restoreAllMocks();
});
it('should add listener', () => {
const controller = new ConditionController();
const handler = vi.fn();
controller.addStateListener(handler);
controller.setState({ fullscreen: true });
expect(handler).toBeCalled();
});
it('should remove listener', () => {
const controller = new ConditionController();
const handler = vi.fn();
controller.addStateListener(handler);
controller.removeStateListener(handler);
controller.setState({ fullscreen: true });
expect(handler).not.toBeCalled();
});
it('should get wrapper', () => {
const controller = new ConditionController();
const wrapper_1 = controller.getEpoch();
expect(wrapper_1).toEqual({ controller: controller });
controller.setState({ fullscreen: true });
const wrapper_2 = controller.getEpoch();
expect(wrapper_2).toEqual({ controller: controller });
// Since the state was set the wrappers should be different.
expect(wrapper_1).not.toBe(wrapper_2);
});
it('should not return hasHAStateConditions without HA state conditions', () => {
const controller = new ConditionController();
expect(controller.hasHAStateConditions).toBeFalsy();
});
it('should return hasHAStateConditions with HA state conditions', () => {
vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
matches: false,
addEventListener: vi.fn(),
} as unknown as MediaQueryList);
const controller = new ConditionController(createConfig(config));
expect(controller.hasHAStateConditions).toBeTruthy();
});
it('should evaluate conditions with a view', () => {
const controller = new ConditionController();
const condition = { view: ['foo'] };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ view: 'foo' });
expect(controller.evaluateCondition(condition)).toBeTruthy();
});
it('should evaluate conditions with fullscreen', () => {
const controller = new ConditionController();
const condition = { fullscreen: true };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ fullscreen: true });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ fullscreen: false });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with expand', () => {
const controller = new ConditionController();
const condition = { expand: true };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ expand: true });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ expand: false });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with camera', () => {
const controller = new ConditionController();
const condition = { camera: ['bar'] };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ camera: 'bar' });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ camera: 'will-not-match' });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with ha state positive check', () => {
const controller = new ConditionController();
const condition = {
state: [
{
entity: 'binary_sensor.foo',
state: 'on',
},
],
};
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
});
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with ha state negative check', () => {
const controller = new ConditionController();
const condition = {
state: [
{
entity: 'binary_sensor.foo',
state_not: 'on',
},
],
};
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
});
expect(controller.evaluateCondition(condition)).toBeTruthy();
});
it('should evaluate conditions with media_loaded', () => {
const controller = new ConditionController();
const condition = { media_loaded: true };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ media_loaded: true });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ media_loaded: false });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with media query', () => {
vi.spyOn(window, 'matchMedia')
.mockReturnValueOnce(<MediaQueryList>{ matches: true })
.mockReturnValueOnce(<MediaQueryList>{ matches: false });
const controller = new ConditionController();
const condition = { media_query: 'whatever' };
expect(controller.evaluateCondition(condition)).toBeTruthy();
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should trigger on changes to media query conditions', () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
matches: true,
addEventListener: addEventListener,
removeEventListener: removeEventListener,
} as unknown as MediaQueryList);
const controller = new ConditionController(createConfig(config));
expect(addEventListener).toHaveBeenCalledWith('change', expect.anything());
const callback = vi.fn();
controller.addStateListener(callback);
// Call the media query callback and use it to pretend a match happened. The
// callback is the 0th mock innvocation and the 1st argument.
addEventListener.mock.calls[0][1]();
// This should result in a callback to our state listener.
expect(callback).toBeCalled();
// Destroy the controller, which should remove the media query listener.
controller.destroy();
expect(removeEventListener).toBeCalled();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { mock } from 'vitest-mock-extended';
import {
CameraConfig,
FrigateCardCondition,
FrigateCardConfig,
cameraConfigSchema,
frigateCardConditionSchema,
frigateCardConfigSchema,
} from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/types';
export const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
return cameraConfigSchema.parse(config);
};
export const createCondition = (
condition?: Partial<FrigateCardCondition>,
): FrigateCardCondition => {
return frigateCardConditionSchema.parse(condition ?? {});
};
export const createConfig = (config?: Partial<FrigateCardConfig>): FrigateCardConfig => {
return frigateCardConfigSchema.parse(config);
};
export const createHASS = (states?: HassEntities): HomeAssistant => {
const hass = mock<HomeAssistant>();
if (states) {
hass.states = states;
}
return hass;
};
export const createRegistryEntity = (entity?: Partial<Entity>): Entity => {
return {
config_entry_id: entity?.config_entry_id ?? null,
device_id: entity?.device_id ?? null,
disabled_by: entity?.disabled_by ?? null,
entity_id: entity?.entity_id ?? 'entity_id',
hidden_by: entity?.hidden_by ?? null,
platform: entity?.platform ?? 'platform',
translation_key: entity?.translation_key ?? null,
unique_id: entity?.unique_id ?? 'unique_id',
};
};
export const createStateEntity = (entity?: Partial<HassEntity>): HassEntity => {
return {
entity_id: entity?.entity_id ?? 'entity_id',
state: entity?.state ?? 'on',
last_changed: entity?.last_changed ?? 'never',
last_updated: entity?.last_updated ?? 'never',
attributes: entity?.attributes ?? {},
context: entity?.context ?? {
id: 'id',
parent_id: 'parent_id',
user_id: 'user_id',
},
};
};
+202
View File
@@ -0,0 +1,202 @@
import { handleActionConfig, hasAction } from 'custom-card-helpers';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { actionSchema } from '../../src/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
stopEventFromActivatingCardWideActions,
} from '../../src/utils/action';
import { createHASS } from '../test-utils';
vi.mock('custom-card-helpers');
describe('convertActionToFrigateCardCustomAction', () => {
it('should skip null action', () => {
expect(convertActionToFrigateCardCustomAction(null)).toBeFalsy();
});
it('should parse valid', () => {
expect(
convertActionToFrigateCardCustomAction({
action: 'custom:frigate-card-action',
frigate_card_action: 'download',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'download',
});
});
it('should not parse invalid', () => {
expect(convertActionToFrigateCardCustomAction('this is garbage')).toBeNull();
});
});
describe('createFrigateCardCustomAction', () => {
it('should create camera_select', () => {
expect(
createFrigateCardCustomAction('camera_select', {
camera: 'camera',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
camera: 'camera',
frigate_card_action: 'camera_select',
card_id: 'card_id',
});
});
it('should not create camera_select without camera', () => {
expect(createFrigateCardCustomAction('camera_select')).toBeNull();
});
it('should create media_player', () => {
expect(
createFrigateCardCustomAction('media_player', {
media_player: 'device',
media_player_action: 'play',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'media_player',
media_player: 'device',
media_player_action: 'play',
card_id: 'card_id',
});
});
it('should not create media_player without player or action', () => {
expect(
createFrigateCardCustomAction('media_player', {
media_player_action: 'play',
}),
).toBeNull();
expect(
createFrigateCardCustomAction('media_player', {
media_player: 'device',
}),
).toBeNull();
});
it('should create general action', () => {
expect(
createFrigateCardCustomAction('clips', {
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'clips',
card_id: 'card_id',
});
});
});
describe('getActionConfigGivenAction', () => {
const action = actionSchema.parse({
action: 'fire-dom-event',
frigate_card_action: 'clips',
});
it('should not handle undefined arguments', () => {
expect(getActionConfigGivenAction()).toBeUndefined();
});
it('should not handle unknown interactions', () => {
expect(
getActionConfigGivenAction('triple_poke', { triple_poke_action: action }),
).toBeUndefined();
});
it('should handle tap actions', () => {
expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action);
});
it('should handle hold actions', () => {
expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
});
it('should handle double_tap actions', () => {
expect(getActionConfigGivenAction('double_tap', { double_tap_action: action })).toBe(
action,
);
});
it('should handle end_tap actions', () => {
expect(getActionConfigGivenAction('end_tap', { end_tap_action: action })).toBe(
action,
);
});
it('should handle start_tap actions', () => {
expect(getActionConfigGivenAction('start_tap', { start_tap_action: action })).toBe(
action,
);
});
});
// @vitest-environment jsdom
describe('frigateCardHandleActionConfig', () => {
const element = document.createElement('div');
const action = actionSchema.parse({
action: 'none',
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not handle missing arguments', () => {
expect(
frigateCardHandleActionConfig(element, createHASS(), {}, 'triple_poke'),
).toBeFalsy();
});
it('should handle simple case', () => {
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action);
expect(handleActionConfig).toBeCalled();
});
it('should handle array case', () => {
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [
action,
action,
action,
]);
expect(handleActionConfig).toBeCalledTimes(3);
});
});
describe('frigateCardHasAction', () => {
const action = actionSchema.parse({
action: 'toggle',
});
afterEach(() => {
vi.clearAllMocks();
});
it('should handle non-array case', () => {
expect(frigateCardHasAction(action)).toBeFalsy();
expect(hasAction).toBeCalledTimes(1);
});
it('should handle array case', () => {
expect(frigateCardHasAction([action, action, action])).toBeFalsy();
expect(hasAction).toBeCalledTimes(3);
});
});
// @vitest-environment jsdom
describe('stopEventFromActivatingCardWideActions', () => {
it('should stop event from propogating', () => {
const event = mock<Event>();
stopEventFromActivatingCardWideActions(event);
expect(event.stopPropagation).toBeCalled();
});
});