From f2f59eb8b7122a420242c141ba7070a2be6c38eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Apr 2026 17:15:56 +0200 Subject: [PATCH] Add todo conditions (#167752) --- .../components/automation/__init__.py | 1 + homeassistant/components/todo/condition.py | 20 ++ homeassistant/components/todo/conditions.yaml | 37 +++ homeassistant/components/todo/icons.json | 8 + homeassistant/components/todo/strings.json | 33 +++ tests/components/todo/test_condition.py | 248 ++++++++++++++++++ 6 files changed, 347 insertions(+) create mode 100644 homeassistant/components/todo/condition.py create mode 100644 homeassistant/components/todo/conditions.yaml create mode 100644 tests/components/todo/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index e6001569bccc..a648207bbea9 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -151,6 +151,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "temperature", "text", "timer", + "todo", "vacuum", "valve", "water_heater", diff --git a/homeassistant/components/todo/condition.py b/homeassistant/components/todo/condition.py new file mode 100644 index 000000000000..e3aebd4cd4a9 --- /dev/null +++ b/homeassistant/components/todo/condition.py @@ -0,0 +1,20 @@ +"""Provides conditions for to-do lists.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import ( + Condition, + make_entity_numerical_condition, + make_entity_state_condition, +) + +from .const import DOMAIN + +CONDITIONS: dict[str, type[Condition]] = { + "all_completed": make_entity_state_condition(DOMAIN, "0"), + "incomplete": make_entity_numerical_condition(DOMAIN), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the to-do list conditions.""" + return CONDITIONS diff --git a/homeassistant/components/todo/conditions.yaml b/homeassistant/components/todo/conditions.yaml new file mode 100644 index 000000000000..84e49fda956e --- /dev/null +++ b/homeassistant/components/todo/conditions.yaml @@ -0,0 +1,37 @@ +.condition_common: &condition_common + target: &condition_todo_target + entity: + domain: todo + fields: + behavior: &condition_behavior + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +.incomplete_threshold_entity: &incomplete_threshold_entity + - domain: input_number + - domain: number + - domain: sensor + +.incomplete_threshold_number: &incomplete_threshold_number + min: 0 + mode: box + +all_completed: *condition_common + +incomplete: + target: *condition_todo_target + fields: + behavior: *condition_behavior + threshold: + required: true + selector: + numeric_threshold: + entity: *incomplete_threshold_entity + mode: is + number: *incomplete_threshold_number diff --git a/homeassistant/components/todo/icons.json b/homeassistant/components/todo/icons.json index 3addb8400c76..588b0e4b2174 100644 --- a/homeassistant/components/todo/icons.json +++ b/homeassistant/components/todo/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "all_completed": { + "condition": "mdi:clipboard-check" + }, + "incomplete": { + "condition": "mdi:clipboard-alert" + } + }, "entity_component": { "_": { "default": "mdi:clipboard-list" diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 4bf0565f135c..8b2d1dceb3e9 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -1,4 +1,31 @@ { + "common": { + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type" + }, + "conditions": { + "all_completed": { + "description": "Tests if all to-do items are completed in one or more to-do lists.", + "fields": { + "behavior": { + "name": "[%key:component::todo::common::condition_behavior_name%]" + } + }, + "name": "All to-do items completed" + }, + "incomplete": { + "description": "Tests the number of incomplete to-do items in one or more to-do lists.", + "fields": { + "behavior": { + "name": "[%key:component::todo::common::condition_behavior_name%]" + }, + "threshold": { + "name": "[%key:component::todo::common::condition_threshold_name%]" + } + }, + "name": "Incomplete to-do items" + } + }, "entity_component": { "_": { "name": "[%key:component::todo::title%]" @@ -13,6 +40,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "status": { "options": { "completed": "Completed", diff --git a/tests/components/todo/test_condition.py b/tests/components/todo/test_condition.py new file mode 100644 index 000000000000..26a0ef33566f --- /dev/null +++ b/tests/components/todo/test_condition.py @@ -0,0 +1,248 @@ +"""Test to-do list conditions.""" + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_todos(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple to-do list entities associated with different targets.""" + return await target_entities(hass, "todo", domain_excluded="sensor") + + +@pytest.mark.parametrize( + "condition", + [ + "todo.all_completed", + "todo.incomplete", + ], +) +async def test_todo_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the to-do list conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("todo"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="todo.all_completed", + target_states=["0"], + other_states=["1", "5"], + excluded_entities_from_other_domain=True, + ), + ], +) +async def test_todo_state_condition_behavior_any( + hass: HomeAssistant, + target_todos: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the to-do list state condition with the 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_todos, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("todo"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="todo.all_completed", + target_states=["0"], + other_states=["1", "5"], + excluded_entities_from_other_domain=True, + ), + ], +) +async def test_todo_state_condition_behavior_all( + hass: HomeAssistant, + target_todos: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the to-do list state condition with the 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_todos, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +def parametrize_incomplete_condition_states_any( + condition: str, +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize above/below threshold test cases for incomplete conditions.""" + return [ + *parametrize_condition_states_any( + condition=condition, + condition_options={"threshold": {"type": "above", "value": {"number": 3}}}, + target_states=["5", "10"], + other_states=["0", "1", "3"], + ), + *parametrize_condition_states_any( + condition=condition, + condition_options={"threshold": {"type": "below", "value": {"number": 5}}}, + target_states=["0", "3"], + other_states=["5", "10"], + ), + *parametrize_condition_states_any( + condition=condition, + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 2}, + "value_max": {"number": 8}, + } + }, + target_states=["3", "5"], + other_states=["0", "1", "2", "8", "10"], + ), + ] + + +def parametrize_incomplete_condition_states_all( + condition: str, +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize above/below threshold test cases for incomplete conditions with 'all' behavior.""" + return [ + *parametrize_condition_states_all( + condition=condition, + condition_options={"threshold": {"type": "above", "value": {"number": 3}}}, + target_states=["5", "10"], + other_states=["0", "1", "3"], + ), + *parametrize_condition_states_all( + condition=condition, + condition_options={"threshold": {"type": "below", "value": {"number": 5}}}, + target_states=["0", "3"], + other_states=["5", "10"], + ), + *parametrize_condition_states_all( + condition=condition, + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 2}, + "value_max": {"number": 8}, + } + }, + target_states=["3", "5"], + other_states=["0", "1", "2", "8", "10"], + ), + ] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("todo"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_incomplete_condition_states_any("todo.incomplete"), + ], +) +async def test_todo_incomplete_condition_behavior_any( + hass: HomeAssistant, + target_todos: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the to-do list incomplete condition with the 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_todos, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("todo"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_incomplete_condition_states_all("todo.incomplete"), + ], +) +async def test_todo_incomplete_condition_behavior_all( + hass: HomeAssistant, + target_todos: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the to-do list incomplete condition with the 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_todos, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + )