"""The tests for the Template event platform.""" from enum import StrEnum from itertools import chain from typing import Any import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import event, template from homeassistant.components.template.const import CONF_ATTRIBUTES, CONF_PICTURE from homeassistant.components.template.coordinator import TriggerUpdateCoordinator from homeassistant.components.template.event import ( CONF_EVENT_TYPE, CONF_EVENT_TYPES, EventEntityCapabilityAttribute, EventEntityStateAttribute, TriggerEventEntity, ) from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_ICON, CONF_ICON, CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.template import Template from .conftest import ( RESTORE_STATE_SAVED_ATTRIBUTES, RESTORE_STATE_UPDATED_ATTRIBUTES, ConfigurationStyle, TemplatePlatformSetup, assert_attributes_template, assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, setup_mock_template_entity_restore_state, setup_restore_template_entity, ) from tests.common import MockConfigEntry, MockEntityPlatform, mock_restore_cache from tests.conftest import WebSocketGenerator TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" TEST_EVENT = TemplatePlatformSetup( event.DOMAIN, "template_event", make_test_trigger(TEST_STATE_ENTITY_ID, TEST_ATTRIBUTE_ENTITY_ID), ) TEST_EVENT_TYPES_TEMPLATE = "{{ ['single', 'double', 'hold'] }}" TEST_EVENT_TYPE_TEMPLATE = "{{ 'single' }}" TEST_EVENT_CONFIG = { "event_types": TEST_EVENT_TYPES_TEMPLATE, "event_type": TEST_EVENT_TYPE_TEMPLATE, } TEST_FROZEN_INPUT = "2024-07-09 00:00:00+00:00" TEST_FROZEN_STATE = "2024-07-09T00:00:00.000+00:00" @pytest.fixture async def setup_base_event( hass: HomeAssistant, count: int, style: ConfigurationStyle, config: dict[str, Any], ) -> None: """Do setup of event integration.""" await setup_entity(hass, TEST_EVENT, style, count, config) @pytest.fixture async def setup_event( hass: HomeAssistant, count: int, style: ConfigurationStyle, event_type_template: str, event_types_template: str, extra_config: dict[str, Any] | None, ) -> None: """Do setup of event integration.""" await setup_entity( hass, TEST_EVENT, style, count, { "event_type": event_type_template, "event_types": event_types_template, }, extra_config=extra_config, ) @pytest.fixture async def setup_single_attribute_state_event( hass: HomeAssistant, count: int, style: ConfigurationStyle, event_type_template: str, event_types_template: str, attribute: str, attribute_template: str, ) -> None: """Do setup of event integration testing a single attribute.""" await setup_entity( hass, TEST_EVENT, style, count, { "event_type": event_type_template, "event_types": event_types_template, }, extra_config={attribute: attribute_template} if attribute and attribute_template else {}, ) @pytest.mark.freeze_time(TEST_FROZEN_INPUT) async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, ) -> None: """Test the config flow.""" await async_trigger( hass, TEST_STATE_ENTITY_ID, "single", {}, ) template_config_entry = MockConfigEntry( data={}, domain=template.DOMAIN, options={ "name": TEST_EVENT.object_id, **TEST_EVENT_CONFIG, "template_type": event.DOMAIN, }, title="My template", ) template_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get(TEST_EVENT.entity_id) assert state is not None assert state == snapshot @pytest.mark.freeze_time(TEST_FROZEN_INPUT) async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for device for Template.""" device_config_entry = MockConfigEntry() device_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=device_config_entry.entry_id, identifiers={("test", "identifier_test")}, connections={("mac", "30:31:32:33:34:35")}, ) await hass.async_block_till_done() assert device_entry is not None assert device_entry.id is not None template_config_entry = MockConfigEntry( data={}, domain=template.DOMAIN, options={ "name": "My template", "event_type": TEST_EVENT_TYPE_TEMPLATE, "event_types": TEST_EVENT_TYPES_TEMPLATE, "template_type": "event", "device_id": device_entry.id, }, title="My template", ) template_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() template_entity = entity_registry.async_get("event.mock_title_my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id @pytest.mark.parametrize( ("count", "event_types_template", "extra_config"), [(1, TEST_EVENT_TYPES_TEMPLATE, None)], ) @pytest.mark.parametrize( ("style", "expected_state"), [ (ConfigurationStyle.MODERN, STATE_UNKNOWN), (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), ], ) @pytest.mark.parametrize("event_type_template", ["{{states.test['big.fat...']}}"]) @pytest.mark.usefixtures("setup_event") async def test_event_type_syntax_error( hass: HomeAssistant, expected_state: str, ) -> None: """Test template event_type with render error.""" state = hass.states.get(TEST_EVENT.entity_id) assert state.state == expected_state @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template", "extra_config"), [(1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE, None)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("event", "expected"), [ ("single", "single"), ("double", "double"), ("hold", "hold"), ], ) @pytest.mark.usefixtures("setup_event") async def test_event_type_template( hass: HomeAssistant, event: str, expected: str, ) -> None: """Test template event_type.""" await async_trigger(hass, TEST_STATE_ENTITY_ID, event) state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes["event_type"] == expected @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template", "extra_config"), [(1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE, None)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_event") @pytest.mark.freeze_time(TEST_FROZEN_INPUT) async def test_event_type_template_updates( hass: HomeAssistant, ) -> None: """Test template event_type updates.""" await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") state = hass.states.get(TEST_EVENT.entity_id) single_triggered = state.state assert state.attributes["event_type"] == "single" await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") state = hass.states.get(TEST_EVENT.entity_id) # Each event advances the timestamp; events within the same millisecond are # bumped so every one stays a distinct state change. double_triggered = state.state assert double_triggered > single_triggered assert state.attributes["event_type"] == "double" await async_trigger(hass, TEST_STATE_ENTITY_ID, "hold") state = hass.states.get(TEST_EVENT.entity_id) assert state.state > double_triggered assert state.attributes["event_type"] == "hold" @pytest.mark.parametrize( ("count", "event_types_template", "extra_config"), [(1, TEST_EVENT_TYPES_TEMPLATE, None)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "event_type_template", [ "{{ None }}", "{{ 7 }}", "{{ 'unknown' }}", "{{ 'tripple_double' }}", ], ) @pytest.mark.usefixtures("setup_event") async def test_event_type_invalid( hass: HomeAssistant, ) -> None: """Test template event_type.""" state = hass.states.get(TEST_EVENT.entity_id) assert state.state == STATE_UNKNOWN assert state.attributes["event_type"] is None @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template"), [(1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("attribute", "attribute_template", "key", "expected"), [ ( "picture", "{% if is_state('sensor.test_state', 'double') %}something{% endif %}", ATTR_ENTITY_PICTURE, "something", ), ( "icon", "{% if is_state('sensor.test_state', 'double') %}mdi:something{% endif %}", ATTR_ICON, "mdi:something", ), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_event") async def test_entity_picture_and_icon_templates( hass: HomeAssistant, key: str, expected: str ) -> None: """Test picture and icon template.""" state = await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes.get(key) in ("", None) state = await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes[key] == expected @pytest.mark.parametrize( ("count", "event_type_template", "extra_config"), [(1, "{{ None }}", None)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("event_types_template", "expected"), [ ( "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", ["Strobe color", "Police", "Christmas", "RGB", "Random Loop"], ), ( "{{ ['Police', 'RGB', 'Random Loop'] }}", ["Police", "RGB", "Random Loop"], ), ("{{ [] }}", []), ("{{ '[]' }}", []), ("{{ 124 }}", []), ("{{ '124' }}", []), ("{{ none }}", []), ("", []), ], ) @pytest.mark.usefixtures("setup_event") async def test_event_types_template(hass: HomeAssistant, expected: str) -> None: """Test template event_types.""" await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes["event_types"] == expected @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template", "extra_config"), [ ( 1, "{{ states('sensor.test_state') }}", "{{ state_attr('sensor.test_state', 'options') or ['unknown'] }}", None, ) ], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_event") @pytest.mark.freeze_time(TEST_FROZEN_INPUT) async def test_event_types_template_updates(hass: HomeAssistant) -> None: """Test template event_type update with entity.""" await async_trigger( hass, TEST_STATE_ENTITY_ID, "single", {"options": ["single", "double", "hold"]} ) await hass.async_block_till_done() state = hass.states.get(TEST_EVENT.entity_id) first_triggered = state.state assert state.attributes["event_type"] == "single" assert state.attributes["event_types"] == ["single", "double", "hold"] await async_trigger( hass, TEST_STATE_ENTITY_ID, "double", {"options": ["double", "hold"]} ) await hass.async_block_till_done() state = hass.states.get(TEST_EVENT.entity_id) # A second event fired, so its timestamp strictly increases (events within # the same millisecond are bumped so each stays a distinct state change). assert state.state > first_triggered assert state.attributes["event_type"] == "double" assert state.attributes["event_types"] == ["double", "hold"] @pytest.mark.parametrize( ( "count", "event_type_template", "event_types_template", "attribute", "attribute_template", ), [ ( 1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE, "availability", "{{ states('sensor.test_state') in ['single', 'double', 'hold'] }}", ) ], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_single_attribute_state_event") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") state = hass.states.get(TEST_EVENT.entity_id) assert state.state != STATE_UNAVAILABLE assert state.attributes["event_type"] == "single" await async_trigger(hass, TEST_STATE_ENTITY_ID, "triple") state = hass.states.get(TEST_EVENT.entity_id) assert state.state == STATE_UNAVAILABLE assert "event_type" not in state.attributes @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) @pytest.mark.parametrize( "config", [ { "event_type": "{{ states('sensor.test_state') }}", "event_types": TEST_EVENT_TYPES_TEMPLATE, "attributes": { "plus_one": "{{ states('sensor.test_attribute') | int(0) + 1 }}", "plus_two": "{{ states('sensor.test_attribute') | int(0) + 2 }}", }, }, ], ) async def test_restore_state( hass: HomeAssistant, style: ConfigurationStyle, config: dict, ) -> None: """Test restoring template event entities.""" # Ensure the initial state is None so that restore data is honored await async_trigger(hass, TEST_STATE_ENTITY_ID, None) restored_attributes = { "plus_one": 55, } setup_mock_template_entity_restore_state( hass, TEST_EVENT, "2021-01-01T23:59:59.123+00:00", saved_extra_data={ "last_event_type": "hold", "last_event_attributes": restored_attributes, }, saved_attributes=restored_attributes, ) await setup_restore_template_entity( hass, TEST_EVENT, style, config, "is_state('sensor.test_attribute', '2')" ) test_state = "2021-01-01T23:59:59.123+00:00" state = assert_state_and_attributes( hass, TEST_EVENT, test_state, {**restored_attributes, **RESTORE_STATE_SAVED_ATTRIBUTES}, ) assert "plus_two" not in state.attributes await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, 2) state = assert_state_and_attributes( hass, TEST_EVENT, expected_attributes={ "plus_one": 3, "plus_two": 4, "event_type": "double", "event_types": ["single", "double", "hold"], **RESTORE_STATE_UPDATED_ATTRIBUTES, }, ) assert state.state != test_state def _make_trigger_event_entity(hass: HomeAssistant) -> TriggerEventEntity: """Create a trigger event entity that renders fresh, static attributes.""" config = { CONF_NAME: Template("fresh_name", hass), CONF_ICON: Template("mdi:fresh", hass), CONF_PICTURE: Template("/local/fresh.png", hass), CONF_EVENT_TYPE: Template("fresh", hass), CONF_EVENT_TYPES: Template("{{ ['fresh'] }}", hass), CONF_ATTRIBUTES: {"attr": Template("fresh_attr", hass)}, } coordinator = TriggerUpdateCoordinator(hass, {}) entity = TriggerEventEntity(hass, coordinator, config) entity.entity_id = "event.test" entity.platform = MockEntityPlatform(hass) return entity def _mock_stale_restore_cache(hass: HomeAssistant) -> None: """Prime the restore cache with stale attributes for the test entity.""" mock_restore_cache( hass, ( State( "event.test", "2021-01-01T00:00:00+00:00", { ATTR_FRIENDLY_NAME: "stale_name", ATTR_ICON: "mdi:stale", ATTR_ENTITY_PICTURE: "/local/stale.png", "attr": "stale_attr", }, ), ), ) async def test_trigger_restore_does_not_clobber_rendered_attributes( hass: HomeAssistant, ) -> None: """A trigger that already fired must win over restored state. Regression test for the race where the trigger fires (populating the coordinator) before the entity is added to hass. ``_process_data`` renders fresh attributes which restore would otherwise overwrite with stale values. """ entity = _make_trigger_event_entity(hass) # Simulate the trigger having already fired before the entity is added. entity.coordinator._execute_update({}) assert entity.coordinator.data is not None _mock_stale_restore_cache(hass) await entity.async_added_to_hass() await hass.async_block_till_done() # The freshly rendered values must win over the stale restored ones. assert entity.name == "fresh_name" assert entity.icon == "mdi:fresh" assert entity.entity_picture == "/local/fresh.png" assert entity.extra_state_attributes == {"attr": "fresh_attr"} async def test_trigger_restores_when_not_yet_triggered( hass: HomeAssistant, ) -> None: """When the trigger has not fired, restored attributes are applied.""" entity = _make_trigger_event_entity(hass) assert entity.coordinator.data is None _mock_stale_restore_cache(hass) await entity.async_added_to_hass() await hass.async_block_till_done() # No fresh data yet, so the restored values are surfaced. assert entity.name == "stale_name" assert entity.icon == "mdi:stale" assert entity.entity_picture == "/local/stale.png" assert entity.extra_state_attributes == {"attr": "stale_attr"} @pytest.mark.parametrize( ( "count", "event_type_template", "event_types_template", "attribute", "attribute_template", ), [ ( 1, TEST_EVENT_TYPE_TEMPLATE, TEST_EVENT_TYPES_TEMPLATE, "availability", "{{ x - 12 }}", ) ], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_single_attribute_state_event") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, caplog_setup_text, ) -> None: """Test that an invalid availability keeps the device available.""" await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") assert hass.states.get(TEST_EVENT.entity_id).state != STATE_UNAVAILABLE error = "UndefinedError: 'x' is undefined" assert error in caplog_setup_text or error in caplog.text @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) async def test_unique_id(hass: HomeAssistant, style: ConfigurationStyle) -> None: """Test unique_id option only creates one event per id.""" await setup_and_test_unique_id(hass, TEST_EVENT, style, TEST_EVENT_CONFIG) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) async def test_nested_unique_id( hass: HomeAssistant, style: ConfigurationStyle, entity_registry: er.EntityRegistry, ) -> None: """Test a template unique_id propagates to event unique_ids.""" await setup_and_test_nested_unique_id( hass, TEST_EVENT, style, entity_registry, TEST_EVENT_CONFIG ) @pytest.mark.freeze_time(TEST_FROZEN_INPUT) async def test_flow_preview( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test the config flow preview.""" state = await async_get_flow_preview_state( hass, hass_ws_client, event.DOMAIN, {"name": "My template", **TEST_EVENT_CONFIG}, ) assert state["state"] == TEST_FROZEN_STATE assert state["attributes"]["event_type"] == "single" assert state["attributes"]["event_types"] == ["single", "double", "hold"] @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) async def test_attributes_template( hass: HomeAssistant, style: ConfigurationStyle, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes as a single template.""" await assert_attributes_template( hass, TEST_EVENT, style, TEST_EVENT_CONFIG, caplog, ) @pytest.mark.parametrize( "attribute", list(chain(EventEntityCapabilityAttribute, EventEntityStateAttribute)), ) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) async def test_attributes_template_with_blocked_attributes( hass: HomeAssistant, style: ConfigurationStyle, attribute: StrEnum, caplog: pytest.LogCaptureFixture, ) -> None: """Test blocked attributes for a single attributes template.""" await setup_entity( hass, TEST_EVENT, style, 1, { **TEST_EVENT_CONFIG, "attributes": f"{{{{ dict({attribute}='does not matter') }}}}", }, ) await async_trigger(hass, "sensor.test_extra_attributes", "anything") error = f"Unsupported attribute(s) found for {TEST_EVENT.entity_id}: {attribute}" assert error in caplog.text