Allow clearing To-do list item extended fields (#106208)

pull/106475/head
Allen Porter 2023-12-27 04:14:59 -08:00 committed by GitHub
parent c51ac7171a
commit 2497798b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 627 additions and 148 deletions

View File

@ -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(

View File

@ -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

View File

@ -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):

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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}'
# ---

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,