diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index a2235a9499b..c30b8fe2603 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3326,6 +3326,63 @@ async def _setup_numerical_condition( "90", False, ), + # outside (inverse of between) — limits are non-inclusive, so a value + # equal to either bound is treated as "not inside" and matches + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "50", + False, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "20", + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "80", + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "10", + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "90", + True, + ), ], ) async def test_numerical_condition_thresholds( diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index ffc0df9b43f..24c71f0bdc0 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1937,6 +1937,188 @@ async def test_numerical_state_attribute_changed_error_handling( assert len(service_calls) == 0 +@pytest.mark.parametrize( + ("trigger_options", "new_value", "expected_fires"), + [ + # above — limit is non-inclusive + ({"threshold": {"type": "above", "value": {"number": 50}}}, 75, True), + ({"threshold": {"type": "above", "value": {"number": 50}}}, 50, False), + ({"threshold": {"type": "above", "value": {"number": 50}}}, 25, False), + # below — limit is non-inclusive + ({"threshold": {"type": "below", "value": {"number": 50}}}, 25, True), + ({"threshold": {"type": "below", "value": {"number": 50}}}, 50, False), + ({"threshold": {"type": "below", "value": {"number": 50}}}, 75, False), + # between — both limits are non-inclusive + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 50, + True, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 20, + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 80, + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 10, + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 90, + False, + ), + # outside — values equal to either bound are treated as "not inside" + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 50, + False, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 20, + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 80, + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 10, + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 90, + True, + ), + # any — fires on every numerical change regardless of value + ({"threshold": {"type": "any"}}, 0, True), + ({"threshold": {"type": "any"}}, 50, True), + ({"threshold": {"type": "any"}}, 1000, True), + ], +) +async def test_numerical_state_attribute_changed_trigger_thresholds( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_options: dict[str, Any], + new_value: float, + expected_fires: bool, +) -> None: + """Test numerical changed trigger above/below/between/outside/any thresholds. + + Verifies that the threshold limits are non-inclusive: a tracked value + exactly equal to a limit is treated as "not inside" the range. + """ + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return { + "attribute_changed": make_entity_numerical_state_changed_trigger( + {"test": DomainSpec(value_source="test_attribute")} + ), + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + # Seed the entity with a starting value that differs from new_value so + # the changed-transition is always satisfied; the test then exercises + # the is_valid_state boundary semantics for the new value. + initial_value = -1 if new_value != -1 else -2 + hass.states.async_set("test.test_entity", "on", {"test_attribute": initial_value}) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "test.attribute_changed", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: trigger_options, + }, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, + }, + } + }, + ) + assert len(service_calls) == 0 + + hass.states.async_set("test.test_entity", "on", {"test_attribute": new_value}) + await hass.async_block_till_done() + assert len(service_calls) == (1 if expected_fires else 0) + + async def test_numerical_state_attribute_changed_entity_limit_unit_validation( hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: @@ -2845,6 +3027,195 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( assert len(service_calls) == 0 +@pytest.mark.parametrize( + ("trigger_options", "new_value", "expected_fires"), + [ + # above — limit is non-inclusive, crossing exactly onto the limit does + # not enter the range + ({"threshold": {"type": "above", "value": {"number": 50}}}, 75, True), + ({"threshold": {"type": "above", "value": {"number": 50}}}, 50, False), + ({"threshold": {"type": "above", "value": {"number": 50}}}, 25, False), + # below — limit is non-inclusive + ({"threshold": {"type": "below", "value": {"number": 50}}}, 25, True), + ({"threshold": {"type": "below", "value": {"number": 50}}}, 50, False), + ({"threshold": {"type": "below", "value": {"number": 50}}}, 75, False), + # between — both limits are non-inclusive + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 50, + True, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 20, + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 80, + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 10, + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 90, + False, + ), + # outside — values equal to either bound are treated as "not inside" + # and therefore enter the "outside" range from the inside seed value + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 50, + False, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 20, + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 80, + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 10, + True, + ), + ( + { + "threshold": { + "type": "outside", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + 90, + True, + ), + ], +) +async def test_numerical_state_attribute_crossed_threshold_trigger_thresholds( + hass: HomeAssistant, + service_calls: list[ServiceCall], + trigger_options: dict[str, Any], + new_value: float, + expected_fires: bool, +) -> None: + """Test crossed-threshold trigger above/below/between/outside thresholds. + + Verifies the threshold limits are non-inclusive: transitioning to a value + exactly equal to a limit does not enter the range, so the trigger does + not fire. For "outside", values equal to either bound are considered + outside and therefore do cause the trigger to fire. + """ + + async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + return { + "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + {"test": DomainSpec(value_source="test_attribute")} + ), + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + # Seed the entity with a value that is NOT in the target range so the + # transition into the new value is a potential "cross". The seed is + # chosen per threshold type to ensure is_valid_state(from_state) is + # False and the seed value differs from any parametrized new_value. + seed_values = { + "above": 0, # 0 is not above 50 + "below": 100, # 100 is not below 50 + "between": 0, # 0 is not inside (20, 80) + "outside": 30, # 30 is inside (20, 80), i.e. not "outside" + } + seed_value = seed_values[trigger_options["threshold"]["type"]] + hass.states.async_set("test.test_entity", "on", {"test_attribute": seed_value}) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: "test.crossed_threshold", + CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"}, + CONF_OPTIONS: trigger_options, + }, + "action": { + "service": "test.automation", + "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, + }, + } + }, + ) + assert len(service_calls) == 0 + + hass.states.async_set("test.test_entity", "on", {"test_attribute": new_value}) + await hass.async_block_till_done() + assert len(service_calls) == (1 if expected_fires else 0) + + async def test_numerical_state_attribute_crossed_threshold_entity_limit_unit_validation( hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: