From 2497798b5d54910faf3c95be4b1b3e6a46a943ed Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 27 Dec 2023 04:14:59 -0800 Subject: [PATCH] Allow clearing To-do list item extended fields (#106208) --- homeassistant/components/caldav/todo.py | 40 +++-- homeassistant/components/google_tasks/todo.py | 14 +- homeassistant/components/local_todo/todo.py | 32 ++-- .../components/shopping_list/todo.py | 11 +- homeassistant/components/todo/__init__.py | 32 ++-- homeassistant/components/todoist/todo.py | 35 ++-- tests/components/caldav/test_todo.py | 163 +++++++++++++++-- .../google_tasks/snapshots/test_todo.ambr | 43 ++++- tests/components/google_tasks/test_todo.py | 15 +- tests/components/local_todo/test_todo.py | 169 ++++++++++++++++-- tests/components/todo/test_init.py | 130 ++++++++++++-- tests/components/todoist/test_todo.py | 91 ++++++++-- 12 files changed, 627 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index b7089c3da65..90380805c31 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -90,20 +90,6 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: ) -def _to_ics_fields(item: TodoItem) -> dict[str, Any]: - """Convert a TodoItem to the set of add or update arguments.""" - item_data: dict[str, Any] = {} - if summary := item.summary: - item_data["summary"] = summary - if status := item.status: - item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") - if due := item.due: - item_data["due"] = due - if description := item.description: - item_data["description"] = description - return item_data - - class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" @@ -140,9 +126,18 @@ class WebDavTodoListEntity(TodoListEntity): async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + item_data["due"] = due + if description := item.description: + item_data["description"] = description try: await self.hass.async_add_executor_job( - partial(self._calendar.save_todo, **_to_ics_fields(item)), + partial(self._calendar.save_todo, **item_data), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -159,10 +154,17 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - updated_fields = _to_ics_fields(item) - if "due" in updated_fields: - todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] - vtodo.update(**updated_fields) + vtodo["SUMMARY"] = item.summary or "" + if status := item.status: + vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + todo.set_due(due) # type: ignore[attr-defined] + else: + vtodo.pop("DUE", None) + if description := item.description: + vtodo["DESCRIPTION"] = description + else: + vtodo.pop("DESCRIPTION", None) try: await self.hass.async_add_executor_job( partial( diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index cf3f84e9a0d..e83b0d39a30 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -29,18 +29,20 @@ TODO_STATUS_MAP = { TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} -def _convert_todo_item(item: TodoItem) -> dict[str, str]: +def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" - result: dict[str, str] = {} - if item.summary is not None: - result["title"] = item.summary + result: dict[str, str | None] = {} + result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + else: + result["status"] = TodoItemStatus.NEEDS_ACTION if (due := item.due) is not None: # due API field is a timestamp string, but with only date resolution result["due"] = dt_util.start_of_local_day(due).isoformat() - if (description := item.description) is not None: - result["notes"] = description + else: + result["due"] = None + result["notes"] = item.description return result diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index c5cf25a8c2e..99fb6dcebfa 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,13 +1,9 @@ """A Local To-do todo platform.""" -from collections.abc import Iterable -import dataclasses import logging -from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus @@ -59,26 +55,18 @@ async def async_setup_entry( async_add_entities([entity], True) -def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: - """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" - result: dict[str, str] = {} - for name, value in obj: - if value is None: - continue - if name == "status": - result[name] = ICS_TODO_STATUS_MAP_INV[value] - else: - result[name] = value - return result - - def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" - try: - return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) - except CalendarParseError as err: - _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) - raise HomeAssistantError("Error parsing todo input fields") from err + todo = Todo() + if item.uid: + todo.uid = item.uid + if item.summary: + todo.summary = item.summary + if item.status: + todo.status = ICS_TODO_STATUS_MAP_INV[item.status] + todo.due = item.due + todo.description = item.description + return todo class LocalTodoListEntity(TodoListEntity): diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index d89f376d662..2d959858067 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -1,6 +1,6 @@ """A shopping list todo platform.""" -from typing import Any, cast +from typing import cast from homeassistant.components.todo import ( TodoItem, @@ -55,11 +55,10 @@ class ShoppingTodoListEntity(TodoListEntity): async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" - data: dict[str, Any] = {} - if item.summary: - data["name"] = item.summary - if item.status: - data["complete"] = item.status == TodoItemStatus.COMPLETED + data = { + "name": item.summary, + "complete": item.status == TodoItemStatus.COMPLETED, + } try: await self._data.async_update(item.uid, data) except NoMatchingShoppingListItem as err: diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index d94233a20b9..0f39d38eb46 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -74,19 +74,19 @@ class TodoItemFieldDescription: TODO_ITEM_FIELDS = [ TodoItemFieldDescription( service_field=ATTR_DUE_DATE, - validation=cv.date, + validation=vol.Any(cv.date, None), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, ), TodoItemFieldDescription( service_field=ATTR_DUE_DATETIME, - validation=vol.All(cv.datetime, dt_util.as_local), + validation=vol.Any(vol.All(cv.datetime, dt_util.as_local), None), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, ), TodoItemFieldDescription( service_field=ATTR_DESCRIPTION, - validation=cv.string, + validation=vol.Any(cv.string, None), todo_item_field=ATTR_DESCRIPTION, required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, ), @@ -485,18 +485,22 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> _validate_supported_features(entity.supported_features, call.data) - 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 - }, - ) + # Perform a partial update on the existing entity based on the fields + # present in the update. This allows explicitly clearing any of the + # extended fields present and set to None. + updated_data = dataclasses.asdict(found) + if summary := call.data.get("rename"): + updated_data["summary"] = summary + if status := call.data.get("status"): + updated_data["status"] = status + updated_data.update( + { + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + } ) + await entity.async_update_todo_item(item=TodoItem(**updated_data)) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 6231a6878ae..5067e98642e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -34,19 +34,20 @@ async def async_setup_entry( def _task_api_data(item: TodoItem) -> dict[str, Any]: """Convert a TodoItem to the set of add or update arguments.""" - item_data: dict[str, Any] = {} - if summary := item.summary: - item_data["content"] = summary + item_data: dict[str, Any] = { + "content": item.summary, + # Description needs to be empty string to be cleared + "description": item.description or "", + } if due := item.due: if isinstance(due, datetime.datetime): - item_data["due"] = { - "date": due.date().isoformat(), - "datetime": due.isoformat(), - } + item_data["due_datetime"] = due.isoformat() else: - item_data["due"] = {"date": due.isoformat()} - if description := item.description: - item_data["description"] = description + item_data["due_date"] = due.isoformat() + else: + # Special flag "no date" clears the due date/datetime. + # See https://developer.todoist.com/rest/v2/#update-a-task for more. + item_data["due_string"] = "no date" return item_data @@ -128,10 +129,16 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if update_data := _task_api_data(item): await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: - if item.status == TodoItemStatus.COMPLETED: - await self.coordinator.api.close_task(task_id=uid) - else: - await self.coordinator.api.reopen_task(task_id=uid) + # Only update status if changed + for existing_item in self._attr_todo_items or (): + if existing_item.uid != item.uid: + continue + + if item.status != existing_item.status: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) await self.coordinator.async_refresh() async def async_delete_todo_items(self, uids: list[str]) -> None: diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index a90529297be..6056cac5fa9 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -69,6 +69,19 @@ STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" +TODO_ALL_FIELDS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +DESCRIPTION:Any kind will do +STATUS:NEEDS-ACTION +DUE:20171126 +END:VTODO +END:VCALENDAR""" + @pytest.fixture def platforms() -> list[Platform]: @@ -132,6 +145,18 @@ async def mock_add_to_hass( config_entry.add_to_hass(hass) +IGNORE_COMPONENTS = ["BEGIN", "END", "DTSTAMP", "PRODID", "UID", "VERSION"] + + +def compact_ics(ics: str) -> list[str]: + """Pull out parts of the rfc5545 content useful for assertions in tests.""" + return [ + line + for line in ics.split("\n") + if line and not any(filter(line.startswith, IGNORE_COMPONENTS)) + ] + + @pytest.mark.parametrize( ("todos", "expected_state"), [ @@ -292,45 +317,148 @@ async def test_add_item_failure( [ ( {"rename": "Swiss Cheese"}, - ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], "1", - {**RESULT_ITEM, "summary": "Swiss Cheese"}, + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"status": "needs_action"}, - ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - RESULT_ITEM, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"status": "completed"}, - ["SUMMARY:Cheese", "STATUS:COMPLETED"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:COMPLETED", + "SUMMARY:Cheese", + ], "0", - {**RESULT_ITEM, "status": "completed"}, + { + "uid": "2", + "summary": "Cheese", + "status": "completed", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"rename": "Swiss Cheese", "status": "needs_action"}, - ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], "1", - {**RESULT_ITEM, "summary": "Swiss Cheese"}, + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"due_date": "2023-11-18"}, - ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20231118", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "due": "2023-11-18"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18", + }, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], + [ + "DESCRIPTION:Any kind will do", + "DUE;TZID=America/Regina:20231118T083000", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18T08:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + [ + "DESCRIPTION:Any kind will do", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + }, ), ( {"description": "Make sure to get Swiss"}, - ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + [ + "DESCRIPTION:Make sure to get Swiss", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + "description": "Make sure to get Swiss", + }, + ), + ( + {"description": None}, + ["DUE;VALUE=DATE:20171126", "STATUS:NEEDS-ACTION", "SUMMARY:Cheese"], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + }, ), ], ids=[ @@ -340,7 +468,9 @@ async def test_add_item_failure( "rename_status", "due_date", "due_datetime", + "clear_due_date", "description", + "clear_description", ], ) async def test_update_item( @@ -355,7 +485,7 @@ async def test_update_item( ) -> None: """Test updating an item on the list.""" - item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + item = Todo(dav_client, None, TODO_ALL_FIELDS, calendar, "2") calendar.search = MagicMock(return_value=[item]) await config_entry.async_setup(hass) @@ -381,8 +511,7 @@ async def test_update_item( assert dav_client.put.call_args ics = dav_client.put.call_args.args[1] - for expected in expected_ics: - assert expected in ics + assert compact_ics(ics) == expected_ics state = hass.states.get(TEST_ENTITY) assert state diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index e30739551f3..af8dec6a182 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -6,7 +6,7 @@ ) # --- # name: test_create_todo_list_item[description].1 - '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": "6-pack"}' # --- # name: test_create_todo_list_item[due] tuple( @@ -15,7 +15,7 @@ ) # --- # name: test_create_todo_list_item[due].1 - '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' # --- # name: test_create_todo_list_item[summary] tuple( @@ -24,7 +24,7 @@ ) # --- # name: test_create_todo_list_item[summary].1 - '{"title": "Soda", "status": "needsAction"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_delete_todo_list_item[_handler] tuple( @@ -106,6 +106,24 @@ }), ]) # --- +# name: test_partial_update[clear_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[clear_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- +# name: test_partial_update[clear_due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[clear_due_date].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- # name: test_partial_update[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -113,7 +131,7 @@ ) # --- # name: test_partial_update[description].1 - '{"notes": "6-pack"}' + '{"title": "Water", "status": "needsAction", "due": null, "notes": "At least one gallon"}' # --- # name: test_partial_update[due_date] tuple( @@ -122,7 +140,16 @@ ) # --- # name: test_partial_update[due_date].1 - '{"due": "2023-11-18T00:00:00-08:00"}' + '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' +# --- +# name: test_partial_update[empty_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[empty_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": ""}' # --- # name: test_partial_update[rename] tuple( @@ -131,7 +158,7 @@ ) # --- # name: test_partial_update[rename].1 - '{"title": "Soda"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_partial_update_status[api_responses0] tuple( @@ -140,7 +167,7 @@ ) # --- # name: test_partial_update_status[api_responses0].1 - '{"status": "needsAction"}' + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_update_todo_list_item[api_responses0] tuple( @@ -149,5 +176,5 @@ ) # --- # name: test_update_todo_list_item[api_responses0].1 - '{"title": "Soda", "status": "completed"}' + '{"title": "Soda", "status": "completed", "due": null, "notes": null}' # --- diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index bf9a3f03df0..ee1b1e4cfd4 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -48,6 +48,7 @@ LIST_TASKS_RESPONSE_WATER = { "id": "some-task-id", "title": "Water", "status": "needsAction", + "description": "Any size is ok", "position": "00000000000000000001", }, ], @@ -516,9 +517,19 @@ async def test_update_todo_list_item_error( [ (UPDATE_API_RESPONSES, {"rename": "Soda"}), (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), - (UPDATE_API_RESPONSES, {"description": "6-pack"}), + (UPDATE_API_RESPONSES, {"due_date": None}), + (UPDATE_API_RESPONSES, {"description": "At least one gallon"}), + (UPDATE_API_RESPONSES, {"description": ""}), + (UPDATE_API_RESPONSES, {"description": None}), ], - ids=("rename", "due_date", "description"), + ids=( + "rename", + "due_date", + "clear_due_date", + "description", + "empty_description", + "clear_description", + ), ) async def test_partial_update( hass: HomeAssistant, diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 67d0703ca7c..22d8abade50 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -65,16 +65,27 @@ def set_time_zone(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Regina") +EXPECTED_ADD_ITEM = { + "status": "needs_action", + "summary": "replace batteries", +} + + @pytest.mark.parametrize( ("item_data", "expected_item_data"), [ - ({}, {}), - ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ({}, EXPECTED_ADD_ITEM), + ({"due_date": "2023-11-17"}, {**EXPECTED_ADD_ITEM, "due": "2023-11-17"}), ( {"due_datetime": "2023-11-17T11:30:00+00:00"}, - {"due": "2023-11-17T05:30:00-06:00"}, + {**EXPECTED_ADD_ITEM, "due": "2023-11-17T05:30:00-06:00"}, ), - ({"description": "Additional detail"}, {"description": "Additional detail"}), + ( + {"description": "Additional detail"}, + {**EXPECTED_ADD_ITEM, "description": "Additional detail"}, + ), + ({"description": ""}, {**EXPECTED_ADD_ITEM, "description": ""}), + ({"description": None}, EXPECTED_ADD_ITEM), ], ) async def test_add_item( @@ -101,11 +112,10 @@ async def test_add_item( items = await ws_get_items() 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] + item_data = items[0] + assert "uid" in item_data + del item_data["uid"] + assert item_data == expected_item_data state = hass.states.get(TEST_ENTITY) assert state @@ -207,19 +217,29 @@ async def test_bulk_remove( assert state.state == "0" +EXPECTED_UPDATE_ITEM = { + "status": "needs_action", + "summary": "soda", +} + + @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"), + ({"status": "completed"}, {**EXPECTED_UPDATE_ITEM, "status": "completed"}, "0"), + ( + {"due_date": "2023-11-17"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17"}, + "1", + ), ( {"due_datetime": "2023-11-17T11:30:00+00:00"}, - {"due": "2023-11-17T05:30:00-06:00"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17T05:30:00-06:00"}, "1", ), ( {"description": "Additional detail"}, - {"description": "Additional detail"}, + {**EXPECTED_UPDATE_ITEM, "description": "Additional detail"}, "1", ), ], @@ -246,6 +266,7 @@ async def test_update_item( # Fetch item items = await ws_get_items() assert len(items) == 1 + item = items[0] assert item["summary"] == "soda" assert item["status"] == "needs_action" @@ -254,7 +275,7 @@ async def test_update_item( assert state assert state.state == "1" - # Mark item completed + # Update item await hass.services.async_call( TODO_DOMAIN, "update_item", @@ -268,14 +289,130 @@ async def test_update_item( assert len(items) == 1 item = items[0] assert item["summary"] == "soda" - for k, v in expected_item_data.items(): - assert items[0][k] == v + assert "uid" in item + del item["uid"] + assert item == expected_item_data state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ( + {"status": "completed"}, + { + "summary": "soda", + "status": "completed", + "description": "Additional detail", + "due": "2024-01-01", + }, + ), + ( + {"due_date": "2024-01-02"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-02", + }, + ), + ( + {"due_date": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"due_datetime": "2024-01-01 10:30:00"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-01T10:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"description": "updated description"}, + { + "summary": "soda", + "status": "needs_action", + "due": "2024-01-01", + "description": "updated description", + }, + ), + ( + {"description": None}, + {"summary": "soda", "status": "needs_action", "due": "2024-01-01"}, + ), + ], + ids=[ + "status", + "due_date", + "clear_due_date", + "due_datetime", + "clear_due_datetime", + "description", + "clear_description", + ], +) +async def test_update_existing_field( + 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 updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda", "description": "Additional detail", "due_date": "2024-01-01"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + # Perform update + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], **item_data}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is updated + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert "uid" in item + del item["uid"] + assert item == expected_item_data + + async def test_rename( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 0edca7a7ef6..e1440b292ee 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -146,15 +146,19 @@ async def create_mock_platform( return config_entry +@pytest.fixture(name="test_entity_items") +def mock_test_entity_items() -> list[TodoItem]: + """Fixture that creates the items returned by the test entity.""" + return [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + + @pytest.fixture(name="test_entity") -def mock_test_entity() -> TodoListEntity: +def mock_test_entity(test_entity_items: list[TodoItem]) -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" - entity1 = MockTodoListEntity( - [ - TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), - ] - ) + entity1 = MockTodoListEntity(test_entity_items) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -504,7 +508,7 @@ async def test_update_todo_item_service_by_id_status_only( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary is None + assert item.summary == "Item #1" assert item.status == TodoItemStatus.COMPLETED @@ -530,7 +534,7 @@ async def test_update_todo_item_service_by_id_rename( assert item assert item.uid == "1" assert item.summary == "Updated item" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_raises( @@ -607,7 +611,7 @@ async def test_update_todo_item_service_by_summary_only_status( assert item assert item.uid == "1" assert item.summary == "Something else" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_by_summary_not_found( @@ -693,20 +697,32 @@ async def test_update_todo_item_field_unsupported( ( TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, {"due_date": "2023-11-13"}, - TodoItem(uid="1", due=datetime.date(2023, 11, 13)), + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.date(2023, 11, 13), + ), ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, 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"), + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + description="Submit revised draft", + ), ), ), ) @@ -736,6 +752,96 @@ async def test_update_todo_item_extended_fields( assert item == expected_update +@pytest.mark.parametrize( + ("test_entity_items", "update_data", "expected_update"), + ( + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": "Submit revised draft"}, + TodoItem(uid="1", summary="Summary", description="Submit revised draft"), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": ""}, + TodoItem(uid="1", summary="Summary", description=""), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": datetime.date(2024, 1, 2)}, + TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 2)), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_datetime": datetime.datetime(2024, 1, 1, 10, 0, 0)}, + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime( + 2024, 1, 1, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="America/Regina") + ), + ), + ), + ( + [ + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime(2024, 1, 1, 10, 0, 0), + ) + ], + {"due_datetime": None}, + TodoItem(uid="1", summary="Summary"), + ), + ), + ids=[ + "overwrite_description", + "overwrite_empty_description", + "clear_description", + "overwrite_due_date", + "clear_due_date", + "overwrite_due_date_with_time", + "clear_due_date_time", + ], +) +async def test_update_todo_item_extended_fields_overwrite_existing_values( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + ) + 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, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 1e94b52149c..5aa1e2af9de 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -80,7 +80,7 @@ async def test_todo_item_state( [], {}, [make_api_task(id="task-id-1", content="Soda", is_completed=False)], - {"content": "Soda"}, + {"content": "Soda", "due_string": "no date", "description": ""}, {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, ), ( @@ -94,7 +94,7 @@ async def test_todo_item_state( due=Due(is_recurring=False, date="2023-11-18", string="today"), ) ], - {"due": {"date": "2023-11-18"}}, + {"description": "", "due_date": "2023-11-18"}, { "uid": "task-id-1", "summary": "Soda", @@ -119,7 +119,8 @@ async def test_todo_item_state( ) ], { - "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + "description": "", + "due_datetime": "2023-11-18T06:30:00-06:00", }, { "uid": "task-id-1", @@ -139,7 +140,7 @@ async def test_todo_item_state( is_completed=False, ) ], - {"description": "6-pack"}, + {"description": "6-pack", "due_string": "no date"}, { "uid": "task-id-1", "summary": "Soda", @@ -264,11 +265,35 @@ async def test_update_todo_item_status( ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), [ ( - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="desc", + ) + ], {"rename": "Milk"}, - [make_api_task(id="task-id-1", content="Milk", is_completed=False)], - {"task_id": "task-id-1", "content": "Milk"}, - {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + [ + make_api_task( + id="task-id-1", + content="Milk", + is_completed=False, + description="desc", + ) + ], + { + "task_id": "task-id-1", + "content": "Milk", + "description": "desc", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Milk", + "status": "needs_action", + "description": "desc", + }, ), ( [make_api_task(id="task-id-1", content="Soda", is_completed=False)], @@ -281,7 +306,12 @@ async def test_update_todo_item_status( due=Due(is_recurring=False, date="2023-11-18", string="today"), ) ], - {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "task_id": "task-id-1", + "content": "Soda", + "due_date": "2023-11-18", + "description": "", + }, { "uid": "task-id-1", "summary": "Soda", @@ -307,7 +337,9 @@ async def test_update_todo_item_status( ], { "task_id": "task-id-1", - "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + "content": "Soda", + "due_datetime": "2023-11-18T06:30:00-06:00", + "description": "", }, { "uid": "task-id-1", @@ -327,7 +359,12 @@ async def test_update_todo_item_status( is_completed=False, ) ], - {"task_id": "task-id-1", "description": "6-pack"}, + { + "task_id": "task-id-1", + "content": "Soda", + "description": "6-pack", + "due_string": "no date", + }, { "uid": "task-id-1", "summary": "Soda", @@ -335,8 +372,38 @@ async def test_update_todo_item_status( "description": "6-pack", }, ), + ( + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": None}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="", + ) + ], + { + "task_id": "task-id-1", + "content": "Soda", + "description": "", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + }, + ), ], - ids=["rename", "due_date", "due_datetime", "description"], + ids=["rename", "due_date", "due_datetime", "description", "clear_description"], ) async def test_update_todo_items( hass: HomeAssistant,