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(); }); }); });