From 65f9dcd7bf313ee0ee802c6f7b9b06d1f38ce181 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 00:32:37 +0200 Subject: [PATCH 1/2] Improve condition test helper docstrings (#169871) --- tests/components/common.py | 206 +++++++++++++++++++++++++++++++++---- 1 file changed, 188 insertions(+), 18 deletions(-) diff --git a/tests/components/common.py b/tests/components/common.py index aac1943ff094e..ea80fb3325d50 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -339,13 +339,34 @@ def parametrize_condition_states_any( required_filter_attributes: dict | None = None, excluded_entities_from_other_domain: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize states and expected condition evaluations. + """Parametrize states and expected evaluations for a condition under behavior=any. - The target_states and other_states iterables are either iterables of - states or iterables of (state, attributes) tuples. + Returns a list of `(condition, condition_options, states)` tuples, where + `states` is a list of ConditionStateDescription dicts. Each dict carries + the state to apply to the entity under test, the state to apply to + entities outside the target, the expected condition evaluation after the + entity under test alone has been set, and the expected evaluation after + every other targeted entity has been set to the same state. - Returns a list of tuples with (condition, condition options, list of states), - where states is a list of ConditionStateDescription dicts. + Args: + condition: Condition key, e.g. `"climate.target_humidity"`. + condition_options: Options dict passed to the condition (typically + includes the `threshold` block); merged into each generated tuple. + target_states: States the condition is expected to evaluate True + for. Entries are either bare state values or `(state, attributes)` + tuples. + other_states: States the condition is expected to evaluate False for. + Same accepted shapes as `target_states`. With behavior=any, an + entity in such a state does not satisfy the condition. + required_filter_attributes: Attributes that must be present on the + entity for the condition's domain filter to accept it. The + helper merges these into every generated state so the entity + satisfies the filter; entities outside the target receive the + same state value but *without* these attributes. + excluded_entities_from_other_domain: When True, the helper assumes + entities outside the target sit in another domain entirely; + their state value is preserved (rather than being replaced with + None) so the test verifies the condition ignores them by domain. """ return _parametrize_condition_states( @@ -368,13 +389,36 @@ def parametrize_condition_states_all( required_filter_attributes: dict | None = None, excluded_entities_from_other_domain: bool = False, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize states and expected condition evaluations. + """Parametrize states and expected evaluations for a condition under behavior=all. - The target_states and other_states iterables are either iterables of - states or iterables of (state, attributes) tuples. + Returns a list of `(condition, condition_options, states)` tuples, where + `states` is a list of ConditionStateDescription dicts. Each dict carries + the state to apply to the entity under test, the state to apply to + entities outside the target, the expected condition evaluation after the + entity under test alone has been set, and the expected evaluation after + every other targeted entity has been set to the same state. - Returns a list of tuples with (condition, condition options, list of states), - where states is a list of ConditionStateDescription dicts. + Args: + condition: Condition key, e.g. `"climate.target_humidity"`. + condition_options: Options dict passed to the condition (typically + includes the `threshold` block); merged into each generated tuple. + target_states: States the condition is expected to evaluate True for + (i.e. entities in any such state contribute a "match" to the + all-check). Entries are either bare state values or + `(state, attributes)` tuples. + other_states: States the condition is expected to evaluate False + for. Same accepted shapes as `target_states`. Under behavior=all, + an entity in such a state blocks the all-check (counts toward + the check but is not a match). + required_filter_attributes: Attributes that must be present on the + entity for the condition's domain filter to accept it. The + helper merges these into every generated state so the entity + satisfies the filter; entities outside the target receive the + same state value but *without* these attributes. + excluded_entities_from_other_domain: When True, the helper assumes + entities outside the target sit in another domain entirely; + their state value is preserved (rather than being replaced with + None) so the test verifies the condition ignores them by domain. """ return _parametrize_condition_states( @@ -1686,9 +1730,41 @@ def parametrize_numerical_condition_above_below_any( threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize above/below threshold test cases for numerical conditions. + """Parametrize above/below/between threshold cases for state-value numerical conditions under behavior=any. + + Generates state sequences for a condition that reads its tracked value + directly from `state.state` (e.g. a sensor with a temperature device + class). The condition is exercised across three threshold types in turn + — "above", "below", "between" — and for each, the helper invokes + `parametrize_condition_states_any` with target/other states populated + from a fixed set of numeric values straddling the thresholds. - Returns a list of tuples with (condition, condition_options, states). + Threshold values are fixed at 20 / 80 (interpreted in the condition's + threshold unit). The `device_class` filter is applied via + `required_filter_attributes={ATTR_DEVICE_CLASS: device_class}` so + entities outside that device class are ignored by the condition. + + Returns a list of `(condition, condition_options, states)` tuples, + suitable for unpacking into a `pytest.mark.parametrize` over + `("condition", "condition_options", "states")`. + + Args: + condition: Condition key, e.g. `"temperature.is"`. + device_class: Device class the condition filters on. Forwarded to + `parametrize_condition_states_any` as + `required_filter_attributes={ATTR_DEVICE_CLASS: device_class}`. + condition_options: Extra keys merged into the generated `options` + dict for each threshold-type variant (e.g. user-supplied + condition-specific keys; the threshold itself is set by the + helper). + threshold_unit: When set, the threshold values in + `condition_options` get this unit attached + (`unit_of_measurement`). Defaults to UNDEFINED, meaning no unit + is added. + unit_attributes: Attributes (typically + `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated + state, so the entity carries a unit alongside its tracked + value. """ from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415 @@ -1776,9 +1852,35 @@ def parametrize_numerical_condition_above_below_all( threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize above/below threshold test cases for numerical conditions with 'all' behavior. + """Parametrize above/below/between threshold cases for state-value numerical conditions under behavior=all. + + See `parametrize_numerical_condition_above_below_any` for the structure + of the generated test cases; the only difference is that this helper + routes through `parametrize_condition_states_all`, so the condition is + expected to evaluate True only when *every* targeted entity matches the + threshold (vacuous-True when every entity is filtered out). - Returns a list of tuples with (condition, condition_options, states). + Returns a list of `(condition, condition_options, states)` tuples, + suitable for unpacking into a `pytest.mark.parametrize` over + `("condition", "condition_options", "states")`. + + Args: + condition: Condition key, e.g. `"temperature.is"`. + device_class: Device class the condition filters on. Forwarded to + `parametrize_condition_states_all` as + `required_filter_attributes={ATTR_DEVICE_CLASS: device_class}`. + condition_options: Extra keys merged into the generated `options` + dict for each threshold-type variant (e.g. user-supplied + condition-specific keys; the threshold itself is set by the + helper). + threshold_unit: When set, the threshold values in + `condition_options` get this unit attached + (`unit_of_measurement`). Defaults to UNDEFINED, meaning no unit + is added. + unit_attributes: Attributes (typically + `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated + state, so the entity carries a unit alongside its tracked + value. """ from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415 @@ -1868,9 +1970,44 @@ def parametrize_numerical_attribute_condition_above_below_any( threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize above/below threshold test cases for attribute-based numerical conditions. + """Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=any. + + Generates state sequences for a condition that reads its tracked value + from a state attribute (e.g. `climate.target_humidity`). The condition + is exercised across three threshold types in turn — "above", "below", + "between" — and for each, the helper invokes + `parametrize_condition_states_any` with target/other states populated + from a fixed set of numeric attribute values straddling the + thresholds. Threshold values are fixed at 20 / 80 (interpreted in the + condition's threshold unit). - Returns a list of tuples with (condition, condition_options, states). + Returns a list of `(condition, condition_options, states)` tuples, + suitable for unpacking into a `pytest.mark.parametrize` over + `("condition", "condition_options", "states")`. + + Args: + condition: Condition key, e.g. `"climate.target_humidity"`. + state: The `state.state` value to use for entities meant to match + the condition (the attribute lives on top of this state). + attribute: Name of the attribute the condition reads. The helper + generates target/other/excluded states by varying this + attribute. + condition_options: Extra keys merged into the generated `options` + dict for each threshold-type variant (the threshold itself is + set by the helper). + required_filter_attributes: Attributes that must be present on the + entity for the condition's domain filter to accept it. The + helper merges these into every generated state so the entity + satisfies the filter; entities outside the target receive the + same state value but *without* these attributes. + threshold_unit: When set, the threshold values in + `condition_options` get this unit attached + (`unit_of_measurement`). Defaults to UNDEFINED, meaning no + unit is added. + unit_attributes: Attributes (typically + `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated + state, so the entity carries a unit alongside its tracked + attribute. """ condition_options = condition_options or {} unit_attributes = unit_attributes or {} @@ -1957,9 +2094,42 @@ def parametrize_numerical_attribute_condition_above_below_all( threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize above/below threshold test cases for attribute-based numerical conditions with 'all' behavior. + """Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=all. + + See `parametrize_numerical_attribute_condition_above_below_any` for the + structure of the generated test cases; the only difference is that this + helper routes through `parametrize_condition_states_all`, so the + condition is expected to evaluate True only when *every* targeted + entity matches the threshold (vacuous-True when every entity is + filtered out). - Returns a list of tuples with (condition, condition_options, states). + Returns a list of `(condition, condition_options, states)` tuples, + suitable for unpacking into a `pytest.mark.parametrize` over + `("condition", "condition_options", "states")`. + + Args: + condition: Condition key, e.g. `"climate.target_humidity"`. + state: The `state.state` value to use for entities meant to match + the condition (the attribute lives on top of this state). + attribute: Name of the attribute the condition reads. The helper + generates target/other/excluded states by varying this + attribute. + condition_options: Extra keys merged into the generated `options` + dict for each threshold-type variant (the threshold itself is + set by the helper). + required_filter_attributes: Attributes that must be present on the + entity for the condition's domain filter to accept it. The + helper merges these into every generated state so the entity + satisfies the filter; entities outside the target receive the + same state value but *without* these attributes. + threshold_unit: When set, the threshold values in + `condition_options` get this unit attached + (`unit_of_measurement`). Defaults to UNDEFINED, meaning no + unit is added. + unit_attributes: Attributes (typically + `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated + state, so the entity carries a unit alongside its tracked + attribute. """ condition_options = condition_options or {} unit_attributes = unit_attributes or {} From 7e8f5365ced9e60ea205c13b6b5f88b9acd9e452 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 May 2026 00:50:22 +0200 Subject: [PATCH 2/2] Add method _should_include to EntityTriggerBase (#169837) --- homeassistant/components/climate/trigger.py | 41 ++- .../components/media_player/trigger.py | 22 +- homeassistant/helpers/trigger.py | 72 ++++-- tests/components/climate/test_trigger.py | 20 +- tests/components/common.py | 152 ++++++++++- tests/components/media_player/test_trigger.py | 239 ++++++------------ tests/helpers/test_trigger.py | 47 +++- 7 files changed, 354 insertions(+), 239 deletions(-) diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index 9f9f02d70710f..b27711cc3bb07 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -8,14 +8,15 @@ from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, + EntityNumericalStateChangedTriggerBase, EntityNumericalStateChangedTriggerWithUnitBase, + EntityNumericalStateCrossedThresholdTriggerBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, + EntityNumericalStateTriggerBase, EntityNumericalStateTriggerWithUnitBase, EntityTargetStateTriggerBase, Trigger, TriggerConfig, - make_entity_numerical_state_changed_trigger, - make_entity_numerical_state_crossed_threshold_trigger, make_entity_target_state_trigger, make_entity_transition_trigger, ) @@ -75,6 +76,32 @@ class ClimateTargetTemperatureCrossedThresholdTrigger( """Trigger for climate target temperature value crossing a threshold.""" +class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for climate target humidity triggers.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _valid_unit = "%" + + def _should_include(self, state: State) -> bool: + """Skip climate entities that do not expose a target humidity.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_HUMIDITY) is not None + ) + + +class ClimateTargetHumidityChangedTrigger( + _ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase +): + """Trigger for climate target humidity value changes.""" + + +class ClimateTargetHumidityCrossedThresholdTrigger( + _ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase +): + """Trigger for climate target humidity value crossing a threshold.""" + + TRIGGERS: dict[str, type[Trigger]] = { "hvac_mode_changed": HVACModeChangedTrigger, "started_cooling": make_entity_target_state_trigger( @@ -83,14 +110,8 @@ class ClimateTargetTemperatureCrossedThresholdTrigger( "started_drying": make_entity_target_state_trigger( {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING ), - "target_humidity_changed": make_entity_numerical_state_changed_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit="%", - ), - "target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit="%", - ), + "target_humidity_changed": ClimateTargetHumidityChangedTrigger, + "target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger, "target_temperature_changed": ClimateTargetTemperatureChangedTrigger, "target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF), diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 5b0d4527a77b1..c3ef068b41adc 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -33,27 +33,7 @@ def _should_include(self, state: State) -> bool: excluded from the check - otherwise an "all" check would never pass when there are media players without volume support. """ - return state.state not in self._excluded_states and self._has_volume_attributes( - state - ) - - def check_all_match(self, entity_ids: set[str]) -> bool: - """Check if all mutable entity states match.""" - return all( - self.is_valid_state(state) - for entity_id in entity_ids - if (state := self._hass.states.get(entity_id)) is not None - and self._should_include(state) - ) - - def count_matches(self, entity_ids: set[str]) -> int: - """Count matching mutable entities.""" - return sum( - self.is_valid_state(state) - for entity_id in entity_ids - if (state := self._hass.states.get(entity_id)) is not None - and self._should_include(state) - ) + return super()._should_include(state) and self._has_volume_attributes(state) def is_muted(self, state: State) -> bool: """Check if the media player is muted.""" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 47693ef79a4f6..55ec0445bfa5b 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -397,23 +397,36 @@ def is_valid_transition(self, from_state: State, to_state: State) -> bool: def is_valid_state(self, state: State) -> bool: """Check if the new state matches the expected state(s).""" - def check_all_match(self, entity_ids: set[str]) -> bool: - """Check if all entity states match.""" - return all( - self.is_valid_state(state) - for entity_id in entity_ids - if (state := self._hass.states.get(entity_id)) is not None - and state.state not in self._excluded_states - ) + def _should_include(self, state: State) -> bool: + """Check if an entity should participate in all/count checks. - def count_matches(self, entity_ids: set[str]) -> int: - """Count the number of entity states that match.""" - return sum( - self.is_valid_state(state) - for entity_id in entity_ids - if (state := self._hass.states.get(entity_id)) is not None - and state.state not in self._excluded_states - ) + The default implementation excludes only entities whose state.state + is in `_excluded_states` (unavailable / unknown). Subclasses can + override to also exclude entities that lack the optional capability + the trigger relies on (e.g. a missing volume_level attribute). + """ + return state.state not in self._excluded_states + + def count_matches(self, entity_ids: set[str]) -> tuple[int, int]: + """Return (matches, included) for the entity set. + + `matches` is the number of entities that pass `_should_include` AND + `is_valid_state`. `included` is the number that pass + `_should_include` (i.e. are visible to the all/count check at all). + Callers can use the pair to distinguish vacuous truth + (`included == 0`) from a genuine all-match + (`matches == included > 0`). + """ + matches = 0 + included = 0 + for entity_id in entity_ids: + state = self._hass.states.get(entity_id) + if state is None or not self._should_include(state): + continue + included += 1 + if self.is_valid_state(state): + matches += 1 + return matches, included @override async def async_attach_runner( @@ -445,14 +458,20 @@ def state_still_valid( For behavior first/last, checks the combined state. """ if behavior == BEHAVIOR_LAST: - return self.check_all_match( + matches, included = self.count_matches( target_state_change_data.targeted_entity_ids ) + # Require at least one included entity to avoid keeping + # the timer alive when every targeted entity has been + # filtered out since it started — a vacuous all-match + # (`included == 0`) would otherwise let the action fire + # after `for:` even though no entity still matches. + return included > 0 and matches == included if behavior == BEHAVIOR_FIRST: - return ( - self.count_matches(target_state_change_data.targeted_entity_ids) - >= 1 + matches, _included = self.count_matches( + target_state_change_data.targeted_entity_ids ) + return matches >= 1 # Behavior any: check the individual entity's state if not to_state: return False @@ -470,18 +489,19 @@ def state_still_valid( return if behavior == BEHAVIOR_LAST: - if not self.check_all_match( + matches, included = self.count_matches( target_state_change_data.targeted_entity_ids - ): + ) + if matches != included: return elif behavior == BEHAVIOR_FIRST: # Note: It's enough to test for exactly 1 match here because if there # were previously 2 matches the transition would not be valid and we # would have returned already. - if ( - self.count_matches(target_state_change_data.targeted_entity_ids) - != 1 - ): + matches, _ = self.count_matches( + target_state_change_data.targeted_entity_ids + ) + if matches != 1: return @callback diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index fb961e8cd10dd..bba2e00b3cfde 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -218,7 +218,10 @@ async def test_climate_state_trigger_behavior_any( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY + "climate.target_humidity_changed", + HVACMode.AUTO, + ATTR_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_changed_trigger_states( "climate.target_temperature_changed", @@ -227,7 +230,10 @@ async def test_climate_state_trigger_behavior_any( threshold_unit=UnitOfTemperature.CELSIUS, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( - "climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY + "climate.target_humidity_crossed_threshold", + HVACMode.AUTO, + ATTR_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "climate.target_temperature_crossed_threshold", @@ -342,7 +348,10 @@ async def test_climate_state_trigger_behavior_first( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_crossed_threshold_trigger_states( - "climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY + "climate.target_humidity_crossed_threshold", + HVACMode.AUTO, + ATTR_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "climate.target_temperature_crossed_threshold", @@ -457,7 +466,10 @@ async def test_climate_state_trigger_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_crossed_threshold_trigger_states( - "climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY + "climate.target_humidity_crossed_threshold", + HVACMode.AUTO, + ATTR_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "climate.target_temperature_crossed_threshold", diff --git a/tests/components/common.py b/tests/components/common.py index ea80fb3325d50..5903941decaf2 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -222,6 +222,12 @@ class TriggerStateDescription(BasicTriggerStateDescription): """Test state and expected service call count for both included and excluded entities.""" excluded_state: StateDescription # State for entities not meant to be targeted + # State for the *other* targeted entities (the ones not under direct test). + # Usually equal to `included_state`; differs when the test exercises a + # scenario where targeted-but-not-under-test entities sit in a state that + # the trigger's `_should_include` method filters out of the all/count + # checks. + others_state: StateDescription class ConditionStateDescription(TypedDict): @@ -438,6 +444,7 @@ def parametrize_trigger_states( trigger_options: dict[str, Any] | None = None, target_states: list[str | None | tuple[str | None, dict]], other_states: list[str | None | tuple[str | None, dict]], + extra_excluded_states: list[str | None | tuple[str | None, dict]] | None = None, extra_invalid_states: list[str | None | tuple[str | None, dict]] | None = None, required_filter_attributes: dict | None = None, trigger_from_none: bool = True, @@ -449,7 +456,7 @@ def parametrize_trigger_states( `states` is a list of TriggerStateDescription dicts describing the state sequence to drive the trigger through. - The target_states, other_states, and extra_invalid_states + The target_states, other_states, excluded_states, and extra_invalid_states iterables are either iterables of states or iterables of (state, attributes) tuples. @@ -459,6 +466,17 @@ def parametrize_trigger_states( count toward the all/count check (i.e. an entity in such a state blocks behavior=last). + `extra_excluded_states` are *additional* states (on top of the always- + included missing/unavailable/unknown that the base `_should_include` + filters out) that the trigger's `_should_include` override is expected + to filter out of the all/count check. The helper iterates over the full + filtered set (`[None, STATE_UNAVAILABLE, STATE_UNKNOWN, + *extra_excluded_states]`) and generates an additional pattern that sets + the *other* targeted entities into each filtered state while the entity + under test transitions to a target state — the trigger should fire even + though the other entities never matched, because they are invisible to + the all/count check. + `extra_invalid_states` are *additional* states (on top of the always- included STATE_UNAVAILABLE and STATE_UNKNOWN) that should be treated as invalid by the trigger (i.e. `is_valid_transition` rejects transitions @@ -480,6 +498,17 @@ def parametrize_trigger_states( STATE_UNKNOWN, *(extra_invalid_states or []), ] + extra_excluded_states = list(extra_excluded_states or []) + # The excluded_states pattern iterates over every state the base + # _should_include impl filters out (a missing state object, unavailable, + # unknown), plus any caller-supplied additions filtered by a + # `_should_include` override. + excluded_states = [ + None, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + *extra_excluded_states, + ] required_filter_attributes = required_filter_attributes or {} trigger_options = trigger_options or {} @@ -520,11 +549,19 @@ def _excluded_state_desc( def state_with_attributes( state: str | None | tuple[str | None, dict], count: int, + *, + others_state: str | None | tuple[str | None, dict] | UndefinedType = UNDEFINED, ) -> TriggerStateDescription: """Return TriggerStateDescription dict.""" + included = _included_state_desc(state) return { - "included_state": _included_state_desc(state), + "included_state": included, "excluded_state": _excluded_state_desc(state), + "others_state": ( + included + if isinstance(others_state, UndefinedType) + else _included_state_desc(others_state) + ), "count": count, } @@ -696,6 +733,40 @@ def state_with_attributes( ), ) + # Pattern: the OTHER targeted entities sit in a state filtered by the + # trigger's `_should_include` (default impl filters + # missing/unavailable/unknown; overrides may add more, supplied by the + # caller via `extra_excluded_states`). They are invisible to the + # all/count checks, so even though they never enter `target_state` the + # trigger should still fire when the entity under test alone transitions + # other -> target. + # Sequence per (target, other, excluded): + # entity_id: other -> target (1) + # others: other -> excluded + # i.e. step 0 sets all entities to `other`; step 1 transitions the + # entity under test to `target` while the others go to `excluded` (via + # `others_state`). The all/count check filters the others out, so a + # single matching entity is enough to fire `behavior=last`. + tests.append( + ( + trigger, + trigger_options, + list( + itertools.chain.from_iterable( + ( + state_with_attributes(other_state, 0), + state_with_attributes( + target_state, 1, others_state=excluded_state + ), + ) + for target_state in target_states + for other_state in other_states + for excluded_state in excluded_states + ) + ), + ) + ) + return tests @@ -723,6 +794,7 @@ def parametrize_numerical_attribute_changed_trigger_states( trigger_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, + attribute_required: bool = False, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical-changed triggers. @@ -755,9 +827,28 @@ def parametrize_numerical_attribute_changed_trigger_states( unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated state, so the entity carries a unit alongside its tracked attribute. + attribute_required: When True, `(state, {attribute: None})` is + classified as an *excluded* state (filtered out of the all/count + check by the trigger's `_should_include` override) instead of an + "other" state. Set this for triggers that override + `_should_include` to skip entities lacking the attribute. """ trigger_options = trigger_options or {} unit_attributes = unit_attributes or {} + # When `attribute_required=True`, `(attr=None)` is filtered by the + # trigger's `_should_include` override, so it can no longer play the + # role of a "non-firing but counted" other state. Substitute a + # non-numeric string value: it fails `float(...)` (so is_valid_state is + # False) but is still `is not None` (so the override includes it in + # the all/count check), giving us a proper "other" state. Mirrors how + # `parametrize_numerical_state_value_changed_trigger_states` uses the + # literal string "none" as a non-numeric state value. + if attribute_required: + extra_excluded_states = [(state, {attribute: None} | unit_attributes)] + other_invalid_attr = (state, {attribute: "none"} | unit_attributes) + else: + extra_excluded_states = None + other_invalid_attr = (state, {attribute: None} | unit_attributes) return [ *parametrize_trigger_states( @@ -776,7 +867,8 @@ def parametrize_numerical_attribute_changed_trigger_states( (state, {attribute: 50} | unit_attributes), (state, {attribute: 100} | unit_attributes), ], - other_states=[(state, {attribute: None} | unit_attributes)], + other_states=[other_invalid_attr], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, ), @@ -797,9 +889,10 @@ def parametrize_numerical_attribute_changed_trigger_states( (state, {attribute: 100} | unit_attributes), ], other_states=[ - (state, {attribute: None} | unit_attributes), + other_invalid_attr, (state, {attribute: 0} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, ), @@ -820,9 +913,10 @@ def parametrize_numerical_attribute_changed_trigger_states( (state, {attribute: 50} | unit_attributes), ], other_states=[ - (state, {attribute: None} | unit_attributes), + other_invalid_attr, (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, retrigger_on_target_state=True, ), @@ -838,6 +932,7 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( trigger_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, unit_attributes: dict | None = None, + attribute_required: bool = False, ) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: """Parametrize states and expected service call counts for numerical crossed-threshold triggers. @@ -872,9 +967,25 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( unit_attributes: Attributes (typically `{ATTR_UNIT_OF_MEASUREMENT: ...}`) merged into every generated state, so the entity carries a unit alongside its tracked attribute. + attribute_required: When True, `(state, {attribute: None})` is + classified as an *excluded* state (filtered out of the all/count + check by the trigger's `_should_include` override) instead of an + "other" state. Set this for triggers that override + `_should_include` to skip entities lacking the attribute. """ trigger_options = trigger_options or {} unit_attributes = unit_attributes or {} + # See `parametrize_numerical_attribute_changed_trigger_states` for the + # rationale of substituting a non-numeric string-attr for `(attr=None)` + # when `attribute_required=True`: the override would filter `None` + # out of the all/count check, so we use a value that fails + # `is_valid_state` but is still included. + if attribute_required: + extra_excluded_states = [(state, {attribute: None} | unit_attributes)] + other_invalid_attr = (state, {attribute: "none"} | unit_attributes) + else: + extra_excluded_states = None + other_invalid_attr = (state, {attribute: None} | unit_attributes) return [ *parametrize_trigger_states( @@ -895,10 +1006,11 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( (state, {attribute: 60} | unit_attributes), ], other_states=[ - (state, {attribute: None} | unit_attributes), + other_invalid_attr, (state, {attribute: 0} | unit_attributes), (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_trigger_states( @@ -919,10 +1031,11 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( (state, {attribute: 100} | unit_attributes), ], other_states=[ - (state, {attribute: None} | unit_attributes), + other_invalid_attr, (state, {attribute: 50} | unit_attributes), (state, {attribute: 60} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_trigger_states( @@ -942,9 +1055,10 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( (state, {attribute: 100} | unit_attributes), ], other_states=[ - (state, {attribute: None} | unit_attributes), + other_invalid_attr, (state, {attribute: 0} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), *parametrize_trigger_states( @@ -964,9 +1078,10 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states( (state, {attribute: 50} | unit_attributes), ], other_states=[ - (state, {attribute: None} | unit_attributes), + other_invalid_attr, (state, {attribute: 100} | unit_attributes), ], + extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, ), ] @@ -1603,6 +1718,7 @@ async def assert_trigger_behavior_any( for state in states[1:]: excluded_state = state["excluded_state"] included_state = state["included_state"] + others_state = state["others_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() assert len(calls) == state["count"] @@ -1611,12 +1727,20 @@ async def assert_trigger_behavior_any( calls.clear() for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, included_state) + set_or_remove_state(hass, other_entity_id, others_state) await hass.async_block_till_done() for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(calls) == (entities_in_target - 1) * state["count"] + # When others_state differs from included_state, the post-others count + # is 0: others are placed in a state filtered or rejected by the + # trigger, so they don't fire individually. + expected_others_count = ( + (entities_in_target - 1) * state["count"] + if others_state == included_state + else 0 + ) + assert len(calls) == expected_others_count calls.clear() @@ -1654,6 +1778,7 @@ async def assert_trigger_behavior_first( for state in states[1:]: excluded_state = state["excluded_state"] included_state = state["included_state"] + others_state = state["others_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() assert len(calls) == state["count"] @@ -1662,7 +1787,7 @@ async def assert_trigger_behavior_first( calls.clear() for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, included_state) + set_or_remove_state(hass, other_entity_id, others_state) await hass.async_block_till_done() for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) @@ -1704,8 +1829,9 @@ async def assert_trigger_behavior_last( for state in states[1:]: excluded_state = state["excluded_state"] included_state = state["included_state"] + others_state = state["others_state"] for other_entity_id in other_entity_ids: - set_or_remove_state(hass, other_entity_id, included_state) + set_or_remove_state(hass, other_entity_id, others_state) await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index 163c199450f5f..b824cf4249c3e 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -51,50 +51,58 @@ async def test_media_player_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) -def parametrize_muted_trigger_states() -> list[ - tuple[str, list[TriggerStateDescription]] -]: - """Parametrize states and expected service call counts. - - Only states with volume attributes are used as other_states, because - entities without volume attributes are excluded from all/last checks - and would cause those tests to fire prematurely. - - Returns a list of tuples with (trigger, list of states), - where states is a list of TriggerStateDescription dicts. +# is_muted=True states (mute attr True OR volume_level == 0) +_IS_MUTED_STATES = [ + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True}), + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0}), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 0, ATTR_MEDIA_VOLUME_MUTED: True}, + ), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 0, ATTR_MEDIA_VOLUME_MUTED: False}, + ), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: True}, + ), +] + +# is_muted=False states (mute attr False/missing AND volume_level != 0) +_IS_NOT_MUTED_STATES = [ + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False}), + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 1}), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: False}, + ), +] + + +def parametrize_muted_trigger_states( + trigger: str, target_muted: bool +) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]: + """Parametrize states and expected service call counts for muted/unmuted. + + `target_muted` selects which side fires: True for `media_player.muted`, + False for `media_player.unmuted`. The helper swaps target / other state + sets accordingly. + + States without any volume attributes are passed as + `extra_excluded_states` because + `_MediaPlayerMutedStateTriggerBase._should_include` filters them out of + the all/count checks. + + Returns a list of tuples with (trigger, trigger_options, list of states). """ - trigger = "media_player.muted" return parametrize_trigger_states( trigger=trigger, - target_states=[ - # States with muted attribute - (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True}), - # States with volume attribute - (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0}), - # States with muted and volume attribute - ( - MediaPlayerState.PLAYING, - {ATTR_MEDIA_VOLUME_LEVEL: 0, ATTR_MEDIA_VOLUME_MUTED: True}, - ), - ( - MediaPlayerState.PLAYING, - {ATTR_MEDIA_VOLUME_LEVEL: 0, ATTR_MEDIA_VOLUME_MUTED: False}, - ), - ( - MediaPlayerState.PLAYING, - {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: True}, - ), - ], - other_states=[ - # States with muted attribute (not muted) - (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False}), - # States with volume attribute (not muted) - (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 1}), - # States with muted and volume attribute (not muted) - ( - MediaPlayerState.PLAYING, - {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: False}, - ), + target_states=_IS_MUTED_STATES if target_muted else _IS_NOT_MUTED_STATES, + other_states=_IS_NOT_MUTED_STATES if target_muted else _IS_MUTED_STATES, + extra_excluded_states=[ + # State without any volume attributes — filtered by _should_include + MediaPlayerState.PLAYING, ], ) @@ -137,7 +145,8 @@ async def test_media_player_trigger_options_validation( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_muted_trigger_states(), + *parametrize_muted_trigger_states("media_player.muted", target_muted=True), + *parametrize_muted_trigger_states("media_player.unmuted", target_muted=False), *parametrize_trigger_states( trigger="media_player.paused_playing", target_states=[ @@ -233,7 +242,8 @@ async def test_media_player_state_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_muted_trigger_states(), + *parametrize_muted_trigger_states("media_player.muted", target_muted=True), + *parametrize_muted_trigger_states("media_player.unmuted", target_muted=False), *parametrize_trigger_states( trigger="media_player.stopped_playing", target_states=[ @@ -280,7 +290,8 @@ async def test_media_player_state_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ - *parametrize_muted_trigger_states(), + *parametrize_muted_trigger_states("media_player.muted", target_muted=True), + *parametrize_muted_trigger_states("media_player.unmuted", target_muted=False), *parametrize_trigger_states( trigger="media_player.stopped_playing", target_states=[ @@ -358,90 +369,6 @@ async def test_muted_trigger_ignores_entities_without_volume_attributes( assert len(calls) == 1 -@pytest.mark.usefixtures("enable_labs_preview_features") -async def test_muted_trigger_fires_when_entity_gains_volume_attributes( - hass: HomeAssistant, -) -> None: - """Test that the trigger fires when an entity gains volume attributes and becomes muted.""" - entity_id = "media_player.gains_volume" - calls: list[str] = [] - - # Start without volume attributes - hass.states.async_set(entity_id, MediaPlayerState.PLAYING, {}) - await hass.async_block_till_done() - - await arm_trigger( - hass, - "media_player.muted", - None, - {CONF_ENTITY_ID: [entity_id]}, - calls, - ) - - # Gain volume attributes and become muted in one transition - hass.states.async_set( - entity_id, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} - ) - await hass.async_block_till_done() - assert len(calls) == 1 - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger", "initial_muted", "target_muted"), - [ - ("media_player.muted", False, True), - ("media_player.unmuted", True, False), - ], -) -async def test_muted_trigger_last_skips_entities_without_volume_attributes( - hass: HomeAssistant, - trigger: str, - initial_muted: bool, - target_muted: bool, -) -> None: - """Test that 'last' behavior skips entities without volume attributes. - - With entities a (has volume), b (has volume), c (no volume): - The trigger should fire when both a and b transition, regardless of c. - """ - entity_a = "media_player.with_volume_a" - entity_b = "media_player.with_volume_b" - entity_c = "media_player.no_volume" - calls: list[str] = [] - - hass.states.async_set( - entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} - ) - hass.states.async_set( - entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} - ) - hass.states.async_set(entity_c, MediaPlayerState.PLAYING, {}) - await hass.async_block_till_done() - - await arm_trigger( - hass, - trigger, - {"behavior": "last"}, - {CONF_ENTITY_ID: [entity_a, entity_b, entity_c]}, - calls, - ) - - # Transition entity a — not all mutable entities transitioned yet - hass.states.async_set( - entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} - ) - await hass.async_block_till_done() - assert len(calls) == 0 - - # Transition entity b — now all mutable entities have transitioned, fires - hass.states.async_set( - entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} - ) - await hass.async_block_till_done() - assert len(calls) == 1 - - @pytest.mark.usefixtures("enable_labs_preview_features") async def test_muted_trigger_does_not_fire_on_losing_volume_attributes( hass: HomeAssistant, @@ -472,54 +399,38 @@ async def test_muted_trigger_does_not_fire_on_losing_volume_attributes( @pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger", "initial_muted", "target_muted"), - [ - ("media_player.muted", False, True), - ("media_player.unmuted", True, False), - ], -) -async def test_muted_trigger_first_skips_entities_without_volume_attributes( +async def test_unmuted_trigger_does_not_fire_when_entity_gains_volume_attributes( hass: HomeAssistant, - trigger: str, - initial_muted: bool, - target_muted: bool, ) -> None: - """Test that 'first' behavior skips entities without volume attributes.""" - entity_a = "media_player.with_volume_a" - entity_b = "media_player.with_volume_b" - entity_c = "media_player.no_volume" + """Test that media_player.unmuted does not fire when an entity gains volume attributes already-unmuted. + + `is_muted` defaults to False for a state without volume attributes, so a + transition `(PLAYING, {})` -> `(PLAYING, {muted=False})` keeps `is_muted` + at False — `is_valid_transition` rejects it and the unmuted trigger + must stay silent. The shared muted/unmuted helper iterates entity_id + through the firing transitions for both sides via `_IS_MUTED_STATES` + and `_IS_NOT_MUTED_STATES`; this dedicated test covers the inverse + no-attrs-as-initial case for unmuted, which the helper does not + exercise on its own. + """ + entity_id = "media_player.gains_volume" calls: list[str] = [] - hass.states.async_set( - entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} - ) - hass.states.async_set( - entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} - ) - hass.states.async_set(entity_c, MediaPlayerState.PLAYING, {}) + # Start without volume attributes + hass.states.async_set(entity_id, MediaPlayerState.PLAYING, {}) await hass.async_block_till_done() await arm_trigger( hass, - trigger, - {"behavior": "first"}, - {CONF_ENTITY_ID: [entity_a, entity_b, entity_c]}, + "media_player.unmuted", + None, + {CONF_ENTITY_ID: [entity_id]}, calls, ) - # Transition entity a — first mutable entity transitions, fires + # Gain volume attributes already-unmuted — must not fire hass.states.async_set( - entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} - ) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0] == entity_a - calls.clear() - - # Transition entity b — first behavior already armed, does not fire again - hass.states.async_set( - entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} + entity_id, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False} ) await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 4d88ed54bae7c..95980ad1a754f 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -3301,7 +3301,7 @@ async def test_entity_trigger_first_requires_exactly_one( async def test_entity_trigger_last_ignores_unavailable_and_unknown_entity( hass: HomeAssistant, invalid_state: str ) -> None: - """Test behavior last: unavailable/unknown entities are excluded from check_all_match. + """Test behavior last: unavailable/unknown entities are excluded from the all-match check. With three entities (A=off, B=unavailable, C=off), turning A on should not fire because C is still off, so the available entities do not all @@ -3569,6 +3569,51 @@ async def test_entity_trigger_duration_last_requires_all( unsub() +async def test_entity_trigger_duration_last_cancelled_when_all_entities_filtered( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test behavior last with for: timer is cancelled when every targeted entity is filtered out. + + With behavior=last + `for:`, an "all match" check that becomes vacuously + True (every targeted entity filtered by `_should_include` — here all + entities go unavailable) must not keep the timer alive; otherwise the + action would fire after the duration even though no entity still + matches. + """ + entity_a = "test.entity_a" + entity_b = "test.entity_b" + hass.states.async_set(entity_a, STATE_OFF) + hass.states.async_set(entity_b, STATE_OFF) + await hass.async_block_till_done() + + calls: list[dict[str, Any]] = [] + unsub = await _arm_off_to_on_trigger( + hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5} + ) + + # Turn both on — combined state "all on", timer starts + hass.states.async_set(entity_a, STATE_ON) + hass.states.async_set(entity_b, STATE_ON) + await hass.async_block_till_done() + + # After 2 seconds, every targeted entity goes unavailable. Both are + # filtered out of the all-check by `_should_include`, leaving the + # check vacuously True. The timer must still be cancelled. + freezer.tick(datetime.timedelta(seconds=2)) + async_fire_time_changed(hass) + hass.states.async_set(entity_a, STATE_UNAVAILABLE) + hass.states.async_set(entity_b, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + # Advance past the original duration — should NOT fire + freezer.tick(datetime.timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 0 + + unsub() + + async def test_entity_trigger_duration_last_cancelled_when_one_turns_off( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: