Add To-do due date and description fields (#104128)

* Add To-do due date and description fields

* Fix due date schema

* Revert devcontainer change

* Split due date and due date time

* Add tests for config validation function

* Add timezone converstion tests

* Add local todo due date/time and description implementation

* Revert configuration

* Revert test changes

* Add comments for the todo item field description

* Rename function _validate_supported_features

* Fix issues in items factory

* Readability improvements

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Rename CONF to ATTR usages

* Simplify local time validator

* Rename TodoListEntityFeature fields for setting extended fields

* Remove duplicate validations

* Update subscribe test

* Fix local_todo tests

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-11-28 04:01:12 -08:00 committed by GitHub
parent 2a4a5d0a07
commit b8cc3349be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 554 additions and 29 deletions

View File

@ -90,6 +90,9 @@ class LocalTodoListEntity(TodoListEntity):
| TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.MOVE_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 _attr_should_poll = False
@ -115,6 +118,8 @@ class LocalTodoListEntity(TodoListEntity):
status=ICS_TODO_STATUS_MAP.get( status=ICS_TODO_STATUS_MAP.get(
item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION
), ),
due=item.due,
description=item.description,
) )
for item in self._calendar.todos for item in self._calendar.todos
] ]

View File

@ -1,6 +1,6 @@
"""The todo integration.""" """The todo integration."""
from collections.abc import Callable from collections.abc import Callable, Iterable
import dataclasses import dataclasses
import datetime import datetime
import logging import logging
@ -28,9 +28,18 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType 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__) _LOGGER = logging.getLogger(__name__)
@ -39,6 +48,65 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60)
ENTITY_ID_FORMAT = DOMAIN + ".{}" 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Todo entities.""" """Set up Todo entities."""
component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( component = hass.data[DOMAIN] = EntityComponent[TodoListEntity](
@ -53,9 +121,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service( component.async_register_entity_service(
"add_item", "add_item",
vol.All(
cv.make_entity_service_schema(
{ {
vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), vol.Required("item"): vol.All(cv.string, vol.Length(min=1)),
}, **TODO_ITEM_FIELD_SCHEMA,
}
),
*TODO_ITEM_FIELD_VALIDATIONS,
),
_async_add_todo_item, _async_add_todo_item,
required_features=[TodoListEntityFeature.CREATE_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( vol.Optional("status"): vol.In(
{TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, {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, _async_update_todo_item,
required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM],
@ -135,6 +213,20 @@ class TodoItem:
status: TodoItemStatus | None = None status: TodoItemStatus | None = None
"""A status or confirmation of the To-do item.""" """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): class TodoListEntity(Entity):
"""An entity that represents a To-do list.""" """An entity that represents a To-do list."""
@ -262,6 +354,19 @@ async def websocket_handle_subscribe_todo_items(
entity.async_update_listeners() 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( @websocket_api.websocket_command(
{ {
vol.Required("type"): "todo/item/list", vol.Required("type"): "todo/item/list",
@ -285,7 +390,13 @@ async def websocket_handle_todo_item_list(
items: list[TodoItem] = entity.todo_items or [] items: list[TodoItem] = entity.todo_items or []
connection.send_message( connection.send_message(
websocket_api.result_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: async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None:
"""Add an item to the To-do list.""" """Add an item to the To-do list."""
_validate_supported_features(entity.supported_features, call.data)
await entity.async_create_todo_item( 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: if not found:
raise ValueError(f"Unable to find To-do item '{item}'") raise ValueError(f"Unable to find To-do item '{item}'")
update_item = TodoItem( _validate_supported_features(entity.supported_features, call.data)
uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status")
)
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: 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 in the To-do list."""
return { return {
"items": [ "items": [
dataclasses.asdict(item) dataclasses.asdict(item, dict_factory=_api_items_factory)
for item in entity.todo_items or () for item in entity.todo_items or ()
if not (statuses := call.data.get("status")) or item.status in statuses if not (statuses := call.data.get("status")) or item.status in statuses
] ]

View File

@ -4,6 +4,11 @@ from enum import IntFlag, StrEnum
DOMAIN = "todo" DOMAIN = "todo"
ATTR_DUE = "due"
ATTR_DUE_DATE = "due_date"
ATTR_DUE_DATE_TIME = "due_date_time"
ATTR_DESCRIPTION = "description"
class TodoListEntityFeature(IntFlag): class TodoListEntityFeature(IntFlag):
"""Supported features of the To-do List entity.""" """Supported features of the To-do List entity."""
@ -12,6 +17,9 @@ class TodoListEntityFeature(IntFlag):
DELETE_TODO_ITEM = 2 DELETE_TODO_ITEM = 2
UPDATE_TODO_ITEM = 4 UPDATE_TODO_ITEM = 4
MOVE_TODO_ITEM = 8 MOVE_TODO_ITEM = 8
SET_DUE_DATE_ON_ITEM = 16
SET_DUE_DATETIME_ON_ITEM = 32
SET_DESCRIPTION_ON_ITEM = 64
class TodoItemStatus(StrEnum): class TodoItemStatus(StrEnum):

View File

@ -25,6 +25,18 @@ add_item:
example: "Submit income tax return" example: "Submit income tax return"
selector: selector:
text: 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: update_item:
target: target:
entity: entity:
@ -49,6 +61,18 @@ update_item:
options: options:
- needs_action - needs_action
- completed - 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: remove_item:
target: target:
entity: entity:

View File

@ -23,6 +23,18 @@
"item": { "item": {
"name": "Item name", "name": "Item name",
"description": "The name that represents the to-do item." "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": { "status": {
"name": "Set status", "name": "Set status",
"description": "A status or confirmation of the to-do item." "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."
} }
} }
}, },

View File

@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import textwrap import textwrap
from typing import Any
import pytest import pytest
@ -58,11 +59,31 @@ async def ws_move_item(
return move 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( async def test_add_item(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
setup_integration: None, setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_get_items: Callable[[], Awaitable[dict[str, str]]],
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
) -> None: ) -> None:
"""Test adding a todo item.""" """Test adding a todo item."""
@ -73,7 +94,7 @@ async def test_add_item(
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"add_item", "add_item",
{"item": "replace batteries"}, {"item": "replace batteries", **item_data},
target={"entity_id": TEST_ENTITY}, target={"entity_id": TEST_ENTITY},
blocking=True, blocking=True,
) )
@ -82,6 +103,8 @@ async def test_add_item(
assert len(items) == 1 assert len(items) == 1
assert items[0]["summary"] == "replace batteries" assert items[0]["summary"] == "replace batteries"
assert items[0]["status"] == "needs_action" assert items[0]["status"] == "needs_action"
for k, v in expected_item_data.items():
assert items[0][k] == v
assert "uid" in items[0] assert "uid" in items[0]
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
@ -89,16 +112,30 @@ async def test_add_item(
assert state.state == "1" 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( async def test_remove_item(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: None, setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_get_items: Callable[[], Awaitable[dict[str, str]]],
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
) -> None: ) -> None:
"""Test removing a todo item.""" """Test removing a todo item."""
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"add_item", "add_item",
{"item": "replace batteries"}, {"item": "replace batteries", **item_data},
target={"entity_id": TEST_ENTITY}, target={"entity_id": TEST_ENTITY},
blocking=True, blocking=True,
) )
@ -107,6 +144,8 @@ async def test_remove_item(
assert len(items) == 1 assert len(items) == 1
assert items[0]["summary"] == "replace batteries" assert items[0]["summary"] == "replace batteries"
assert items[0]["status"] == "needs_action" assert items[0]["status"] == "needs_action"
for k, v in expected_item_data.items():
assert items[0][k] == v
assert "uid" in items[0] assert "uid" in items[0]
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
@ -168,10 +207,30 @@ async def test_bulk_remove(
assert state.state == "0" 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( async def test_update_item(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: None, setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_get_items: Callable[[], Awaitable[dict[str, str]]],
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
expected_state: str,
) -> None: ) -> None:
"""Test updating a todo item.""" """Test updating a todo item."""
@ -199,21 +258,22 @@ async def test_update_item(
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"update_item", "update_item",
{"item": item["uid"], "status": "completed"}, {"item": item["uid"], **item_data},
target={"entity_id": TEST_ENTITY}, target={"entity_id": TEST_ENTITY},
blocking=True, blocking=True,
) )
# Verify item is marked as completed # Verify item is updated
items = await ws_get_items() items = await ws_get_items()
assert len(items) == 1 assert len(items) == 1
item = items[0] item = items[0]
assert item["summary"] == "soda" 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) state = hass.states.get(TEST_ENTITY)
assert state assert state
assert state.state == "0" assert state.state == expected_state
async def test_rename( async def test_rename(

View File

@ -1,8 +1,10 @@
"""Tests for the todo integration.""" """Tests for the todo integration."""
from collections.abc import Generator from collections.abc import Generator
import datetime
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import zoneinfo
import pytest import pytest
import voluptuous as vol import voluptuous as vol
@ -43,6 +45,8 @@ ITEM_2 = {
"summary": "Item #2", "summary": "Item #2",
"status": "completed", "status": "completed",
} }
TEST_TIMEZONE = zoneinfo.ZoneInfo("America/Regina")
TEST_OFFSET = "-06:00"
class MockFlow(ConfigFlow): 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( async def create_mock_platform(
hass: HomeAssistant, hass: HomeAssistant,
entities: list[TodoListEntity], entities: list[TodoListEntity],
@ -263,7 +273,7 @@ async def test_unsupported_websocket(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
) -> None: ) -> 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 = TodoListEntity()
entity1.entity_id = "todo.entity1" entity1.entity_id = "todo.entity1"
@ -327,23 +337,42 @@ async def test_add_item_service_raises(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("item_data", "expected_error"), ("item_data", "expected_exception", "expected_error"),
[ [
({}, "required key not provided"), ({}, vol.Invalid, "required key not provided"),
({"item": ""}, "length of value must be at least 1"), ({"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( async def test_add_item_service_invalid_input(
hass: HomeAssistant, hass: HomeAssistant,
test_entity: TodoListEntity, test_entity: TodoListEntity,
item_data: dict[str, Any], item_data: dict[str, Any],
expected_exception: str,
expected_error: str, expected_error: str,
) -> None: ) -> None:
"""Test invalid input to the add item service.""" """Test invalid input to the add item service."""
await create_mock_platform(hass, [test_entity]) 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( await hass.services.async_call(
DOMAIN, DOMAIN,
"add_item", "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( async def test_update_todo_item_service_by_id(
hass: HomeAssistant, hass: HomeAssistant,
test_entity: TodoListEntity, 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( async def test_remove_todo_item_service_by_id(
hass: HomeAssistant, hass: HomeAssistant,
test_entity: TodoListEntity, test_entity: TodoListEntity,
@ -971,8 +1152,20 @@ async def test_subscribe(
event_message = msg["event"] event_message = msg["event"]
assert event_message == { assert event_message == {
"items": [ "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 = [ test_entity._attr_todo_items = [
@ -985,9 +1178,27 @@ async def test_subscribe(
event_message = msg["event"] event_message = msg["event"]
assert event_message == { assert event_message == {
"items": [ "items": [
{"summary": "Item #1", "uid": "1", "status": "needs_action"}, {
{"summary": "Item #2", "uid": "2", "status": "completed"}, "summary": "Item #1",
{"summary": "Item #3", "uid": "3", "status": "needs_action"}, "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", "code": "invalid_entity_id",
"message": "To-do list entity not found: todo.unknown", "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,
},
]
}
}