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
20 changed files with 962 additions and 0 deletions
|
@ -204,6 +204,7 @@ homeassistant.components.light.*
|
||||||
homeassistant.components.litejet.*
|
homeassistant.components.litejet.*
|
||||||
homeassistant.components.litterrobot.*
|
homeassistant.components.litterrobot.*
|
||||||
homeassistant.components.local_ip.*
|
homeassistant.components.local_ip.*
|
||||||
|
homeassistant.components.local_todo.*
|
||||||
homeassistant.components.lock.*
|
homeassistant.components.lock.*
|
||||||
homeassistant.components.logbook.*
|
homeassistant.components.logbook.*
|
||||||
homeassistant.components.logger.*
|
homeassistant.components.logger.*
|
||||||
|
|
|
@ -710,6 +710,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/local_calendar/ @allenporter
|
/tests/components/local_calendar/ @allenporter
|
||||||
/homeassistant/components/local_ip/ @issacg
|
/homeassistant/components/local_ip/ @issacg
|
||||||
/tests/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
|
/homeassistant/components/lock/ @home-assistant/core
|
||||||
/tests/components/lock/ @home-assistant/core
|
/tests/components/lock/ @home-assistant/core
|
||||||
/homeassistant/components/logbook/ @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",
|
"livisi",
|
||||||
"local_calendar",
|
"local_calendar",
|
||||||
"local_ip",
|
"local_ip",
|
||||||
|
"local_todo",
|
||||||
"locative",
|
"locative",
|
||||||
"logi_circle",
|
"logi_circle",
|
||||||
"lookin",
|
"lookin",
|
||||||
|
|
|
@ -3111,6 +3111,11 @@
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"local_todo": {
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
},
|
||||||
"locative": {
|
"locative": {
|
||||||
"name": "Locative",
|
"name": "Locative",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
@ -6831,6 +6836,7 @@
|
||||||
"islamic_prayer_times",
|
"islamic_prayer_times",
|
||||||
"local_calendar",
|
"local_calendar",
|
||||||
"local_ip",
|
"local_ip",
|
||||||
|
"local_todo",
|
||||||
"min_max",
|
"min_max",
|
||||||
"mobile_app",
|
"mobile_app",
|
||||||
"moehlenhoff_alpha2",
|
"moehlenhoff_alpha2",
|
||||||
|
|
10
mypy.ini
10
mypy.ini
|
@ -1801,6 +1801,16 @@ disallow_untyped_defs = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.lock.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
|
|
@ -1046,6 +1046,7 @@ ibeacon-ble==1.0.1
|
||||||
ibmiotf==0.3.4
|
ibmiotf==0.3.4
|
||||||
|
|
||||||
# homeassistant.components.local_calendar
|
# homeassistant.components.local_calendar
|
||||||
|
# homeassistant.components.local_todo
|
||||||
ical==5.1.0
|
ical==5.1.0
|
||||||
|
|
||||||
# homeassistant.components.ping
|
# homeassistant.components.ping
|
||||||
|
|
|
@ -826,6 +826,7 @@ iaqualink==0.5.0
|
||||||
ibeacon-ble==1.0.1
|
ibeacon-ble==1.0.1
|
||||||
|
|
||||||
# homeassistant.components.local_calendar
|
# homeassistant.components.local_calendar
|
||||||
|
# homeassistant.components.local_todo
|
||||||
ical==5.1.0
|
ical==5.1.0
|
||||||
|
|
||||||
# homeassistant.components.ping
|
# homeassistant.components.ping
|
||||||
|
|
|
@ -37,6 +37,7 @@ ALLOW_NAME_TRANSLATION = {
|
||||||
"islamic_prayer_times",
|
"islamic_prayer_times",
|
||||||
"local_calendar",
|
"local_calendar",
|
||||||
"local_ip",
|
"local_ip",
|
||||||
|
"local_todo",
|
||||||
"nmap_tracker",
|
"nmap_tracker",
|
||||||
"rpi_power",
|
"rpi_power",
|
||||||
"waze_travel_time",
|
"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…
Add table
Add a link
Reference in a new issue