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.
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import type { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js';
|
|
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
|
import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
|
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
|
import {
|
|
createCardAPI,
|
|
createHASS,
|
|
createHASSEvent,
|
|
flushPromises,
|
|
} from '../test-utils.js';
|
|
|
|
describe('AutomationsManager', () => {
|
|
const actions = [
|
|
{
|
|
action: 'fire-dom-event' as const,
|
|
advanced_camera_card_action: 'clips',
|
|
},
|
|
];
|
|
const triggers = [{ trigger: 'fullscreen' as const, fullscreen: true }];
|
|
const automation = {
|
|
triggers: triggers,
|
|
actions: actions,
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('should not execute actions', () => {
|
|
it('should do nothing without hass', () => {
|
|
const api = createCardAPI();
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should do nothing without being initialized', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(false);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should do nothing when an issue is present', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getIssueManager().getStateManager().hasFullCardIssue,
|
|
).mockReturnValue(true);
|
|
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should execute actions when triggered', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
|
|
// It does not re-trigger while its source stays in the same state.
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
|
|
stateManager.setState({ fullscreen: false });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should subscribe automations registered before initialization', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
const isInitialized = vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
);
|
|
isInitialized.mockReturnValue(false);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
// Registered before initialization: the triggers are not yet subscribed, so
|
|
// a matching change does nothing.
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).not.toHaveBeenCalled();
|
|
|
|
// Initialization completes and subscribes the dormant automations.
|
|
isInitialized.mockReturnValue(true);
|
|
automationsManager.subscribe();
|
|
|
|
stateManager.setState({ fullscreen: false });
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should run actions when the ongoing conditions hold', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: triggers,
|
|
conditions: [{ condition: 'expand' as const, expand: true }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// The ongoing condition holds when triggered, so `actions` run.
|
|
stateManager.setState({ expand: true });
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledWith({
|
|
actions: actions,
|
|
triggerData: { platform: 'acc', type: 'fullscreen' },
|
|
});
|
|
});
|
|
|
|
it('should do nothing when the ongoing conditions do not hold', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: triggers,
|
|
conditions: [{ condition: 'expand' as const, expand: true }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// The automation is triggered but the ongoing condition does not hold, so
|
|
// nothing runs.
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should do nothing when the actions are empty', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: triggers,
|
|
actions: [],
|
|
},
|
|
]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should prevent automation loops', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'camera' as const }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// Create a setup where the automation's action re-triggers itself: the camera
|
|
// trigger responds to every camera change, and the action changes the camera,
|
|
// looping until the nested-execution guard trips.
|
|
let camera = 'one';
|
|
|
|
vi.mocked(api.getActionsManager().executeActions).mockImplementation(
|
|
async (
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_request: ActionsExecutionRequest,
|
|
): Promise<void> => {
|
|
camera = camera === 'one' ? 'two' : 'one';
|
|
stateManager.setState({ camera: camera });
|
|
},
|
|
);
|
|
|
|
stateManager.setState({ camera: camera });
|
|
|
|
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith({
|
|
heading: {
|
|
text: 'Too many nested automation calls, please check your configuration for loops',
|
|
icon: 'mdi:alert',
|
|
severity: 'high',
|
|
},
|
|
});
|
|
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(10);
|
|
});
|
|
|
|
it('should reset the nested-execution counter after an overflow', async () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'camera' as const }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// As in the loop test: the action changes the camera, re-triggering itself
|
|
// until the nested-execution guard trips.
|
|
let camera = 'one';
|
|
vi.mocked(api.getActionsManager().executeActions).mockImplementation(
|
|
async (): Promise<void> => {
|
|
camera = camera === 'one' ? 'two' : 'one';
|
|
stateManager.setState({ camera: camera });
|
|
},
|
|
);
|
|
|
|
stateManager.setState({ camera: camera });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(10);
|
|
|
|
// The counter is decremented on the microtasks that resume after each
|
|
// awaited execution, so let them drain before the next batch.
|
|
await flushPromises();
|
|
|
|
vi.mocked(api.getActionsManager().executeActions).mockClear();
|
|
|
|
// A second, independent change overflows afresh and reaches the full limit
|
|
// again -- only possible if the counter returned to zero. A leaked counter
|
|
// (overflow returning without decrementing) would cut this batch short.
|
|
stateManager.setState({ camera: 'three' });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(10);
|
|
});
|
|
|
|
it('should execute actions on a matching HA bus event trigger', () => {
|
|
const api = createCardAPI();
|
|
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
|
vi.mocked(eventWatcher.subscribe).mockResolvedValue();
|
|
vi.mocked(eventWatcher.unsubscribe).mockResolvedValue();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
|
vi.mocked(api.getHASSManager().getEventWatcher).mockReturnValue(eventWatcher);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'event' as const, event_type: 'zha_event' }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
expect(eventWatcher.subscribe).toHaveBeenCalledTimes(1);
|
|
|
|
// Simulate an event arrival.
|
|
const event = createHASSEvent('zha_event', { command: 'press' });
|
|
vi.mocked(eventWatcher.subscribe).mock.calls[0][0].callback(event);
|
|
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
vi.mocked(api.getActionsManager().executeActions).mock.calls[0][0].triggerData,
|
|
).toEqual({ platform: 'event', event });
|
|
});
|
|
|
|
it('should delete automations', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(
|
|
api.getInitializationManager().areMandatoryAspectsInitialized,
|
|
).mockReturnValue(true);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'expand' as const, expand: true }],
|
|
actions: actions,
|
|
},
|
|
{
|
|
triggers: [{ trigger: 'fullscreen' as const, fullscreen: true }],
|
|
actions: actions,
|
|
tag: 'fullscreen',
|
|
},
|
|
]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
|
|
// Delete the fullscreen automation.
|
|
automationsManager.deleteAutomations('fullscreen');
|
|
|
|
stateManager.setState({ fullscreen: false });
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(1);
|
|
|
|
stateManager.setState({ expand: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(2);
|
|
|
|
// Delete all automations.
|
|
automationsManager.deleteAutomations();
|
|
|
|
stateManager.setState({ expand: false });
|
|
stateManager.setState({ expand: true });
|
|
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|