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