mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 17:27:52 +00:00
Add a Local To-do component (#102627)
Co-authored-by: Robert Resch <robert@resch.dev> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
35d18a9a3e
commit
476e867fe8
@ -204,6 +204,7 @@ homeassistant.components.light.*
|
||||
homeassistant.components.litejet.*
|
||||
homeassistant.components.litterrobot.*
|
||||
homeassistant.components.local_ip.*
|
||||
homeassistant.components.local_todo.*
|
||||
homeassistant.components.lock.*
|
||||
homeassistant.components.logbook.*
|
||||
homeassistant.components.logger.*
|
||||
|
@ -710,6 +710,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/local_calendar/ @allenporter
|
||||
/homeassistant/components/local_ip/ @issacg
|
||||
/tests/components/local_ip/ @issacg
|
||||
/homeassistant/components/local_todo/ @allenporter
|
||||
/tests/components/local_todo/ @allenporter
|
||||
/homeassistant/components/lock/ @home-assistant/core
|
||||
/tests/components/lock/ @home-assistant/core
|
||||
/homeassistant/components/logbook/ @home-assistant/core
|
||||
|
55
homeassistant/components/local_todo/__init__.py
Normal file
55
homeassistant/components/local_todo/__init__.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""The Local To-do integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN
|
||||
from .store import LocalTodoListStore
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.TODO]
|
||||
|
||||
STORAGE_PATH = ".storage/local_todo.{key}.ics"
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Local To-do from a config entry."""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY])))
|
||||
store = LocalTodoListStore(hass, path)
|
||||
try:
|
||||
await store.async_load()
|
||||
except OSError as err:
|
||||
raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = store
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle removal of an entry."""
|
||||
key = slugify(entry.data[CONF_TODO_LIST_NAME])
|
||||
path = Path(hass.config.path(STORAGE_PATH.format(key=key)))
|
||||
|
||||
def unlink(path: Path) -> None:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
await hass.async_add_executor_job(unlink, path)
|
44
homeassistant/components/local_todo/config_flow.py
Normal file
44
homeassistant/components/local_todo/config_flow.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Config flow for Local To-do integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TODO_LIST_NAME): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Local To-do."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
key = slugify(user_input[CONF_TODO_LIST_NAME])
|
||||
self._async_abort_entries_match({CONF_STORAGE_KEY: key})
|
||||
user_input[CONF_STORAGE_KEY] = key
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_TODO_LIST_NAME], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
6
homeassistant/components/local_todo/const.py
Normal file
6
homeassistant/components/local_todo/const.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Constants for the Local To-do integration."""
|
||||
|
||||
DOMAIN = "local_todo"
|
||||
|
||||
CONF_TODO_LIST_NAME = "todo_list_name"
|
||||
CONF_STORAGE_KEY = "storage_key"
|
9
homeassistant/components/local_todo/manifest.json
Normal file
9
homeassistant/components/local_todo/manifest.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "local_todo",
|
||||
"name": "Local To-do",
|
||||
"codeowners": ["@allenporter"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==5.1.0"]
|
||||
}
|
36
homeassistant/components/local_todo/store.py
Normal file
36
homeassistant/components/local_todo/store.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""Local storage for the Local To-do integration."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
class LocalTodoListStore:
|
||||
"""Local storage for a single To-do list."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, path: Path) -> None:
|
||||
"""Initialize LocalTodoListStore."""
|
||||
self._hass = hass
|
||||
self._path = path
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_load(self) -> str:
|
||||
"""Load the calendar from disk."""
|
||||
async with self._lock:
|
||||
return await self._hass.async_add_executor_job(self._load)
|
||||
|
||||
def _load(self) -> str:
|
||||
"""Load the calendar from disk."""
|
||||
if not self._path.exists():
|
||||
return ""
|
||||
return self._path.read_text()
|
||||
|
||||
async def async_store(self, ics_content: str) -> None:
|
||||
"""Persist the calendar to storage."""
|
||||
async with self._lock:
|
||||
await self._hass.async_add_executor_job(self._store, ics_content)
|
||||
|
||||
def _store(self, ics_content: str) -> None:
|
||||
"""Persist the calendar to storage."""
|
||||
self._path.write_text(ics_content)
|
16
homeassistant/components/local_todo/strings.json
Normal file
16
homeassistant/components/local_todo/strings.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"title": "Local To-do",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Please choose a name for your new To-do list",
|
||||
"data": {
|
||||
"todo_list_name": "To-do list name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
}
|
||||
}
|
||||
}
|
162
homeassistant/components/local_todo/todo.py
Normal file
162
homeassistant/components/local_todo/todo.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""A Local To-do todo platform."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ical.calendar import Calendar
|
||||
from ical.calendar_stream import IcsCalendarStream
|
||||
from ical.store import TodoStore
|
||||
from ical.todo import Todo, TodoStatus
|
||||
from pydantic import ValidationError
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
TodoItemStatus,
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import CONF_TODO_LIST_NAME, DOMAIN
|
||||
from .store import LocalTodoListStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PRODID = "-//homeassistant.io//local_todo 1.0//EN"
|
||||
|
||||
ICS_TODO_STATUS_MAP = {
|
||||
TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION,
|
||||
TodoStatus.NEEDS_ACTION: TodoItemStatus.NEEDS_ACTION,
|
||||
TodoStatus.COMPLETED: TodoItemStatus.COMPLETED,
|
||||
TodoStatus.CANCELLED: TodoItemStatus.COMPLETED,
|
||||
}
|
||||
ICS_TODO_STATUS_MAP_INV = {
|
||||
TodoItemStatus.COMPLETED: TodoStatus.COMPLETED,
|
||||
TodoItemStatus.NEEDS_ACTION: TodoStatus.NEEDS_ACTION,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the local_todo todo platform."""
|
||||
|
||||
store = hass.data[DOMAIN][config_entry.entry_id]
|
||||
ics = await store.async_load()
|
||||
calendar = IcsCalendarStream.calendar_from_ics(ics)
|
||||
calendar.prodid = PRODID
|
||||
|
||||
name = config_entry.data[CONF_TODO_LIST_NAME]
|
||||
entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id)
|
||||
async_add_entities([entity], True)
|
||||
|
||||
|
||||
def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]:
|
||||
"""Convert TodoItem dataclass items to dictionary of attributes for ical consumption."""
|
||||
result: dict[str, str] = {}
|
||||
for name, value in obj:
|
||||
if name == "status":
|
||||
result[name] = ICS_TODO_STATUS_MAP_INV[value]
|
||||
elif value is not None:
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
def _convert_item(item: TodoItem) -> Todo:
|
||||
"""Convert a HomeAssistant TodoItem to an ical Todo."""
|
||||
try:
|
||||
return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory))
|
||||
except ValidationError as err:
|
||||
_LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err)
|
||||
raise HomeAssistantError("Error parsing todo input fields") from err
|
||||
|
||||
|
||||
class LocalTodoListEntity(TodoListEntity):
|
||||
"""A To-do List representation of the Shopping List."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = (
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
| TodoListEntityFeature.UPDATE_TODO_ITEM
|
||||
| TodoListEntityFeature.MOVE_TODO_ITEM
|
||||
)
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: LocalTodoListStore,
|
||||
calendar: Calendar,
|
||||
name: str,
|
||||
unique_id: str,
|
||||
) -> None:
|
||||
"""Initialize LocalTodoListEntity."""
|
||||
self._store = store
|
||||
self._calendar = calendar
|
||||
self._attr_name = name.capitalize()
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity state based on the local To-do items."""
|
||||
self._attr_todo_items = [
|
||||
TodoItem(
|
||||
uid=item.uid,
|
||||
summary=item.summary or "",
|
||||
status=ICS_TODO_STATUS_MAP.get(
|
||||
item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION
|
||||
),
|
||||
)
|
||||
for item in self._calendar.todos
|
||||
]
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add an item to the To-do list."""
|
||||
todo = _convert_item(item)
|
||||
TodoStore(self._calendar).add(todo)
|
||||
await self._async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update an item to the To-do list."""
|
||||
todo = _convert_item(item)
|
||||
TodoStore(self._calendar).edit(todo.uid, todo)
|
||||
await self._async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Add an item to the To-do list."""
|
||||
store = TodoStore(self._calendar)
|
||||
for uid in uids:
|
||||
store.delete(uid)
|
||||
await self._async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_move_todo_item(self, uid: str, pos: int) -> None:
|
||||
"""Re-order an item to the To-do list."""
|
||||
todos = self._calendar.todos
|
||||
found_item: Todo | None = None
|
||||
for idx, itm in enumerate(todos):
|
||||
if itm.uid == uid:
|
||||
found_item = itm
|
||||
todos.pop(idx)
|
||||
break
|
||||
if found_item is None:
|
||||
raise HomeAssistantError(
|
||||
f"Item '{uid}' not found in todo list {self.entity_id}"
|
||||
)
|
||||
todos.insert(pos, found_item)
|
||||
await self._async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Persist the todo list to disk."""
|
||||
content = IcsCalendarStream.calendar_to_ics(self._calendar)
|
||||
await self._store.async_store(content)
|
@ -264,6 +264,7 @@ FLOWS = {
|
||||
"livisi",
|
||||
"local_calendar",
|
||||
"local_ip",
|
||||
"local_todo",
|
||||
"locative",
|
||||
"logi_circle",
|
||||
"lookin",
|
||||
|
@ -3111,6 +3111,11 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"local_todo": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"locative": {
|
||||
"name": "Locative",
|
||||
"integration_type": "hub",
|
||||
@ -6831,6 +6836,7 @@
|
||||
"islamic_prayer_times",
|
||||
"local_calendar",
|
||||
"local_ip",
|
||||
"local_todo",
|
||||
"min_max",
|
||||
"mobile_app",
|
||||
"moehlenhoff_alpha2",
|
||||
|
10
mypy.ini
10
mypy.ini
@ -1801,6 +1801,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.local_todo.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.lock.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
@ -1046,6 +1046,7 @@ ibeacon-ble==1.0.1
|
||||
ibmiotf==0.3.4
|
||||
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
ical==5.1.0
|
||||
|
||||
# homeassistant.components.ping
|
||||
|
@ -826,6 +826,7 @@ iaqualink==0.5.0
|
||||
ibeacon-ble==1.0.1
|
||||
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
ical==5.1.0
|
||||
|
||||
# homeassistant.components.ping
|
||||
|
@ -37,6 +37,7 @@ ALLOW_NAME_TRANSLATION = {
|
||||
"islamic_prayer_times",
|
||||
"local_calendar",
|
||||
"local_ip",
|
||||
"local_todo",
|
||||
"nmap_tracker",
|
||||
"rpi_power",
|
||||
"waze_travel_time",
|
||||
|
1
tests/components/local_todo/__init__.py
Normal file
1
tests/components/local_todo/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the local_todo integration."""
|
104
tests/components/local_todo/conftest.py
Normal file
104
tests/components/local_todo/conftest.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Common fixtures for the local_todo tests."""
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.local_todo import LocalTodoListStore
|
||||
from homeassistant.components.local_todo.const import (
|
||||
CONF_STORAGE_KEY,
|
||||
CONF_TODO_LIST_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TODO_NAME = "My Tasks"
|
||||
FRIENDLY_NAME = "My tasks"
|
||||
STORAGE_KEY = "my_tasks"
|
||||
TEST_ENTITY = "todo.my_tasks"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.local_todo.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
class FakeStore(LocalTodoListStore):
|
||||
"""Mock storage implementation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
path: Path,
|
||||
ics_content: str | None,
|
||||
read_side_effect: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialize FakeStore."""
|
||||
mock_path = self._mock_path = Mock()
|
||||
mock_path.exists = self._mock_exists
|
||||
mock_path.read_text = Mock()
|
||||
mock_path.read_text.return_value = ics_content
|
||||
mock_path.read_text.side_effect = read_side_effect
|
||||
mock_path.write_text = self._mock_write_text
|
||||
|
||||
super().__init__(hass, mock_path)
|
||||
|
||||
def _mock_exists(self) -> bool:
|
||||
return self._mock_path.read_text.return_value is not None
|
||||
|
||||
def _mock_write_text(self, content: str) -> None:
|
||||
self._mock_path.read_text.return_value = content
|
||||
|
||||
|
||||
@pytest.fixture(name="ics_content")
|
||||
def mock_ics_content() -> str | None:
|
||||
"""Fixture to set .ics file content."""
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture(name="store_read_side_effect")
|
||||
def mock_store_read_side_effect() -> Any | None:
|
||||
"""Fixture to raise errors from the FakeStore."""
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(name="store", autouse=True)
|
||||
def mock_store(
|
||||
ics_content: str, store_read_side_effect: Any | None
|
||||
) -> Generator[None, None, None]:
|
||||
"""Fixture that sets up a fake local storage object."""
|
||||
|
||||
stores: dict[Path, FakeStore] = {}
|
||||
|
||||
def new_store(hass: HomeAssistant, path: Path) -> FakeStore:
|
||||
if path not in stores:
|
||||
stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect)
|
||||
return stores[path]
|
||||
|
||||
with patch("homeassistant.components.local_todo.LocalTodoListStore", new=new_store):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Fixture for mock configuration entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_STORAGE_KEY: STORAGE_KEY, CONF_TODO_LIST_NAME: TODO_NAME},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_integration")
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
64
tests/components/local_todo/test_config_flow.py
Normal file
64
tests/components/local_todo/test_config_flow.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Test the local_todo config flow."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.local_todo.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import STORAGE_KEY, TODO_NAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"todo_list_name": TODO_NAME,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == TODO_NAME
|
||||
assert result2["data"] == {
|
||||
"todo_list_name": TODO_NAME,
|
||||
"storage_key": STORAGE_KEY,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_duplicate_todo_list_name(
|
||||
hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test two todo-lists cannot be added with the same name."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
# Pick a name that has the same slugify value as an existing config entry
|
||||
"todo_list_name": "my tasks",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == FlowResultType.ABORT
|
||||
assert result2["reason"] == "already_configured"
|
60
tests/components/local_todo/test_init.py
Normal file
60
tests/components/local_todo/test_init.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Tests for init platform of local_todo."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import TEST_ENTITY
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload(
|
||||
hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test loading and unloading a config entry."""
|
||||
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
|
||||
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "unavailable"
|
||||
|
||||
|
||||
async def test_remove_config_entry(
|
||||
hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test removing a config entry."""
|
||||
|
||||
with patch("homeassistant.components.local_todo.Path.unlink") as unlink_mock:
|
||||
assert await hass.config_entries.async_remove(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
unlink_mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("store_read_side_effect"),
|
||||
[
|
||||
(OSError("read error")),
|
||||
],
|
||||
)
|
||||
async def test_load_failure(
|
||||
hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test failures loading the todo store."""
|
||||
|
||||
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert not state
|
382
tests/components/local_todo/test_todo.py
Normal file
382
tests/components/local_todo/test_todo.py
Normal file
@ -0,0 +1,382 @@
|
||||
"""Tests for todo platform of local_todo."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import TEST_ENTITY
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_req_id() -> Callable[[], int]:
|
||||
"""Fixture for incremental websocket requests."""
|
||||
|
||||
id = 0
|
||||
|
||||
def next() -> int:
|
||||
nonlocal id
|
||||
id += 1
|
||||
return id
|
||||
|
||||
return next
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ws_get_items(
|
||||
hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int]
|
||||
) -> Callable[[], Awaitable[dict[str, str]]]:
|
||||
"""Fixture to fetch items from the todo websocket."""
|
||||
|
||||
async def get() -> list[dict[str, str]]:
|
||||
# Fetch items using To-do platform
|
||||
client = await hass_ws_client()
|
||||
id = ws_req_id()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": id,
|
||||
"type": "todo/item/list",
|
||||
"entity_id": TEST_ENTITY,
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp.get("id") == id
|
||||
assert resp.get("success")
|
||||
return resp.get("result", {}).get("items", [])
|
||||
|
||||
return get
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ws_move_item(
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
ws_req_id: Callable[[], int],
|
||||
) -> Callable[[str, str | None], Awaitable[None]]:
|
||||
"""Fixture to move an item in the todo list."""
|
||||
|
||||
async def move(uid: str, pos: int) -> None:
|
||||
# Fetch items using To-do platform
|
||||
client = await hass_ws_client()
|
||||
id = ws_req_id()
|
||||
data = {
|
||||
"id": id,
|
||||
"type": "todo/item/move",
|
||||
"entity_id": TEST_ENTITY,
|
||||
"uid": uid,
|
||||
"pos": pos,
|
||||
}
|
||||
await client.send_json(data)
|
||||
resp = await client.receive_json()
|
||||
assert resp.get("id") == id
|
||||
assert resp.get("success")
|
||||
|
||||
return move
|
||||
|
||||
|
||||
async def test_create_item(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
setup_integration: None,
|
||||
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
|
||||
) -> None:
|
||||
"""Test creating a todo item."""
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"create_item",
|
||||
{"summary": "replace batteries"},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 1
|
||||
assert items[0]["summary"] == "replace batteries"
|
||||
assert items[0]["status"] == "needs_action"
|
||||
assert "uid" in items[0]
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "1"
|
||||
|
||||
|
||||
async def test_delete_item(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: None,
|
||||
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
|
||||
) -> None:
|
||||
"""Test deleting a todo item."""
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"create_item",
|
||||
{"summary": "replace batteries"},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 1
|
||||
assert items[0]["summary"] == "replace batteries"
|
||||
assert items[0]["status"] == "needs_action"
|
||||
assert "uid" in items[0]
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "1"
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"delete_item",
|
||||
{"uid": [items[0]["uid"]]},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 0
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
|
||||
|
||||
async def test_bulk_delete(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: None,
|
||||
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
|
||||
) -> None:
|
||||
"""Test deleting multiple todo items."""
|
||||
for i in range(0, 5):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"create_item",
|
||||
{"summary": f"soda #{i}"},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 5
|
||||
uids = [item["uid"] for item in items]
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "5"
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"delete_item",
|
||||
{"uid": uids},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 0
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
|
||||
|
||||
async def test_update_item(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: None,
|
||||
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
|
||||
) -> None:
|
||||
"""Test updating a todo item."""
|
||||
|
||||
# Create new item
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"create_item",
|
||||
{"summary": "soda"},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Fetch item
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item["summary"] == "soda"
|
||||
assert item["status"] == "needs_action"
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "1"
|
||||
|
||||
# Mark item completed
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"update_item",
|
||||
{"uid": item["uid"], "status": "completed"},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify item is marked as completed
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item["summary"] == "soda"
|
||||
assert item["status"] == "completed"
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("src_idx", "pos", "expected_items"),
|
||||
[
|
||||
# Move any item to the front of the list
|
||||
(0, 0, ["item 1", "item 2", "item 3", "item 4"]),
|
||||
(1, 0, ["item 2", "item 1", "item 3", "item 4"]),
|
||||
(2, 0, ["item 3", "item 1", "item 2", "item 4"]),
|
||||
(3, 0, ["item 4", "item 1", "item 2", "item 3"]),
|
||||
# Move items right
|
||||
(0, 1, ["item 2", "item 1", "item 3", "item 4"]),
|
||||
(0, 2, ["item 2", "item 3", "item 1", "item 4"]),
|
||||
(0, 3, ["item 2", "item 3", "item 4", "item 1"]),
|
||||
(1, 2, ["item 1", "item 3", "item 2", "item 4"]),
|
||||
(1, 3, ["item 1", "item 3", "item 4", "item 2"]),
|
||||
(1, 4, ["item 1", "item 3", "item 4", "item 2"]),
|
||||
(1, 5, ["item 1", "item 3", "item 4", "item 2"]),
|
||||
# Move items left
|
||||
(2, 1, ["item 1", "item 3", "item 2", "item 4"]),
|
||||
(3, 1, ["item 1", "item 4", "item 2", "item 3"]),
|
||||
(3, 2, ["item 1", "item 2", "item 4", "item 3"]),
|
||||
# No-ops
|
||||
(1, 1, ["item 1", "item 2", "item 3", "item 4"]),
|
||||
(2, 2, ["item 1", "item 2", "item 3", "item 4"]),
|
||||
(3, 3, ["item 1", "item 2", "item 3", "item 4"]),
|
||||
(3, 4, ["item 1", "item 2", "item 3", "item 4"]),
|
||||
],
|
||||
)
|
||||
async def test_move_item(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: None,
|
||||
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
|
||||
ws_move_item: Callable[[str, str | None], Awaitable[None]],
|
||||
src_idx: int,
|
||||
pos: int,
|
||||
expected_items: list[str],
|
||||
) -> None:
|
||||
"""Test moving a todo item within the list."""
|
||||
for i in range(1, 5):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
"create_item",
|
||||
{"summary": f"item {i}"},
|
||||
target={"entity_id": TEST_ENTITY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 4
|
||||
uids = [item["uid"] for item in items]
|
||||
summaries = [item["summary"] for item in items]
|
||||
assert summaries == ["item 1", "item 2", "item 3", "item 4"]
|
||||
|
||||
# Prepare items for moving
|
||||
await ws_move_item(uids[src_idx], pos)
|
||||
|
||||
items = await ws_get_items()
|
||||
assert len(items) == 4
|
||||
summaries = [item["summary"] for item in items]
|
||||
assert summaries == expected_items
|
||||
|
||||
|
||||
async def test_move_item_unknown(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: None,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test moving a todo item that does not exist."""
|
||||
|
||||
# Prepare items for moving
|
||||
client = await hass_ws_client()
|
||||
data = {
|
||||
"id": 1,
|
||||
"type": "todo/item/move",
|
||||
"entity_id": TEST_ENTITY,
|
||||
"uid": "unknown",
|
||||
"pos": 0,
|
||||
}
|
||||
await client.send_json(data)
|
||||
resp = await client.receive_json()
|
||||
assert resp.get("id") == 1
|
||||
assert not resp.get("success")
|
||||
assert resp.get("error", {}).get("code") == "failed"
|
||||
assert "not found in todo list" in resp["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ics_content", "expected_state"),
|
||||
[
|
||||
("", "0"),
|
||||
(None, "0"),
|
||||
(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//homeassistant.io//local_todo 1.0//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTODO
|
||||
DTSTAMP:20231024T014011
|
||||
UID:077cb7f2-6c89-11ee-b2a9-0242ac110002
|
||||
CREATED:20231017T010348
|
||||
LAST-MODIFIED:20231024T014011
|
||||
SEQUENCE:1
|
||||
STATUS:COMPLETED
|
||||
SUMMARY:Complete Task
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
"""
|
||||
),
|
||||
"0",
|
||||
),
|
||||
(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//homeassistant.io//local_todo 1.0//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTODO
|
||||
DTSTAMP:20231024T014011
|
||||
UID:077cb7f2-6c89-11ee-b2a9-0242ac110002
|
||||
CREATED:20231017T010348
|
||||
LAST-MODIFIED:20231024T014011
|
||||
SEQUENCE:1
|
||||
STATUS:NEEDS-ACTION
|
||||
SUMMARY:Incomplete Task
|
||||
END:VTODO
|
||||
END:VCALENDAR
|
||||
"""
|
||||
),
|
||||
"1",
|
||||
),
|
||||
],
|
||||
ids=("empty", "not_exists", "completed", "needs_action"),
|
||||
)
|
||||
async def test_parse_existing_ics(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
setup_integration: None,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Test parsing ics content."""
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == expected_state
|
Loading…
x
Reference in New Issue
Block a user