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

BREAKING CHANGE:

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

If you only used connected:

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

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

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

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

1416 lines
46 KiB
TypeScript

import { HassEntities } from 'home-assistant-js-websocket';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MicrophoneState } from '../../src/card-controller/types';
import { ConditionsManager } from '../../src/conditions/conditions-manager';
import { ConditionStateManager } from '../../src/conditions/state-manager';
import { HomeAssistant } from '../../src/ha/types';
import {
createConfig,
createHASS,
createMediaLoadedInfo,
createStateEntity,
createUser,
} from '../test-utils';
// @vitest-environment jsdom
describe('ConditionsManager', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('should evaluate conditions', () => {
describe('with a view condition', () => {
it('should match named view change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'view' as const, views: ['live'] }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ view: 'live' });
expect(manager.getEvaluation().result).toBeTruthy();
});
it('should match any view change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'view' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ view: 'clips' });
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
view: {
to: 'clips',
},
},
});
stateManager.setState({ view: 'timeline' });
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
view: {
from: 'clips',
to: 'timeline',
},
},
});
expect(listener).toBeCalledTimes(2);
});
it('should not re-trigger without a real change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'view' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ view: 'clips' });
expect(listener).toHaveBeenCalledTimes(1);
stateManager.setState({ view: 'clips' });
expect(listener).toHaveBeenCalledTimes(1);
});
});
it('with fullscreen condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'fullscreen' as const, fullscreen: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ fullscreen: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ fullscreen: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with expand condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'expand' as const, expand: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ expand: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ expand: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
describe('with camera condition', () => {
it('should match named camera change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'camera' as const, cameras: ['bar'] }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ camera: 'bar' });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ camera: 'will-not-match' });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match any camera change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'camera' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ camera: 'bar' });
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
camera: {
to: 'bar',
},
},
});
stateManager.setState({ camera: 'foo' });
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
camera: {
from: 'bar',
to: 'foo',
},
},
});
expect(listener).toBeCalledTimes(2);
});
it('should not re-trigger without a real change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'camera' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ camera: 'bar' });
expect(listener).toHaveBeenCalledTimes(1);
stateManager.setState({ camera: 'bar' });
expect(listener).toHaveBeenCalledTimes(1);
});
});
describe('with stock HA conditions', () => {
describe('with state condition', () => {
it('neither positive nor negative', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'state' as const,
entity: 'binary_sensor.foo',
},
],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
});
expect(listener).toBeCalledWith({
result: true,
triggerData: {
state: {
entity: 'binary_sensor.foo',
to: 'on',
},
},
});
expect(listener).toBeCalledTimes(1);
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(listener).toBeCalledWith({
result: true,
triggerData: {
state: {
entity: 'binary_sensor.foo',
from: 'on',
to: 'off',
},
},
});
expect(listener).toBeCalledTimes(2);
});
describe('positive', () => {
it('single state', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'state' as const,
entity: 'binary_sensor.foo',
state: 'on',
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('multiple states', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'state' as const,
entity: 'binary_sensor.foo',
state: ['active', 'on'],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'active' }),
}),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
});
describe('negative', () => {
it('single state', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'state' as const,
entity: 'binary_sensor.foo',
state_not: 'on',
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
});
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(manager.getEvaluation().result).toBeTruthy();
});
});
it('multiple states', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'state' as const,
entity: 'binary_sensor.foo',
state_not: ['active', 'on'],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
});
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'active' }),
}),
});
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(manager.getEvaluation().result).toBeTruthy();
});
it('implicit state condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
entity: 'binary_sensor.foo',
state: 'on',
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match any state change when state and state_not omitted', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{ condition: 'state' as const, entity: 'switch.one' },
{ condition: 'state' as const, entity: 'switch.two' },
],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({
hass: createHASS({
'switch.one': createStateEntity({ state: 'on' }),
'switch.two': createStateEntity({ state: 'off' }),
}),
});
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
// Only the last matching state will be included in the data.
state: {
entity: 'switch.two',
to: 'off',
},
},
});
stateManager.setState({
hass: createHASS({
'switch.one': createStateEntity({ state: 'off' }),
'switch.two': createStateEntity({ state: 'on' }),
}),
});
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
// Only the last matching state will be included in the data.
state: {
entity: 'switch.two',
from: 'off',
to: 'on',
},
},
});
expect(listener).toBeCalledTimes(2);
});
it('should not re-trigger without a real change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'state' as const, entity: 'switch.one' }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
const hass = createHASS({
'switch.one': createStateEntity({ state: 'on' }),
});
stateManager.setState({
hass,
});
expect(listener).toBeCalledTimes(1);
expect(manager.getEvaluation()).toEqual({
result: true,
triggerData: {
// Only the last matching state will be included in the data.
state: {
entity: 'switch.one',
to: 'on',
},
},
});
stateManager.setState({ hass });
expect(listener).toBeCalledTimes(1);
});
});
describe('with numeric state condition', () => {
it('above', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'numeric_state' as const,
entity: 'sensor.foo',
above: 10,
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: '9' }) }),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('below', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'numeric_state' as const,
entity: 'sensor.foo',
below: 10,
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }),
});
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }),
});
expect(manager.getEvaluation().result).toBeTruthy();
});
});
describe('with template condition', () => {
const createHASSForTemplateCondition = (states: HassEntities): HomeAssistant => {
const hass = createHASS(states);
vi.mocked(hass.connection.sendMessagePromise).mockResolvedValue([]);
return hass;
};
it('should evaluate true when template evalutes to true', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'template' as const,
value_template: '{{ is_state("sensor.foo", "on") }}',
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASSForTemplateCondition({
'sensor.foo': createStateEntity({ state: 'on' }),
}),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASSForTemplateCondition({
'sensor.foo': createStateEntity({ state: 'off' }),
}),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should evaluate false when template evalutes to non-boolean', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'template' as const,
// This does not result in a boolean.
value_template: '{{ hass.states["light.office"].state }}',
},
],
stateManager,
);
stateManager.setState({
hass: createHASSForTemplateCondition({
'light.office': createStateEntity({ state: 'on' }),
}),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
});
it('should not call listeners for HA state changes without relevant condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'fullscreen' as const,
fullscreen: true,
},
],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({
hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }),
});
expect(listener).not.toBeCalled();
});
});
it('with user condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'user' as const,
users: ['user_1', 'user_2'],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
hass: createHASS({}, createUser({ id: 'user_1' })),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
hass: createHASS({}, createUser({ id: 'user_WRONG' })),
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with media loaded condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'media_loaded' as const, media_loaded: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ mediaLoadedInfo: createMediaLoadedInfo() });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ mediaLoadedInfo: null });
expect(manager.getEvaluation().result).toBeFalsy();
});
describe('with screen condition', () => {
it('on evaluation', () => {
vi.spyOn(window, 'matchMedia')
.mockReturnValueOnce({
addEventListener: vi.fn(),
} as unknown as MediaQueryList)
.mockReturnValueOnce({
matches: true,
} as unknown as MediaQueryList);
const manager = new ConditionsManager([
{ condition: 'screen' as const, media_query: 'whatever' },
]);
expect(manager.getEvaluation().result).toBeTruthy();
});
it('on trigger', () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
vi.spyOn(window, 'matchMedia')
.mockReturnValueOnce({
addEventListener: addEventListener,
removeEventListener: removeEventListener,
} as unknown as MediaQueryList)
.mockReturnValueOnce({
matches: false,
} as unknown as MediaQueryList)
.mockReturnValueOnce({
matches: true,
} as unknown as MediaQueryList);
const manager = new ConditionsManager([
{
condition: 'screen' as const,
media_query: 'media query goes here',
},
]);
expect(addEventListener).toHaveBeenCalledWith('change', expect.anything());
const callback = vi.fn();
manager.addListener(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).toBeCalledWith({ result: true, triggerData: {} });
// Destroy the manager and ensure the event listener is removed.
manager.destroy();
expect(removeEventListener).toBeCalled();
});
});
it('with display mode condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'display_mode' as const, display_mode: 'grid' as const }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ displayMode: 'grid' });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ displayMode: 'single' });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with triggered condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'triggered' as const, triggered: ['camera_1', 'camera_2'] }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ triggered: new Set(['camera_1']) });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
triggered: new Set(['camera_2', 'camera_1', 'camera_3']),
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ triggered: new Set(['camera_3']) });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with interaction condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'interaction' as const, interaction: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ interaction: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ interaction: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
describe('with microphone condition', () => {
const createMicrophoneState = (
state: Partial<MicrophoneState>,
): MicrophoneState => {
return {
connected: false,
muted: false,
forbidden: false,
...state,
};
};
it('muted is true', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'microphone' as const, muted: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ microphone: createMicrophoneState({ muted: true }) });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ microphone: createMicrophoneState({ muted: false }) });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('muted is false', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'microphone' as const, muted: false }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ microphone: createMicrophoneState({ muted: true }) });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ microphone: createMicrophoneState({ muted: false }) });
expect(manager.getEvaluation().result).toBeTruthy();
});
});
describe('with call condition', () => {
it('bare form defaults to call:true', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'call' as const }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ call: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ call: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('call is true', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'call' as const, call: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ call: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ call: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('call is false', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'call' as const, call: false }],
stateManager,
);
// With no state.call published, the bare condition matches `false`,
// so `call: false` is satisfied initially.
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ call: true });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ call: false });
expect(manager.getEvaluation().result).toBeTruthy();
});
});
describe('with key condition', () => {
it('simple keypress', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'key' as const, key: 'a' }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
keys: {
a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false },
},
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
keys: {
a: { state: 'up', ctrl: false, shift: false, alt: false, meta: false },
},
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('keypress with modifiers', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'key' as const,
key: 'a',
state: 'down' as const,
ctrl: true,
shift: true,
alt: true,
meta: true,
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
keys: {
a: { state: 'down', ctrl: false, shift: false, alt: false, meta: false },
},
});
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
keys: {
a: { state: 'down', ctrl: true, shift: true, alt: true, meta: false },
},
});
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
keys: {
a: { state: 'down', ctrl: true, shift: true, alt: true, meta: true },
},
});
expect(manager.getEvaluation().result).toBeTruthy();
});
});
describe('with user agent condition', () => {
const userAgent =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
it('should match exact user agent', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'user_agent' as const, user_agent: userAgent }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
userAgent: userAgent,
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
userAgent: 'Something else',
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match user agent regex', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'user_agent' as const, user_agent_re: 'Chrome/' }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
userAgent: userAgent,
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
userAgent: 'Something else',
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match casting', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'user_agent' as const, casting: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
userAgent: 'CrKey/1.0',
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
userAgent: userAgent,
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match companion app', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'user_agent' as const, companion: true }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
userAgent: 'Home Assistant/',
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
userAgent: userAgent,
});
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match multiple parameters', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'user_agent' as const,
companion: true,
user_agent: 'Home Assistant/',
user_agent_re: 'Home.Assistant',
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({
userAgent: 'Home Assistant/',
});
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({
userAgent: 'Something else',
});
expect(manager.getEvaluation().result).toBeFalsy();
});
});
describe('with config condition', () => {
const config_1 = createConfig({
// Default is:
//
// view: {
// default: live,
// },
});
const config_2 = createConfig({
view: {
default: 'clips',
},
});
const config_3 = createConfig({
view: {
default: 'clips',
default_cycle_camera: true,
},
});
const config_4 = createConfig({
view: {
default: 'clips',
default_cycle_camera: true,
dim: true,
},
});
it('should match any config change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'config' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ config: config_1 });
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
config: {
to: config_1,
},
},
});
stateManager.setState({ config: config_2 });
expect(listener).toHaveBeenLastCalledWith({
result: true,
triggerData: {
config: {
from: config_1,
to: config_2,
},
},
});
expect(listener).toBeCalledTimes(2);
});
it('should match specific config change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'config' as const, paths: ['view.default'] }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ config: config_1 });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ config: config_2 });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ config: config_3 });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should match multiple config change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'config' as const,
paths: ['view.default', 'view.default_cycle_camera'],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ config: config_1 });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ config: config_2 });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ config: config_3 });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ config: config_4 });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('should not match unrelated changes', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'config' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ config: config_1 });
expect(listener).toBeCalledTimes(1);
// On the next state set, the condition won't match anymore (as the
// config is the same), the listener will still be called to indicate
// the condition evaluation has changed.
stateManager.setState({ expand: true });
expect(listener).toBeCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ result: false });
// Future unrelated state changes won't call the listener.
stateManager.setState({ fullscreen: true });
expect(listener).toBeCalledTimes(2);
});
it('should not re-trigger without a real change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'config' as const }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ config: config_1 });
expect(listener).toHaveBeenCalledTimes(1);
stateManager.setState({ config: config_1 });
expect(listener).toHaveBeenCalledTimes(1);
});
});
it('with initialized condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'initialized' as const }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ initialized: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ initialized: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with simple OR condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'or' as const,
conditions: [
{
condition: 'fullscreen' as const,
fullscreen: true,
},
{
condition: 'expand' as const,
expand: true,
},
],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ fullscreen: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ expand: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ fullscreen: false });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ expand: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with triggered OR condition', () => {
const stateManager = new ConditionStateManager();
// This is not a terribly realistic example, but chosen so that trigger
// data for both camera and view should be returned.
const manager = new ConditionsManager(
[
{
condition: 'or' as const,
conditions: [
{ condition: 'camera' as const },
{ condition: 'view' as const },
],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ camera: 'camera-1' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
camera: { to: 'camera-1' },
});
stateManager.setState({ view: 'live' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
view: { to: 'live' },
});
stateManager.setState({ camera: 'camera-2', view: 'clip' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
camera: { to: 'camera-2', from: 'camera-1' },
// View data will not be here as the view condition is not evaluated,
// since the camera one will evaluate to true first.
});
});
it('with simple AND condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'and' as const,
conditions: [
{
condition: 'fullscreen' as const,
fullscreen: true,
},
{
condition: 'expand' as const,
expand: true,
},
],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ fullscreen: true });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ expand: true });
expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ fullscreen: false });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ expand: false });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with triggered AND condition', () => {
const stateManager = new ConditionStateManager();
// This is not a terribly realistic example, but chosen so that trigger
// data for both camera and view should be returned.
const manager = new ConditionsManager(
[
{
condition: 'and' as const,
conditions: [
{ condition: 'camera' as const },
{ condition: 'view' as const },
],
},
],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ camera: 'camera-1' });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ view: 'live' });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ camera: 'camera-2', view: 'clip' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
camera: { from: 'camera-1', to: 'camera-2' },
view: { from: 'live', to: 'clip' },
});
stateManager.setState({ view: 'snapshot' });
expect(manager.getEvaluation().result).toBeFalsy();
});
it('with not condition', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[
{
condition: 'not' as const,
conditions: [
{
condition: 'fullscreen' as const,
fullscreen: true,
},
{
condition: 'expand' as const,
expand: true,
},
],
},
],
stateManager,
);
// Neither sub-condition is true initially, so `not` passes.
expect(manager.getEvaluation().result).toBeTruthy();
// fullscreen becomes true — any sub-condition being true means `not` fails.
stateManager.setState({ fullscreen: true });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ expand: true });
expect(manager.getEvaluation().result).toBeFalsy();
// fullscreen reverts, but expand is still true, so `not` still fails.
stateManager.setState({ fullscreen: false });
expect(manager.getEvaluation().result).toBeFalsy();
// Both sub-conditions are false again, so `not` passes.
stateManager.setState({ expand: false });
expect(manager.getEvaluation().result).toBeTruthy();
// `not` conditions never have trigger data (as nothing is triggering).
expect(manager.getEvaluation().triggerData).toEqual({});
});
});
describe('should handle listeners correctly', () => {
it('should add listener', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'fullscreen' as const, fullscreen: true }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ fullscreen: true });
expect(listener).toBeCalledWith({ result: true, triggerData: {} });
expect(listener).toBeCalledTimes(1);
stateManager.setState({ fullscreen: false });
expect(listener).toBeCalledWith({ result: false });
expect(listener).toBeCalledTimes(2);
// Re-add the same listener (will still only be called once).
manager.addListener(listener);
stateManager.setState({ fullscreen: true });
expect(listener).toBeCalledWith({ result: true, triggerData: {} });
expect(listener).toBeCalledTimes(3);
});
it('should remove listener', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'fullscreen' as const, fullscreen: true }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
manager.removeListener(listener);
stateManager.setState({ fullscreen: true });
expect(listener).not.toBeCalled();
});
it('should remove listener on destroy', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'fullscreen' as const, fullscreen: true }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
manager.destroy();
stateManager.setState({ fullscreen: true });
expect(listener).not.toBeCalled();
});
it('with not call listeners when condition result does not change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'view' as const, views: ['live'] }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ view: 'live' });
expect(listener).toBeCalledTimes(1);
stateManager.setState({ view: 'clip' });
expect(listener).toBeCalledTimes(2);
stateManager.setState({ view: 'clip' });
expect(listener).toBeCalledTimes(2);
stateManager.setState({ view: 'live' });
expect(listener).toBeCalledTimes(3);
stateManager.setState({ view: 'live' });
expect(listener).toBeCalledTimes(3);
});
});
});