mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
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:
parent
2a4a5d0a07
commit
b8cc3349be
@ -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
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
]
|
]
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user