Add complete item intent function for todo component (#127806)

* add complete item intent

* fix error and add tests

* fix merge conflict

* improve error tests

* improve error tests

* add response_key

* add check for non completed

---------

Co-authored-by: Michael Hansen <mike@rhasspy.org>
This commit is contained in:
Elias Wernicke 2025-03-03 19:16:43 +01:00 committed by GitHub
parent 1b15df3075
commit 62b6be900f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 205 additions and 2 deletions

View File

@ -11,11 +11,13 @@ from . import TodoItem, TodoItemStatus, TodoListEntity
from .const import DATA_COMPONENT, DOMAIN
INTENT_LIST_ADD_ITEM = "HassListAddItem"
INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the todo intents."""
intent.async_register(hass, ListAddItemIntent())
intent.async_register(hass, ListCompleteItemIntent())
class ListAddItemIntent(intent.IntentHandler):
@ -53,14 +55,92 @@ class ListAddItemIntent(intent.IntentHandler):
match_result.states[0].entity_id
)
if target_list is None:
raise intent.IntentHandleError(f"No to-do list: {list_name}")
raise intent.IntentHandleError(
f"No to-do list: {list_name}", "list_not_found"
)
# Add to list
await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
)
response = intent_obj.create_response()
response: intent.IntentResponse = intent_obj.create_response()
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name=list_name,
id=match_result.states[0].entity_id,
)
]
)
return response
class ListCompleteItemIntent(intent.IntentHandler):
"""Handle ListCompleteItem intents."""
intent_type = INTENT_LIST_COMPLETE_ITEM
description = "Complete item on a todo list"
slot_schema = {
vol.Required("item"): intent.non_empty_string,
vol.Required("name"): intent.non_empty_string,
}
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
item = slots["item"]["value"]
list_name = slots["name"]["value"]
target_list: TodoListEntity | None = None
# Find matching list
match_constraints = intent.MatchTargetsConstraints(
name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
target_list = hass.data[DATA_COMPONENT].get_entity(
match_result.states[0].entity_id
)
if target_list is None:
raise intent.IntentHandleError(
f"No to-do list: {list_name}", "list_not_found"
)
# Find item in list
matching_item = None
for todo_item in target_list.todo_items or ():
if (
item in (todo_item.uid, todo_item.summary)
and todo_item.status == TodoItemStatus.NEEDS_ACTION
):
matching_item = todo_item
break
if not matching_item or not matching_item.uid:
raise intent.IntentHandleError(
f"Item '{item}' not found on list", "item_not_found"
)
# Mark as completed
await target_list.async_update_todo_item(
TodoItem(
uid=matching_item.uid,
summary=matching_item.summary,
status=TodoItemStatus.COMPLETED,
)
)
response: intent.IntentResponse = intent_obj.create_response()
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
[

View File

@ -34,6 +34,13 @@ class MockTodoListEntity(TodoListEntity):
"""Delete an item in the To-do list."""
self._attr_todo_items = [item for item in self.items if item.uid not in uids]
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item in the To-do list."""
for idx, existing_item in enumerate(self.items):
if existing_item.uid == item.uid:
self._attr_todo_items[idx] = item
break
async def create_mock_platform(
hass: HomeAssistant,

View File

@ -1,5 +1,7 @@
"""Tests for the todo intents."""
from unittest.mock import patch
import pytest
from homeassistant.components import conversation
@ -7,10 +9,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose
from homeassistant.components.todo import (
ATTR_ITEM,
DOMAIN,
TodoItem,
TodoItemStatus,
TodoListEntity,
intent as todo_intent,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
@ -18,6 +22,7 @@ from homeassistant.setup import async_setup_component
from . import MockTodoListEntity, create_mock_platform
from tests.common import async_mock_service
from tests.typing import WebSocketGenerator
@ -174,3 +179,114 @@ async def test_add_item_intent_errors(
},
assistant=conversation.DOMAIN,
)
async def test_complete_item_intent(
hass: HomeAssistant,
) -> None:
"""Test the complete item intent."""
entity1 = MockTodoListEntity(
[
TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION),
TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION),
]
)
entity1._attr_name = "List 1"
entity1.entity_id = "todo.list_1"
# Add entities to hass
config_entry = await create_mock_platform(hass, [entity1])
assert config_entry.state is ConfigEntryState.LOADED
assert len(entity1.items) == 2
assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION
# Complete item
async_mock_service(hass, DOMAIN, todo_intent.INTENT_LIST_COMPLETE_ITEM)
response = await intent.async_handle(
hass,
DOMAIN,
todo_intent.INTENT_LIST_COMPLETE_ITEM,
{ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(entity1.items) == 2
assert entity1.items[0].status == TodoItemStatus.COMPLETED
async def test_complete_item_intent_errors(
hass: HomeAssistant,
test_entity: TodoListEntity,
) -> None:
"""Test errors with the complete item intent."""
entity1 = MockTodoListEntity(
[
TodoItem(summary="beer", uid="1", status=TodoItemStatus.COMPLETED),
]
)
entity1._attr_name = "List 1"
entity1.entity_id = "todo.list_1"
# Add entities to hass
await create_mock_platform(hass, [entity1])
# Try to complete item in list that does not exist
with pytest.raises(intent.MatchFailedError):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_COMPLETE_ITEM,
{
ATTR_ITEM: {"value": "wine"},
ATTR_NAME: {"value": "This list does not exist"},
},
assistant=conversation.DOMAIN,
)
# Try to complete item that does not exist
with pytest.raises(intent.IntentHandleError):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_COMPLETE_ITEM,
{ATTR_ITEM: {"value": "bread"}, ATTR_NAME: {"value": "list 1"}},
assistant=conversation.DOMAIN,
)
# Item is already completed
with pytest.raises(intent.IntentHandleError):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_COMPLETE_ITEM,
{ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}},
assistant=conversation.DOMAIN,
)
async def test_complete_item_intent_ha_errors(
hass: HomeAssistant,
test_entity: TodoListEntity,
) -> None:
"""Test error handling of HA errors with the complete item intent."""
test_entity._attr_name = "List 1"
test_entity.entity_id = "todo.list_1"
await create_mock_platform(hass, [test_entity])
# Mock the get_entity method to return None
with (
patch(
"homeassistant.helpers.entity_component.EntityComponent.get_entity",
return_value=None,
),
pytest.raises(intent.IntentHandleError),
):
await intent.async_handle(
hass,
DOMAIN,
todo_intent.INTENT_LIST_COMPLETE_ITEM,
{ATTR_ITEM: {"value": "wine"}, ATTR_NAME: {"value": "List 1"}},
assistant=conversation.DOMAIN,
)