From ff78cc51a1f9869402bc08d7d36ce4ac2eed14b0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 10 Mar 2024 14:30:45 -0700 Subject: [PATCH] Add support for users and numeric_conditions. --- src/card-controller/conditions-manager.ts | 32 +- src/card-controller/hass-manager.ts | 5 +- src/config/types.ts | 53 +- .../conditions-manager.test.ts | 689 +++++++++++------- tests/card-controller/hass-manager.test.ts | 5 +- tests/config/types.test.ts | 2 + tests/test-utils.ts | 17 +- 7 files changed, 522 insertions(+), 281 deletions(-) diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index 848d2a1e..6d326d1f 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -1,3 +1,4 @@ +import { CurrentUser } from '@dermotduffy/custom-card-helpers'; import { HassEntities } from 'home-assistant-js-websocket'; import merge from 'lodash-es/merge'; import { copyConfig } from '../config-mgmt'; @@ -22,6 +23,7 @@ interface ConditionState { triggered?: Set; interaction?: boolean; microphone?: MicrophoneConditionState; + user?: CurrentUser; } export class ConditionEvaluateRequestEvent extends Event { @@ -174,7 +176,10 @@ export class ConditionsManager { const conditions = getAllConditions(); this._hasHAStateConditions = conditions.some( - (condition) => !!condition.state?.length, + (condition) => + !!condition.state?.length || + !!condition.numeric_state?.length || + !!condition.users?.length, ); conditions.forEach((condition) => { if (condition.media_query) { @@ -192,7 +197,7 @@ export class ConditionsManager { }; this._triggerChange(); } - + public getState(): ConditionState { return this._state; } @@ -235,11 +240,30 @@ export class ConditionsManager { ((!stateTest.state && !stateTest.state_not) || (stateTest.entity in state.state && (!stateTest.state || - state.state[stateTest.entity].state === stateTest.state) && + (Array.isArray(stateTest.state) + ? stateTest.state.includes(state.state[stateTest.entity].state) + : stateTest.state === state.state[stateTest.entity].state)) && (!stateTest.state_not || - state.state[stateTest.entity].state !== stateTest.state_not))); + (Array.isArray(stateTest.state_not) + ? !stateTest.state_not.includes(state.state[stateTest.entity].state) + : stateTest.state_not !== state.state[stateTest.entity].state)))); } } + if (condition.numeric_state?.length) { + for (const stateTest of condition.numeric_state) { + result &&= + !!state.state && + stateTest.entity in state.state && + state.state[stateTest.entity].state !== undefined && + (stateTest.above === undefined || + Number(state.state[stateTest.entity].state) > stateTest.above) && + (stateTest.below === undefined || + Number(state.state[stateTest.entity].state) < stateTest.below); + } + } + if (condition.users?.length) { + result &&= !!state.user && condition.users.includes(state.user.id); + } if (condition.media_loaded !== undefined) { result &&= state.media_loaded !== undefined && diff --git a/src/card-controller/hass-manager.ts b/src/card-controller/hass-manager.ts index db50c7c9..9c0d3503 100644 --- a/src/card-controller/hass-manager.ts +++ b/src/card-controller/hass-manager.ts @@ -66,7 +66,10 @@ export class HASSManager { } if (this._api.getConditionsManager().hasHAStateConditions()) { - this._api.getConditionsManager().setState({ state: this._hass.states }); + this._api.getConditionsManager().setState({ + state: this._hass.states, + user: this._hass.user, + }); } // Dark mode may depend on HASS. diff --git a/src/config/types.ts b/src/config/types.ts index 44bddb1d..d835eb1f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -400,20 +400,47 @@ const imageSchema = elementsBaseSchema.extend({ aspect_ratio: z.string().optional(), }); -// This state condition is used both for the Picture elements conditional -// schema, and also in frigateCardConditionSchema. -const stateConditions = z - .object({ - entity: z.string(), - state: z.string().optional(), - state_not: z.string().optional(), - }) - .array(); +// https://www.home-assistant.io/dashboards/conditional/#state +const stateCondition = z.object({ + condition: z.literal('state'), + entity: z.string(), + state: z.string().or(z.string().array()).optional(), + state_not: z.string().or(z.string().array()).optional(), +}); + +// https://www.home-assistant.io/dashboards/conditional/#numeric-state +const numericCondition = z.object({ + condition: z.literal('numeric_state'), + entity: z.string(), + above: z.number().optional(), + below: z.number().optional(), +}); + +const mediaQuerySchema = z.string(); + +// https://www.home-assistant.io/dashboards/conditional/#screen +const screenCondition = z.object({ + condition: z.literal('screen'), + media_query: mediaQuerySchema, +}); + +// https://www.home-assistant.io/dashboards/conditional/#user +const usersCondition = z.object({ + condition: z.literal('user'), + users: z.string().array().min(1), +}); + +const stockCondition = z.union([ + stateCondition, + numericCondition, + screenCondition, + usersCondition, +]); // https://www.home-assistant.io/lovelace/picture-elements/#image-element export const conditionalSchema = z.object({ type: z.literal('conditional'), - conditions: stateConditions, + conditions: stockCondition.array(), elements: z.lazy(() => pictureElementsSchema), }); @@ -501,12 +528,14 @@ export const frigateCardConditionSchema = z.object({ expand: z.boolean().optional(), camera: z.string().array().optional(), media_loaded: z.boolean().optional(), - state: stateConditions.optional(), - media_query: z.string().optional(), + state: stateCondition.array().optional(), + numeric_state: numericCondition.array().optional(), + media_query: mediaQuerySchema.optional(), display_mode: viewDisplayModeSchema.optional(), triggered: z.string().array().optional(), interaction: z.boolean().optional(), microphone: microphoneConditionSchema.optional(), + users: z.string().array().optional() }); export type FrigateCardCondition = z.infer; diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index b40dffaf..a6f5fe9e 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FrigateCardCondition } from '../../src/config/types'; import { ConditionEvaluateRequestEvent, @@ -12,6 +12,7 @@ import { createCondition, createConfig, createStateEntity, + createUser, } from '../test-utils'; // @vitest-environment jsdom @@ -145,49 +146,6 @@ describe('getOverridesByKey', () => { }); describe('ConditionsManager', () => { - 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(); }); @@ -214,252 +172,461 @@ describe('ConditionsManager', () => { expect(manager.getState()).toEqual(state); }); - it('should not return hasHAStateConditions without HA state conditions', () => { - const manager = new ConditionsManager(createCardAPI()); - expect(manager.hasHAStateConditions()).toBeFalsy(); - }); + describe('should handle hasHAStateConditions', () => { + beforeEach(() => { + vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ + matches: false, + addEventListener: vi.fn(), + } as unknown as MediaQueryList); + }); - it('should return hasHAStateConditions with HA state conditions', () => { - vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ - matches: false, - addEventListener: vi.fn(), - } as unknown as MediaQueryList); - const api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); - const manager = new ConditionsManager(api); + afterEach(() => { + vi.restoreAllMocks(); + }); - manager.setConditionsFromConfig(); - - expect(manager.hasHAStateConditions()).toBeTruthy(); - }); - - it('should evaluate conditions with a view', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { view: ['foo'] }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ view: 'foo' }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - }); - - it('should evaluate conditions with fullscreen', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { fullscreen: true }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ fullscreen: true }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ fullscreen: false }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - it('should evaluate conditions with expand', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { expand: true }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ expand: true }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ expand: false }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - it('should evaluate conditions with camera', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { camera: ['bar'] }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ camera: 'bar' }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ camera: 'will-not-match' }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - it('should evaluate conditions with ha state positive check', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { - state: [ - { - entity: 'binary_sensor.foo', - state: 'on', - }, - ], + const createSuitableConfig = (conditions: FrigateCardCondition) => { + return createConfig({ + overrides: [ + { + overrides: {}, + conditions: conditions, + }, + ], + }); }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - it('should evaluate conditions with ha state negative check', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { - state: [ - { - entity: 'binary_sensor.foo', - state_not: 'on', - }, - ], - }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, - }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - }); - - it('should evaluate conditions with media_loaded', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition = { media_loaded: true }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ media_loaded: true }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ media_loaded: false }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - it('should evaluate conditions with media query', () => { - vi.spyOn(window, 'matchMedia') - .mockReturnValueOnce({ matches: true }) - .mockReturnValueOnce({ matches: false }); - - const manager = new ConditionsManager(createCardAPI()); - const condition = { media_query: 'whatever' }; - expect(manager.evaluateCondition(condition)).toBeTruthy(); - expect(manager.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 api = createCardAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); - const callback = vi.fn(); - const manager = new ConditionsManager(api, callback); - - manager.setConditionsFromConfig(); - - expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); - - // 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(); - - // Remove the conditions, which should remove the media query listener. - manager.removeConditions(); - expect(removeEventListener).toBeCalled(); - }); - - it('should evaluate conditions with display mode', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { display_mode: 'grid' }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ displayMode: 'grid' }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ displayMode: 'single' }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - it('should evaluate conditions with triggers', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { triggered: ['camera_1', 'camera_2'] }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ triggered: new Set(['camera_1']) }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ triggered: new Set(['camera_3']) }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - it('should evaluate conditions with interaction', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { interaction: true }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ interaction: true }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ interaction: false }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - }); - - describe('should evaluate conditions with microphone', () => { - it('empty', () => { + it('without HA state conditions', () => { const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { microphone: {} }; - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ microphone: { connected: true } }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ microphone: { connected: false } }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ microphone: { muted: true } }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ microphone: { muted: false } }); + expect(manager.hasHAStateConditions()).toBeFalsy(); + }); + + it('with HA state conditions', () => { + const api = createCardAPI(); + const numericConfig = createSuitableConfig({ + state: [ + { + condition: 'state', + entity: 'binary_sensor.foo', + state: 'on', + }, + ], + }); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(numericConfig); + const manager = new ConditionsManager(api); + manager.setConditionsFromConfig(); + + expect(manager.hasHAStateConditions()).toBeTruthy(); + }); + + it('with HA numeric_state conditions', () => { + const api = createCardAPI(); + const stateConfig = createSuitableConfig({ + numeric_state: [ + { + entity: 'sensor.foo', + condition: 'numeric_state' as const, + above: 10, + }, + ], + }); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(stateConfig); + const manager = new ConditionsManager(api); + manager.setConditionsFromConfig(); + + expect(manager.hasHAStateConditions()).toBeTruthy(); + }); + + it('with HA user conditions', () => { + const api = createCardAPI(); + const userConfig = createSuitableConfig({ + users: ['user_1'], + }); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(userConfig); + const manager = new ConditionsManager(api); + manager.setConditionsFromConfig(); + + expect(manager.hasHAStateConditions()).toBeTruthy(); + }); + }); + + describe('should evaluate conditions', () => { + it('with a view', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { view: ['foo'] }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ view: 'foo' }); expect(manager.evaluateCondition(condition)).toBeTruthy(); }); - it('connected is true', () => { + it('with fullscreen', () => { const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { microphone: { connected: true } }; + const condition = { fullscreen: true }; expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { connected: true } }); + manager.setState({ fullscreen: true }); expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ microphone: { connected: false } }); + manager.setState({ fullscreen: false }); expect(manager.evaluateCondition(condition)).toBeFalsy(); }); - it('connected is false', () => { + it('with expand', () => { const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { microphone: { connected: false } }; + const condition = { expand: true }; expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { connected: true } }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { connected: false } }); + manager.setState({ expand: true }); expect(manager.evaluateCondition(condition)).toBeTruthy(); - }); - - it('muted is true', () => { - const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { microphone: { muted: true } }; - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { muted: true } }); - expect(manager.evaluateCondition(condition)).toBeTruthy(); - manager.setState({ microphone: { muted: false } }); + manager.setState({ expand: false }); expect(manager.evaluateCondition(condition)).toBeFalsy(); }); - it('muted is false', () => { + it('camera', () => { const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { microphone: { muted: false } }; + const condition = { camera: ['bar'] }; expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { muted: true } }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { muted: false } }); + manager.setState({ camera: 'bar' }); expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ camera: 'will-not-match' }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); }); - it('connected and muted', () => { + describe('with stock HA conditions', () => { + describe('state check', () => { + describe('positive', () => { + it('single', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + state: [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state: 'on', + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('multiple', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + state: [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state: ['active', 'on'], + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + }); + + describe('negative', () => { + it('single', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + state: [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state_not: 'on', + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + }); + + it('multiple', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + state: [ + { + condition: 'state' as const, + entity: 'binary_sensor.foo', + state_not: ['active', 'on'], + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + }); + + describe('numeric state check', () => { + it('above', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + numeric_state: [ + { + condition: 'numeric_state' as const, + entity: 'sensor.foo', + above: 10, + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ + state: { 'binary_sensor.foo': createStateEntity({ state: '9' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('below', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { + numeric_state: [ + { + condition: 'numeric_state' as const, + entity: 'sensor.foo', + below: 10, + }, + ], + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ + state: { 'sensor.foo': createStateEntity({ state: '9' }) }, + }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + }); + }); + + it('with users', () => { const manager = new ConditionsManager(createCardAPI()); - const condition: FrigateCardCondition = { - microphone: { muted: false, connected: true }, + const condition = { + users: ['user_1', 'user_2'], }; expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { muted: true } }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { muted: false } }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { connected: false, muted: false } }); - expect(manager.evaluateCondition(condition)).toBeFalsy(); - manager.setState({ microphone: { connected: true, muted: false } }); + manager.setState({ + user: createUser({ id: 'user_1' }), + }); expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ + user: createUser({ id: 'user_WRONG' }), + }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('with media_loaded', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition = { media_loaded: true }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ media_loaded: true }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ media_loaded: false }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + describe('with media query', () => { + const mediaQueryConfig = { + 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: [], + }, + }, + ], + }, + ], + }; + + it('on evaluation', () => { + vi.spyOn(window, 'matchMedia') + .mockReturnValueOnce({ matches: true }) + .mockReturnValueOnce({ matches: false }); + + const manager = new ConditionsManager(createCardAPI()); + const condition = { media_query: 'whatever' }; + expect(manager.evaluateCondition(condition)).toBeTruthy(); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('on trigger', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.spyOn(window, 'matchMedia').mockReturnValueOnce({ + matches: true, + addEventListener: addEventListener, + removeEventListener: removeEventListener, + } as unknown as MediaQueryList); + const api = createCardAPI(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig(mediaQueryConfig), + ); + const callback = vi.fn(); + const manager = new ConditionsManager(api, callback); + + manager.setConditionsFromConfig(); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.anything()); + + // 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(); + + // Remove the conditions, which should remove the media query listener. + manager.removeConditions(); + expect(removeEventListener).toBeCalled(); + }); + }); + + it('with display mode', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { display_mode: 'grid' }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ displayMode: 'grid' }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ displayMode: 'single' }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('with triggers', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { triggered: ['camera_1', 'camera_2'] }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ triggered: new Set(['camera_1']) }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ triggered: new Set(['camera_3']) }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('with interaction', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { interaction: true }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ interaction: true }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ interaction: false }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + describe('with microphone', () => { + it('empty', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: {} }; + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { connected: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { connected: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('connected is true', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { connected: true } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { connected: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('connected is false', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { connected: false } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: true } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('muted is true', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { muted: true } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + }); + + it('muted is false', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { microphone: { muted: false } }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); + + it('connected and muted', () => { + const manager = new ConditionsManager(createCardAPI()); + const condition: FrigateCardCondition = { + microphone: { muted: false, connected: true }, + }; + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: true } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { muted: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: false, muted: false } }); + expect(manager.evaluateCondition(condition)).toBeFalsy(); + manager.setState({ microphone: { connected: true, muted: false } }); + expect(manager.evaluateCondition(condition)).toBeTruthy(); + }); }); }); }); diff --git a/tests/card-controller/hass-manager.test.ts b/tests/card-controller/hass-manager.test.ts index 45205f0e..3d0a1fa3 100644 --- a/tests/card-controller/hass-manager.test.ts +++ b/tests/card-controller/hass-manager.test.ts @@ -9,6 +9,7 @@ import { createHASS, createStateEntity, createStore, + createUser, createView, } from '../test-utils'; @@ -44,13 +45,15 @@ describe('HASSManager', () => { vi.mocked(api.getConditionsManager().hasHAStateConditions).mockReturnValue(true); const states = { 'switch.foo': createStateEntity() }; - const hass = createHASS(states); + const user = createUser({ id: 'user_1' }); + const hass = createHASS(states, user); manager.setHASS(hass); expect(api.getConditionsManager().setState).toBeCalledWith( expect.objectContaining({ state: states, + user: user, }), ); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 632586a7..c3b2d4c2 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -381,6 +381,7 @@ describe('should lazy evaluate', () => { type: 'conditional', conditions: [ { + condition: 'state', entity: 'light.office_main_lights', state: 'on', state_not: 'off', @@ -401,6 +402,7 @@ describe('should lazy evaluate', () => { ).toEqual({ conditions: [ { + condition: 'state', entity: 'light.office_main_lights', state: 'on', state_not: 'off', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 773c3220..721c1439 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,4 +1,4 @@ -import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { CurrentUser, HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { expect, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -80,15 +80,28 @@ export const createCamera = ( return new Camera(config, engine, { capabilities: capabilities }); }; -export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => { +export const createHASS = (states?: HassEntities, user?: CurrentUser): ExtendedHomeAssistant => { const hass = mock(); if (states) { hass.states = states; } + if (user) { + hass.user = user; + } hass.connection.subscribeMessage = vi.fn(); return hass; }; +export const createUser = (user?: Partial): CurrentUser => ({ + id: 'user', + is_owner: false, + is_admin: false, + name: 'User', + credentials: [], + mfa_modules: [], + ...user, +}); + export const createRegistryEntity = (entity?: Partial): Entity => { return { config_entry_id: entity?.config_entry_id ?? null,