diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index cd30c2eeebe..c5cf25a8c2e 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -90,6 +90,9 @@ class LocalTodoListEntity(TodoListEntity): | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.MOVE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) _attr_should_poll = False @@ -115,6 +118,8 @@ class LocalTodoListEntity(TodoListEntity): status=ICS_TODO_STATUS_MAP.get( item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION ), + due=item.due, + description=item.description, ) for item in self._calendar.todos ] diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index be3c0b57593..814138dcb7f 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,6 +1,6 @@ """The todo integration.""" -from collections.abc import Callable +from collections.abc import Callable, Iterable import dataclasses import datetime import logging @@ -28,9 +28,18 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType -from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature +from .const import ( + ATTR_DESCRIPTION, + ATTR_DUE, + ATTR_DUE_DATE, + ATTR_DUE_DATE_TIME, + DOMAIN, + TodoItemStatus, + TodoListEntityFeature, +) _LOGGER = logging.getLogger(__name__) @@ -39,6 +48,65 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) ENTITY_ID_FORMAT = DOMAIN + ".{}" +@dataclasses.dataclass +class TodoItemFieldDescription: + """A description of To-do item fields and validation requirements.""" + + service_field: str + """Field name for service calls.""" + + todo_item_field: str + """Field name for TodoItem.""" + + validation: Callable[[Any], Any] + """Voluptuous validation function.""" + + required_feature: TodoListEntityFeature + """Entity feature that enables this field.""" + + +TODO_ITEM_FIELDS = [ + TodoItemFieldDescription( + service_field=ATTR_DUE_DATE, + validation=cv.date, + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DUE_DATE_TIME, + validation=vol.All(cv.datetime, dt_util.as_local), + todo_item_field=ATTR_DUE, + required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + ), + TodoItemFieldDescription( + service_field=ATTR_DESCRIPTION, + validation=cv.string, + todo_item_field=ATTR_DESCRIPTION, + required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + ), +] + +TODO_ITEM_FIELD_SCHEMA = { + vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS +} +TODO_ITEM_FIELD_VALIDATIONS = [ + cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATE_TIME) +] + + +def _validate_supported_features( + supported_features: int | None, call_data: dict[str, Any] +) -> None: + """Validate service call fields against entity supported features.""" + for desc in TODO_ITEM_FIELDS: + if desc.service_field not in call_data: + continue + if not supported_features or not supported_features & desc.required_feature: + raise ValueError( + f"Entity does not support setting field '{desc.service_field}'" + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Todo entities.""" component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( @@ -53,9 +121,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( "add_item", - { - vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), - }, + vol.All( + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + **TODO_ITEM_FIELD_SCHEMA, + } + ), + *TODO_ITEM_FIELD_VALIDATIONS, + ), _async_add_todo_item, required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], ) @@ -69,9 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Optional("status"): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), + **TODO_ITEM_FIELD_SCHEMA, } ), - cv.has_at_least_one_key("rename", "status"), + *TODO_ITEM_FIELD_VALIDATIONS, + cv.has_at_least_one_key( + "rename", "status", *[desc.service_field for desc in TODO_ITEM_FIELDS] + ), ), _async_update_todo_item, required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], @@ -135,6 +213,20 @@ class TodoItem: status: TodoItemStatus | None = None """A status or confirmation of the To-do item.""" + due: datetime.date | datetime.datetime | None = None + """The date and time that a to-do is expected to be completed. + + This field may be a date or datetime depending whether the entity feature + DUE_DATE or DUE_DATETIME are set. + """ + + description: str | None = None + """A more complete description of than that provided by the summary. + + This field may be set when TodoListEntityFeature.DESCRIPTION is supported by + the entity. + """ + class TodoListEntity(Entity): """An entity that represents a To-do list.""" @@ -262,6 +354,19 @@ async def websocket_handle_subscribe_todo_items( entity.async_update_listeners() +def _api_items_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert CalendarEvent dataclass items to dictionary of attributes.""" + result: dict[str, str] = {} + for name, value in obj: + if value is None: + continue + if isinstance(value, (datetime.date, datetime.datetime)): + result[name] = value.isoformat() + else: + result[name] = str(value) + return result + + @websocket_api.websocket_command( { vol.Required("type"): "todo/item/list", @@ -285,7 +390,13 @@ async def websocket_handle_todo_item_list( items: list[TodoItem] = entity.todo_items or [] connection.send_message( websocket_api.result_message( - msg["id"], {"items": [dataclasses.asdict(item) for item in items]} + msg["id"], + { + "items": [ + dataclasses.asdict(item, dict_factory=_api_items_factory) + for item in items + ] + }, ) ) @@ -342,8 +453,17 @@ def _find_by_uid_or_summary( async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Add an item to the To-do list.""" + _validate_supported_features(entity.supported_features, call.data) await entity.async_create_todo_item( - item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + item=TodoItem( + summary=call.data["item"], + status=TodoItemStatus.NEEDS_ACTION, + **{ + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + }, + ) ) @@ -354,11 +474,20 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> if not found: raise ValueError(f"Unable to find To-do item '{item}'") - update_item = TodoItem( - uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") - ) + _validate_supported_features(entity.supported_features, call.data) - await entity.async_update_todo_item(item=update_item) + await entity.async_update_todo_item( + item=TodoItem( + uid=found.uid, + summary=call.data.get("rename"), + status=call.data.get("status"), + **{ + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + }, + ) + ) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: @@ -378,7 +507,7 @@ async def _async_get_todo_items( """Return items in the To-do list.""" return { "items": [ - dataclasses.asdict(item) + dataclasses.asdict(item, dict_factory=_api_items_factory) for item in entity.todo_items or () if not (statuses := call.data.get("status")) or item.status in statuses ] diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 5a8a6e54e8f..95e190cb3e3 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -4,6 +4,11 @@ from enum import IntFlag, StrEnum DOMAIN = "todo" +ATTR_DUE = "due" +ATTR_DUE_DATE = "due_date" +ATTR_DUE_DATE_TIME = "due_date_time" +ATTR_DESCRIPTION = "description" + class TodoListEntityFeature(IntFlag): """Supported features of the To-do List entity.""" @@ -12,6 +17,9 @@ class TodoListEntityFeature(IntFlag): DELETE_TODO_ITEM = 2 UPDATE_TODO_ITEM = 4 MOVE_TODO_ITEM = 8 + SET_DUE_DATE_ON_ITEM = 16 + SET_DUE_DATETIME_ON_ITEM = 32 + SET_DESCRIPTION_ON_ITEM = 64 class TodoItemStatus(StrEnum): diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 5474efefbdf..390aa82753a 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -25,6 +25,18 @@ add_item: example: "Submit income tax return" selector: text: + due_date: + example: "2023-11-17" + selector: + date: + due_date_time: + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: update_item: target: entity: @@ -49,6 +61,18 @@ update_item: options: - needs_action - completed + due_date: + example: "2023-11-17" + selector: + date: + due_date_time: + example: "2023-11-17 13:30:00" + selector: + datetime: + description: + example: "A more complete description of the to-do item than that provided by the summary." + selector: + text: remove_item: target: entity: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index a651a161763..bca32f850eb 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -23,6 +23,18 @@ "item": { "name": "Item name", "description": "The name that represents the to-do item." + }, + "due_date": { + "name": "Due date", + "description": "The date the to-do item is expected to be completed." + }, + "due_date_time": { + "name": "Due date time", + "description": "The date and time the to-do item is expected to be completed." + }, + "description": { + "name": "Description", + "description": "A more complete description of the to-do item than provided by the item name." } } }, @@ -41,6 +53,18 @@ "status": { "name": "Set status", "description": "A status or confirmation of the to-do item." + }, + "due_date": { + "name": "Due date", + "description": "The date the to-do item is expected to be completed." + }, + "due_date_time": { + "name": "Due date time", + "description": "The date and time the to-do item is expected to be completed." + }, + "description": { + "name": "Description", + "description": "A more complete description of the to-do item than provided by the item name." } } }, diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 5e6aff9cbf3..b2c79ef4bd1 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable import textwrap +from typing import Any import pytest @@ -58,11 +59,31 @@ async def ws_move_item( return move +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, {}), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ( + {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + ), + ({"description": "Additional detail"}, {"description": "Additional detail"}), + ], +) async def test_add_item( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], ) -> None: """Test adding a todo item.""" @@ -73,7 +94,7 @@ async def test_add_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "replace batteries"}, + {"item": "replace batteries", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -82,6 +103,8 @@ async def test_add_item( assert len(items) == 1 assert items[0]["summary"] == "replace batteries" assert items[0]["status"] == "needs_action" + for k, v in expected_item_data.items(): + assert items[0][k] == v assert "uid" in items[0] state = hass.states.get(TEST_ENTITY) @@ -89,16 +112,30 @@ async def test_add_item( assert state.state == "1" +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({}, {}), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ( + {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + ), + ({"description": "Additional detail"}, {"description": "Additional detail"}), + ], +) async def test_remove_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], ) -> None: """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "replace batteries"}, + {"item": "replace batteries", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -107,6 +144,8 @@ async def test_remove_item( assert len(items) == 1 assert items[0]["summary"] == "replace batteries" assert items[0]["status"] == "needs_action" + for k, v in expected_item_data.items(): + assert items[0][k] == v assert "uid" in items[0] state = hass.states.get(TEST_ENTITY) @@ -168,10 +207,30 @@ async def test_bulk_remove( assert state.state == "0" +@pytest.mark.parametrize( + ("item_data", "expected_item_data", "expected_state"), + [ + ({"status": "completed"}, {"status": "completed"}, "0"), + ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), + ( + {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due": "2023-11-17T05:30:00-06:00"}, + "1", + ), + ( + {"description": "Additional detail"}, + {"description": "Additional detail"}, + "1", + ), + ], +) async def test_update_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], + expected_state: str, ) -> None: """Test updating a todo item.""" @@ -199,21 +258,22 @@ async def test_update_item( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": item["uid"], "status": "completed"}, + {"item": item["uid"], **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) - # Verify item is marked as completed + # Verify item is updated items = await ws_get_items() assert len(items) == 1 item = items[0] assert item["summary"] == "soda" - assert item["status"] == "completed" + for k, v in expected_item_data.items(): + assert items[0][k] == v state = hass.states.get(TEST_ENTITY) assert state - assert state.state == "0" + assert state.state == expected_state async def test_rename( diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index a65cce27349..0071d4ada86 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -1,8 +1,10 @@ """Tests for the todo integration.""" from collections.abc import Generator +import datetime from typing import Any from unittest.mock import AsyncMock +import zoneinfo import pytest import voluptuous as vol @@ -43,6 +45,8 @@ ITEM_2 = { "summary": "Item #2", "status": "completed", } +TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina") +TEST_OFFSET = "-06:00" class MockFlow(ConfigFlow): @@ -108,6 +112,12 @@ def mock_setup_integration(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + async def create_mock_platform( hass: HomeAssistant, entities: list[TodoListEntity], @@ -263,7 +273,7 @@ async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: - """Test a To-do list that does not support features.""" + """Test a To-do list for an entity that does not exist.""" entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" @@ -327,23 +337,42 @@ async def test_add_item_service_raises( @pytest.mark.parametrize( - ("item_data", "expected_error"), + ("item_data", "expected_exception", "expected_error"), [ - ({}, "required key not provided"), - ({"item": ""}, "length of value must be at least 1"), + ({}, vol.Invalid, "required key not provided"), + ({"item": ""}, vol.Invalid, "length of value must be at least 1"), + ( + {"item": "Submit forms", "description": "Submit tax forms"}, + ValueError, + "does not support setting field 'description'", + ), + ( + {"item": "Submit forms", "due_date": "2023-11-17"}, + ValueError, + "does not support setting field 'due_date'", + ), + ( + { + "item": "Submit forms", + "due_date_time": f"2023-11-17T17:00:00{TEST_OFFSET}", + }, + ValueError, + "does not support setting field 'due_date_time'", + ), ], ) async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], + expected_exception: str, expected_error: str, ) -> None: """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) - with pytest.raises(vol.Invalid, match=expected_error): + with pytest.raises(expected_exception, match=expected_error): await hass.services.async_call( DOMAIN, "add_item", @@ -353,6 +382,82 @@ async def test_add_item_service_invalid_input( ) +@pytest.mark.parametrize( + ("supported_entity_feature", "item_data", "expected_item"), + ( + ( + TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + {"item": "New item", "due_date": "2023-11-13"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.date(2023, 11, 13), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 17, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_date_time": "2023-11-13T17:00:00+00:00"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 11, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"item": "New item", "due_date_time": "2023-11-13"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.datetime(2023, 11, 13, 0, 00, 00, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"item": "New item", "description": "Submit revised draft"}, + TodoItem( + summary="New item", + status=TodoItemStatus.NEEDS_ACTION, + description="Submit revised draft", + ), + ), + ), +) +async def test_add_item_service_extended_fields( + hass: HomeAssistant, + test_entity: TodoListEntity, + supported_entity_feature: int, + item_data: dict[str, Any], + expected_item: TodoItem, +) -> None: + """Test adding an item in a To-do list.""" + + test_entity._attr_supported_features |= supported_entity_feature + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "add_item", + {"item": "New item", **item_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_create_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_item + + async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -555,6 +660,82 @@ async def test_update_item_service_invalid_input( ) +@pytest.mark.parametrize( + ("update_data"), + [ + ({"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({"due_date": "2023-11-13"}), + ({"description": "Submit revised draft"}), + ], +) +async def test_update_todo_item_field_unsupported( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="does not support"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("supported_entity_feature", "update_data", "expected_update"), + ( + ( + TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, + {"due_date": "2023-11-13"}, + TodoItem(uid="1", due=datetime.date(2023, 11, 13)), + ), + ( + TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, + {"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + TodoItem( + uid="1", + due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), + ), + ), + ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, + {"description": "Submit revised draft"}, + TodoItem(uid="1", description="Submit revised draft"), + ), + ), +) +async def test_update_todo_item_extended_fields( + hass: HomeAssistant, + test_entity: TodoListEntity, + supported_entity_feature: int, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= supported_entity_feature + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_update + + async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -971,8 +1152,20 @@ async def test_subscribe( event_message = msg["event"] assert event_message == { "items": [ - {"summary": "Item #1", "uid": "1", "status": "needs_action"}, - {"summary": "Item #2", "uid": "2", "status": "completed"}, + { + "summary": "Item #1", + "uid": "1", + "status": "needs_action", + "due": None, + "description": None, + }, + { + "summary": "Item #2", + "uid": "2", + "status": "completed", + "due": None, + "description": None, + }, ] } test_entity._attr_todo_items = [ @@ -985,9 +1178,27 @@ async def test_subscribe( event_message = msg["event"] assert event_message == { "items": [ - {"summary": "Item #1", "uid": "1", "status": "needs_action"}, - {"summary": "Item #2", "uid": "2", "status": "completed"}, - {"summary": "Item #3", "uid": "3", "status": "needs_action"}, + { + "summary": "Item #1", + "uid": "1", + "status": "needs_action", + "due": None, + "description": None, + }, + { + "summary": "Item #2", + "uid": "2", + "status": "completed", + "due": None, + "description": None, + }, + { + "summary": "Item #3", + "uid": "3", + "status": "needs_action", + "due": None, + "description": None, + }, ] } @@ -1023,3 +1234,67 @@ async def test_subscribe_entity_does_not_exist( "code": "invalid_entity_id", "message": "To-do list entity not found: todo.unknown", } + + +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ({"due": datetime.date(2023, 11, 17)}, {"due": "2023-11-17"}), + ( + {"due": datetime.datetime(2023, 11, 17, 17, 0, 0, tzinfo=TEST_TIMEZONE)}, + {"due": f"2023-11-17T17:00:00{TEST_OFFSET}"}, + ), + ({"description": "Some description"}, {"description": "Some description"}), + ], +) +async def test_list_todo_items_extended_fields( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_item_data: dict[str, Any], +) -> None: + """Test listing items in a To-do list with extended fields.""" + + test_entity._attr_todo_items = [ + TodoItem( + **ITEM_1, + **item_data, + ), + ] + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "todo/item/list", "entity_id": "todo.entity1"} + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") == { + "items": [ + { + **ITEM_1, + **expected_item_data, + }, + ] + } + + result = await hass.services.async_call( + DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.entity1"}, + blocking=True, + return_response=True, + ) + assert result == { + "todo.entity1": { + "items": [ + { + **ITEM_1, + **expected_item_data, + }, + ] + } + }