Skip to content
Snippets Groups Projects
Unverified Commit 476e867f authored by Allen Porter's avatar Allen Porter Committed by GitHub
Browse files

Add a Local To-do component (#102627)


Co-authored-by: default avatarRobert Resch <robert@resch.dev>
Co-authored-by: default avatarMartin Hjelmare <marhje52@gmail.com>
parent 35d18a9a
No related branches found
No related tags found
No related merge requests found
Showing
with 962 additions and 0 deletions
......@@ -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
......
"""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)
"""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
)
"""Constants for the Local To-do integration."""
DOMAIN = "local_todo"
CONF_TODO_LIST_NAME = "todo_list_name"
CONF_STORAGE_KEY = "storage_key"
{
"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"]
}
"""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)
{
"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%]"
}
}
}
"""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",
......
......@@ -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",
......
"""Tests for the local_todo integration."""
"""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()
"""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"
"""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
"""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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment