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 type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types.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 => { 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 => { 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(); 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); }); describe('should report the triggers it subscribes to', () => { it('with no automations', () => { expect(new AutomationsManager(createCardAPI()).getTriggers()).toEqual([]); }); it('with automations', () => { const keyTrigger: Trigger = { trigger: 'key', key: 'ArrowLeft' }; const expandTrigger: Trigger = { trigger: 'expand', expand: true }; const automationsManager = new AutomationsManager(createCardAPI()); automationsManager.addAutomations([ { triggers: [keyTrigger, expandTrigger], actions }, { triggers: triggers, actions }, ]); expect(automationsManager.getTriggers()).toEqual([ keyTrigger, expandTrigger, ...triggers, ]); }); }); });