Files
advanced-camera-card/tests/card-controller/config/load-control-entities.test.ts
T
Dermot Duffy 17146c8ca6 fix: Clear the initialized condition state when the card is not usable (#2646)
The `initialized` state (used in conditions/triggers) was written once
and never cleared, so it meant "has this card ever been initialized"
while everything reading it took it as "is this card usable now". Home
Assistant takes a card off the page and puts it back whenever its
dashboard tab is left and returned to, so the card initialized again
while the state claimed it was initialized throughout: `trigger:
initialized` fired once per card rather than once per startup, and
automations were dropped in between.

The card lifecycle is now an explicit state machine (`SessionManager`),
the only writer of `initialized`, which separates a card that is
starting up from one initializing part of itself again while it runs. A
new `ever` parameter (conditions/triggers) selects the old latched
behaviour.

`remote_control` uses that parameter to keep its two camera priorities
correct under repeated starts. With `camera_priority: entity` the card
now re-reads the entity every time it starts, so a camera selected while
the card was away is picked up on return. With `camera_priority: card`
the card writes the entity on its first start only, unchanged, since
repeating that write would overwrite a camera the user had selected.

Closes: #2642


BREAKING CHANGE: `condition: initialized` is now `false` whenever the
card is not usable, and `trigger: initialized` fires each time the card
starts up rather than only the first time. Set `ever: true` on either to
keep the previous behaviour.
2026-08-02 12:52:58 -07:00

782 lines
25 KiB
TypeScript

import { assert, describe, expect, it, vi } from 'vitest';
import { AutomationsManager } from '../../../src/card-controller/automations-manager';
import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities';
import type { CardController } from '../../../src/card-controller/controller';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import {
GENERATED_ACTION,
type ActionGenerator,
type GeneratedActionConfig,
} from '../../../src/config/schema/actions/custom/generated-action';
import {
INTERNAL_CALLBACK_ACTION,
type InternalCallbackActionConfig,
} from '../../../src/config/schema/actions/custom/internal';
import type { ActionConfig } from '../../../src/config/schema/actions/types';
import {
createCameraAction,
isAdvancedCameraCardCustomAction,
} from '../../../src/utils/action';
import { arrayify } from '../../../src/utils/basic';
import { createStore } from '../../camera-manager/test-utils';
import { createConfig } from '../../config/test-utils';
import { createCardAPI, createHASS, createStateEntity } from '../../test-utils';
import { createView } from '../../view/test-utils';
const isGeneratedAction = (action: ActionConfig): action is GeneratedActionConfig =>
'advanced_camera_card_action' in action &&
action.advanced_camera_card_action === GENERATED_ACTION;
describe('setRemoteControlEntityFromConfig', () => {
it('without control entity', () => {
const api = createCardAPI();
setRemoteControlEntityFromConfig(api);
expect(api.getAutomationsManager().deleteAutomations).toHaveBeenCalled();
expect(api.getAutomationsManager().addAutomations).not.toHaveBeenCalled();
});
it('with control entity and card priority', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
setRemoteControlEntityFromConfig(api);
expect(api.getAutomationsManager().deleteAutomations).toHaveBeenCalled();
expect(api.getAutomationsManager().addAutomations).toHaveBeenCalledWith([
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__',
callback: expect.any(Function),
},
],
triggers: [
{
trigger: 'config',
paths: ['cameras', 'remote_control.entities.camera'],
},
],
tag: setRemoteControlEntityFromConfig,
},
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__',
callback: expect.any(Function),
},
],
triggers: [
{
trigger: 'camera',
},
],
tag: setRemoteControlEntityFromConfig,
},
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__',
callback: expect.any(Function),
},
],
triggers: [
{
trigger: 'initialized',
// Writes the entity, so it asserts the card's camera once and does
// not repeat on a later initialization.
ever: true,
},
],
tag: setRemoteControlEntityFromConfig,
},
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__GENERATED_ACTION__',
generator: expect.any(Function),
},
],
triggers: [
{
trigger: 'state',
entity_id: 'input_select.camera',
},
],
tag: setRemoteControlEntityFromConfig,
},
]);
});
it('with control entity and entity priority', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'entity',
},
},
}),
);
setRemoteControlEntityFromConfig(api);
expect(api.getAutomationsManager().deleteAutomations).toHaveBeenCalled();
expect(api.getAutomationsManager().addAutomations).toHaveBeenCalledWith([
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__',
callback: expect.any(Function),
},
],
triggers: [
{
trigger: 'config',
paths: ['cameras', 'remote_control.entities.camera'],
},
],
tag: setRemoteControlEntityFromConfig,
},
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__',
callback: expect.any(Function),
},
],
triggers: [
{
trigger: 'camera',
},
],
tag: setRemoteControlEntityFromConfig,
},
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__GENERATED_ACTION__',
generator: expect.any(Function),
},
],
triggers: [
{
trigger: 'initialized',
// Only updates the card, so it repeats on every initialization and
// picks up an entity change made while the card could not act.
ever: false,
},
],
tag: setRemoteControlEntityFromConfig,
},
{
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: '__GENERATED_ACTION__',
generator: expect.any(Function),
},
],
triggers: [
{
trigger: 'state',
entity_id: 'input_select.camera',
},
],
tag: setRemoteControlEntityFromConfig,
},
]);
});
describe('should set options', () => {
it('should set options when they are incorrect', () => {
const hass = createHASS();
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
},
},
}),
);
const store = createStore([
{
cameraID: 'camera.one',
},
{
cameraID: 'camera.two',
},
]);
vi.mocked(api.getCameraManager().getStore).mockReturnValue(store);
setRemoteControlEntityFromConfig(api);
const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][0].actions?.[0] as InternalCallbackActionConfig;
expect(addOptionsAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(addOptionsAction)).toBeTruthy();
expect(addOptionsAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
addOptionsAction.callback(api);
expect(hass.callService).toHaveBeenCalledWith(
'input_select',
'set_options',
{
options: ['camera.one', 'camera.two'],
},
{
entity_id: 'input_select.camera',
},
);
});
it('should not set options when they are already correct', () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
attributes: { options: ['camera.one', 'camera.two'] },
}),
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
},
},
}),
);
const store = createStore([
{
cameraID: 'camera.one',
},
{
cameraID: 'camera.two',
},
]);
vi.mocked(api.getCameraManager().getStore).mockReturnValue(store);
setRemoteControlEntityFromConfig(api);
const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][0].actions?.[0] as InternalCallbackActionConfig;
expect(addOptionsAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(addOptionsAction)).toBeTruthy();
expect(addOptionsAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
addOptionsAction.callback(api);
expect(hass.callService).not.toHaveBeenCalled();
});
it('should not throw when hass is undefined setting options', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
},
},
}),
);
const store = createStore([
{
cameraID: 'camera.one',
},
{
cameraID: 'camera.two',
},
]);
vi.mocked(api.getCameraManager().getStore).mockReturnValue(store);
setRemoteControlEntityFromConfig(api);
const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][0].actions?.[0] as InternalCallbackActionConfig;
expect(addOptionsAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(addOptionsAction)).toBeTruthy();
expect(addOptionsAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
// Should not throw
addOptionsAction.callback(api);
});
});
describe('should select option on entity', () => {
it('should select option when camera differs from entity state', async () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
state: 'camera.one',
}),
});
const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.two' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.two',
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
// Get the camera sync callback (automation index 1)
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
expect(cameraSyncAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy();
expect(cameraSyncAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
await cameraSyncAction.callback(api);
expect(hass.callService).toHaveBeenCalledWith(
'input_select',
'select_option',
{
option: 'camera.two',
},
{
entity_id: 'input_select.camera',
},
);
});
it('should not select option when camera matches entity state', () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
state: 'camera.one',
}),
});
const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.one' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.one',
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
// Get the camera sync callback (automation index 1)
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
expect(cameraSyncAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy();
expect(cameraSyncAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
cameraSyncAction.callback(api);
// Should NOT call select_option since entity already shows camera.one
expect(hass.callService).not.toHaveBeenCalled();
});
it('should not select option when camera is undefined', () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
state: 'camera.one',
}),
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(null);
setRemoteControlEntityFromConfig(api);
// Get the camera sync callback (automation index 1)
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
expect(cameraSyncAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy();
expect(cameraSyncAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
cameraSyncAction.callback(api);
// Should NOT call select_option since camera is undefined
expect(hass.callService).not.toHaveBeenCalled();
});
it('should not select option when view exists but has no camera', () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
state: 'camera.one',
}),
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: null,
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
// Test the 'camera' condition callback (automation index 1)
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
cameraSyncAction.callback(api);
expect(hass.callService).not.toHaveBeenCalled();
// Also test the 'initialized' condition callback (automation index 2)
const initializedSyncAction = vi.mocked(api.getAutomationsManager().addAutomations)
.mock.calls[0][0][2].actions?.[0] as InternalCallbackActionConfig;
initializedSyncAction.callback(api);
expect(hass.callService).not.toHaveBeenCalled();
});
it('should not throw when hass is undefined', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.two',
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
expect(cameraSyncAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy();
expect(cameraSyncAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
// Should not throw and obviously not call service (as hass is null)
await cameraSyncAction.callback(api);
});
it('should select option when entity state is undefined', async () => {
const hass = createHASS({});
const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.two' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.two',
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
expect(cameraSyncAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy();
expect(cameraSyncAction.advanced_camera_card_action).toBe(
INTERNAL_CALLBACK_ACTION,
);
await cameraSyncAction.callback(api);
expect(hass.callService).toHaveBeenCalledWith(
'input_select',
'select_option',
{
option: 'camera.two',
},
{
entity_id: 'input_select.camera',
},
);
});
it('should select option on initialization with card priority', async () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
state: 'camera.one',
}),
});
const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.two' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera.two',
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
// Get the initialization callback (automation index 2)
const initAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][2].actions?.[0] as InternalCallbackActionConfig;
expect(initAction).toBeTruthy();
expect(isAdvancedCameraCardCustomAction(initAction)).toBeTruthy();
expect(initAction.advanced_camera_card_action).toBe(INTERNAL_CALLBACK_ACTION);
await initAction.callback(api);
expect(hass.callService).toHaveBeenCalledWith(
'input_select',
'select_option',
{
option: 'camera.two',
},
{
entity_id: 'input_select.camera',
},
);
});
});
describe('should wire the firing trigger into the camera action', () => {
it('should carry the new entity state to the camera-select action', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
// Capture the automations the loader registers, then drive them through a
// real AutomationsManager so the state trigger actually fires. The
// registered action is a generated action whose generator reads the
// firing trigger's `to_state` to pick the camera, so this verifies that
// the generator and the exact triggering value are wired through together
// (the other tests only string-match the registered action shape, so a
// context mis-wire would silently break remote control).
setRemoteControlEntityFromConfig(api);
const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0];
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(true);
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
new AutomationsManager(api).addAutomations(automations);
stateManager.setState({
hass: createHASS({
'input_select.camera': createStateEntity({ state: 'camera.one' }),
}),
});
const hass = createHASS({
'input_select.camera': createStateEntity({ state: 'camera.two' }),
});
stateManager.setState({ hass });
const captured = vi
.mocked(api.getActionsManager().executeActions)
.mock.calls.at(-1)?.[0];
assert(captured);
expect(captured.actions).toEqual([
{
action: 'fire-dom-event',
advanced_camera_card_action: '__GENERATED_ACTION__',
generator: expect.any(Function),
},
]);
expect(captured.triggerData?.platform).toBe('state');
expect(captured.triggerData?.entity_id).toBe('input_select.camera');
expect(captured.triggerData?.to_state?.state).toBe('camera.two');
// Running the generated action's generator with the firing trigger
// selects the camera named by the new state.
const generated = arrayify(captured.actions)[0];
assert(isGeneratedAction(generated));
expect(generated.generator({ api, triggerData: captured.triggerData })).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'camera_select',
camera: 'camera.two',
});
});
});
describe('should generate a camera-select action only when a camera is named', () => {
// Register the automations for a given camera priority, then pull the
// generator off the generated action on the named trigger's automation.
const getGenerator = (
api: CardController,
cameraPriority: 'card' | 'entity',
triggerName: string,
): ActionGenerator => {
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: cameraPriority,
},
},
}),
);
setRemoteControlEntityFromConfig(api);
const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0];
const automation = automations.find((candidate) =>
candidate.triggers.some((trigger) => trigger.trigger === triggerName),
);
assert(automation);
const action = arrayify(automation.actions)[0];
assert(isGeneratedAction(action));
return action.generator;
};
it('should select the camera named by the entity state on initialize', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
createHASS({
'input_select.camera': createStateEntity({ state: 'camera.kitchen' }),
}),
);
expect(getGenerator(api, 'entity', 'initialized')({ api })).toEqual(
createCameraAction('camera.kitchen'),
);
});
it('should generate nothing on initialize when the entity has no state', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS({}));
expect(getGenerator(api, 'entity', 'initialized')({ api })).toBeNull();
});
it('should generate nothing for a state trigger that carries no to_state', () => {
const api = createCardAPI();
expect(
getGenerator(
api,
'card',
'state',
)({
api,
triggerData: { platform: 'state', entity_id: 'input_select.camera' },
}),
).toBeNull();
});
});
});