mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
Add Mealie parser to to-do lists (#183099)
This commit is contained in:
@@ -3,15 +3,35 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, override
|
||||
|
||||
from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError
|
||||
from aiomealie import (
|
||||
MealieAuthenticationError,
|
||||
MealieClient,
|
||||
MealieConnectionError,
|
||||
RegisteredParser,
|
||||
)
|
||||
import probatio
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_PORT, CONF_VERIFY_SSL
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER, MIN_REQUIRED_MEALIE_VERSION
|
||||
from .const import (
|
||||
CONF_PARSE_TODO_EDIT,
|
||||
CONF_PARSE_TODO_NEW,
|
||||
CONF_PARSER,
|
||||
DEFAULT_PARSER,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_REQUIRED_MEALIE_VERSION,
|
||||
)
|
||||
from .coordinator import MealieConfigEntry
|
||||
from .utils import create_version
|
||||
|
||||
USER_SCHEMA = probatio.Schema(
|
||||
@@ -44,6 +64,15 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
verify_ssl: bool = True
|
||||
_hassio_discovery: dict[str, Any] | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@override
|
||||
def async_get_options_flow(
|
||||
config_entry: MealieConfigEntry,
|
||||
) -> MealieOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return MealieOptionsFlowHandler()
|
||||
|
||||
async def check_connection(
|
||||
self, api_token: str
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
@@ -215,3 +244,38 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={"addon": self._hassio_discovery["addon"]},
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
|
||||
class MealieOptionsFlowHandler(OptionsFlowWithReload):
|
||||
"""Handle Mealie options."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the Mealie options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
options = {
|
||||
probatio.Required(
|
||||
CONF_PARSE_TODO_NEW,
|
||||
default=self.config_entry.options.get(CONF_PARSE_TODO_NEW, True),
|
||||
): bool,
|
||||
probatio.Required(
|
||||
CONF_PARSE_TODO_EDIT,
|
||||
default=self.config_entry.options.get(CONF_PARSE_TODO_EDIT, True),
|
||||
): bool,
|
||||
probatio.Required(
|
||||
CONF_PARSER,
|
||||
default=self.config_entry.options.get(CONF_PARSER, DEFAULT_PARSER),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[parser.value for parser in RegisteredParser],
|
||||
translation_key=CONF_PARSER,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=probatio.Schema(options)
|
||||
)
|
||||
|
||||
@@ -20,4 +20,10 @@ ATTR_NOTE_TEXT = "note_text"
|
||||
ATTR_SEARCH_TERMS = "search_terms"
|
||||
ATTR_RESULT_LIMIT = "result_limit"
|
||||
|
||||
CONF_PARSE_TODO_NEW = "parse_todo_new"
|
||||
CONF_PARSE_TODO_EDIT = "parse_todo_edit"
|
||||
CONF_PARSER = "parser"
|
||||
DEFAULT_PARSER = "nlp"
|
||||
MINIMUM_PARSER_CONFIDENCE = 0.98
|
||||
|
||||
MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v3.2.0")
|
||||
|
||||
@@ -31,10 +31,7 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
|
||||
@@ -166,6 +166,22 @@
|
||||
"message": "You are running {mealie_version} of Mealie. Minimum required version is {min_version}. Please upgrade Mealie and then retry."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"parse_todo_edit": "Recognize food in edited items",
|
||||
"parse_todo_new": "Recognize food in new items",
|
||||
"parser": "Recognition method"
|
||||
},
|
||||
"data_description": {
|
||||
"parse_todo_edit": "When enabled, Mealie checks edited to-do list items to find matches in your food items and identify quantities and units. If it finds a confident match, Mealie updates the food item, unit, and quantity. If disabled, the item is updated as entered.",
|
||||
"parse_todo_new": "When enabled, Mealie checks new to-do list items to find matches in your food items and identify quantities and units. If it finds a confident match, Mealie adds the food item, unit, and quantity separately. Otherwise, the item is added as a note.",
|
||||
"parser": "The method to use for processing to-do list items.\nIf you select OpenAI, you must set up an API key in Mealie."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"mealplan_entry_type": {
|
||||
"options": {
|
||||
@@ -177,6 +193,13 @@
|
||||
"side": "[%key:component::mealie::entity::calendar::side::name%]",
|
||||
"snack": "[%key:component::mealie::entity::calendar::snack::name%]"
|
||||
}
|
||||
},
|
||||
"parser": {
|
||||
"options": {
|
||||
"brute": "Brute force",
|
||||
"nlp": "Natural language processing",
|
||||
"openai": "OpenAI"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -7,6 +7,7 @@ from aiomealie import (
|
||||
MealieConnectionError,
|
||||
MealieError,
|
||||
MutateShoppingItem,
|
||||
RegisteredParser,
|
||||
ShoppingItem,
|
||||
ShoppingList,
|
||||
)
|
||||
@@ -23,7 +24,15 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
CONF_PARSE_TODO_EDIT,
|
||||
CONF_PARSE_TODO_NEW,
|
||||
CONF_PARSER,
|
||||
DEFAULT_PARSER,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MINIMUM_PARSER_CONFIDENCE,
|
||||
)
|
||||
from .coordinator import MealieConfigEntry, MealieShoppingListCoordinator
|
||||
from .entity import MealieEntity
|
||||
|
||||
@@ -111,6 +120,15 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
|
||||
super().__init__(coordinator, shopping_list_id)
|
||||
self._shopping_list_id = shopping_list_id
|
||||
self._attr_name = self.shopping_list.name
|
||||
self.parse_todo_new: bool = coordinator.config_entry.options.get(
|
||||
CONF_PARSE_TODO_NEW, True
|
||||
)
|
||||
self.parse_todo_edit: bool = coordinator.config_entry.options.get(
|
||||
CONF_PARSE_TODO_EDIT, True
|
||||
)
|
||||
self.parser = RegisteredParser(
|
||||
coordinator.config_entry.options.get(CONF_PARSER, DEFAULT_PARSER)
|
||||
)
|
||||
|
||||
@property
|
||||
def shopping_list(self) -> ShoppingList:
|
||||
@@ -128,6 +146,51 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
|
||||
"""Get the current set of To-do items."""
|
||||
return [_convert_api_item(item) for item in self.shopping_items]
|
||||
|
||||
async def async_parse_todo_item(
|
||||
self, item_summary: str
|
||||
) -> MutateShoppingItem | None:
|
||||
"""Parse a to-do item into a shopping item.
|
||||
|
||||
The average confidence is a combination of whether there is a food and also if the unit and quantity can be identified.
|
||||
This method will only return a shopping item if the average confidence meets or exceeds the minimum threshold.
|
||||
The returned shopping item can be either a food or a note item, with unit and quantity separated out.
|
||||
"""
|
||||
try:
|
||||
parsed_ingredient = await self.coordinator.client.parse_ingredient(
|
||||
item_summary.strip(), parser=self.parser
|
||||
)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except MealieError as exception:
|
||||
LOGGER.warning(
|
||||
"Unable to parse to-do item %s: %s",
|
||||
item_summary,
|
||||
exception,
|
||||
)
|
||||
parsed_ingredient = None
|
||||
|
||||
LOGGER.debug("Parsed ingredient: %s", parsed_ingredient)
|
||||
|
||||
if not parsed_ingredient or not parsed_ingredient.confidence:
|
||||
return None
|
||||
if (parsed_ingredient.confidence.average or 0.0) < MINIMUM_PARSER_CONFIDENCE:
|
||||
return None
|
||||
|
||||
ingredient = parsed_ingredient.ingredient
|
||||
if not ingredient.food:
|
||||
return None
|
||||
if ingredient.unit and ingredient.unit.unit_id is None:
|
||||
return None
|
||||
|
||||
return MutateShoppingItem(
|
||||
is_food=ingredient.food.food_id is not None,
|
||||
food_id=ingredient.food.food_id
|
||||
if ingredient.food.food_id is not None
|
||||
else None,
|
||||
note=ingredient.food.name if not ingredient.food.food_id else None,
|
||||
unit_id=ingredient.unit.unit_id if ingredient.unit else None,
|
||||
quantity=ingredient.quantity or 0.0,
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add an item to the list."""
|
||||
@@ -135,12 +198,21 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
|
||||
if len(self.shopping_items) > 0:
|
||||
position = self.shopping_items[-1].position + 1
|
||||
|
||||
new_shopping_item = MutateShoppingItem(
|
||||
list_id=self._shopping_list_id,
|
||||
note=item.summary.strip() if item.summary else item.summary,
|
||||
position=position,
|
||||
quantity=0.0,
|
||||
)
|
||||
new_shopping_item: MutateShoppingItem | None = None
|
||||
|
||||
if item.summary and self.parse_todo_new:
|
||||
new_shopping_item = await self.async_parse_todo_item(item.summary)
|
||||
|
||||
# If parsing fails or is not performed, create a fallback shopping item
|
||||
if not new_shopping_item:
|
||||
new_shopping_item = MutateShoppingItem(
|
||||
note=item.summary.strip() if item.summary else item.summary,
|
||||
quantity=0.0,
|
||||
)
|
||||
|
||||
new_shopping_item.list_id = self._shopping_list_id
|
||||
new_shopping_item.position = position
|
||||
|
||||
try:
|
||||
await self.coordinator.client.add_shopping_item(new_shopping_item)
|
||||
except MealieError as exception:
|
||||
@@ -165,31 +237,45 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
|
||||
assert list_item is not None
|
||||
position = list_item.position
|
||||
|
||||
update_shopping_item = MutateShoppingItem(
|
||||
item_id=list_item.item_id,
|
||||
list_id=list_item.list_id,
|
||||
note=list_item.note,
|
||||
display=list_item.display,
|
||||
checked=item.status == TodoItemStatus.COMPLETED,
|
||||
position=position,
|
||||
is_food=list_item.is_food,
|
||||
disable_amount=list_item.disable_amount,
|
||||
quantity=list_item.quantity,
|
||||
label_id=list_item.label_id,
|
||||
food_id=list_item.food_id,
|
||||
unit_id=list_item.unit_id,
|
||||
)
|
||||
update_shopping_item: MutateShoppingItem | None = None
|
||||
|
||||
stripped_item_summary = item.summary.strip() if item.summary else item.summary
|
||||
if (
|
||||
item.summary
|
||||
and self.parse_todo_edit
|
||||
and list_item.display.strip() != item.summary.strip()
|
||||
):
|
||||
update_shopping_item = await self.async_parse_todo_item(item.summary)
|
||||
|
||||
if list_item.display.strip() != stripped_item_summary:
|
||||
update_shopping_item.note = stripped_item_summary
|
||||
update_shopping_item.position = position
|
||||
if update_shopping_item.is_food is not None:
|
||||
update_shopping_item.is_food = False
|
||||
update_shopping_item.food_id = None
|
||||
update_shopping_item.quantity = 0.0
|
||||
update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED
|
||||
# If parsing fails or is not performed, create a fallback shopping item
|
||||
if not update_shopping_item:
|
||||
update_shopping_item = MutateShoppingItem(
|
||||
note=list_item.note,
|
||||
display=list_item.display,
|
||||
is_food=list_item.is_food,
|
||||
disable_amount=list_item.disable_amount,
|
||||
quantity=list_item.quantity,
|
||||
label_id=list_item.label_id,
|
||||
food_id=list_item.food_id,
|
||||
unit_id=list_item.unit_id,
|
||||
)
|
||||
|
||||
stripped_item_summary = (
|
||||
item.summary.strip() if item.summary else item.summary
|
||||
)
|
||||
|
||||
if list_item.display.strip() != stripped_item_summary:
|
||||
update_shopping_item.note = stripped_item_summary
|
||||
update_shopping_item.position = position
|
||||
if update_shopping_item.is_food is not None:
|
||||
update_shopping_item.is_food = False
|
||||
update_shopping_item.food_id = None
|
||||
update_shopping_item.quantity = 0.0
|
||||
update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED
|
||||
|
||||
update_shopping_item.item_id = list_item.item_id
|
||||
update_shopping_item.list_id = list_item.list_id
|
||||
update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED
|
||||
update_shopping_item.position = position
|
||||
|
||||
try:
|
||||
await self.coordinator.client.update_shopping_item(
|
||||
|
||||
@@ -73,6 +73,7 @@ def mock_mealie_client() -> Generator[AsyncMock]:
|
||||
client.get_shopping_items.return_value = ShoppingItemsResponse.from_json(
|
||||
load_fixture("get_shopping_items.json", DOMAIN)
|
||||
)
|
||||
client.parse_ingredient.return_value = None
|
||||
client.get_statistics.return_value = Statistics.from_json(
|
||||
load_fixture("statistics.json", DOMAIN)
|
||||
)
|
||||
@@ -91,6 +92,11 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
domain=DOMAIN,
|
||||
title="Mealie",
|
||||
data={CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"},
|
||||
options={
|
||||
"parse_todo_new": True,
|
||||
"parse_todo_edit": True,
|
||||
"parser": "nlp",
|
||||
},
|
||||
entry_id="01J0BC4QM2YBRP6H5G933CETT7",
|
||||
unique_id="bf1c62fe-4941-4332-9886-e54e88dbdba0",
|
||||
)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""Tests for the Mealie config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiomealie import About, MealieAuthenticationError, MealieConnectionError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.mealie.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER
|
||||
from homeassistant.components.mealie.const import CONF_PARSER, DOMAIN
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HASSIO,
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_USER,
|
||||
ConfigEntryState,
|
||||
)
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
@@ -192,6 +197,50 @@ async def test_reauth_flow(
|
||||
assert mock_config_entry.data[CONF_API_TOKEN] == "token2"
|
||||
|
||||
|
||||
async def test_reauth_flow_after_setup_error(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth retries an entry that failed during setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
mock_mealie_client.get_about.side_effect = MealieAuthenticationError()
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
mock_mealie_client.get_about.side_effect = None
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_TOKEN: "token2"}
|
||||
)
|
||||
|
||||
mock_reload.assert_called_once_with(mock_config_entry.entry_id)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_flow_unchanged_token(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth reloads when the token is unchanged."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_TOKEN: "token"}
|
||||
)
|
||||
|
||||
mock_reload.assert_called_once_with(mock_config_entry.entry_id)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_reauth_flow_wrong_account(
|
||||
hass: HomeAssistant,
|
||||
@@ -484,3 +533,27 @@ async def test_hassio_connection_error(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_options(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test updating options."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
parser_selector = result["data_schema"].schema[CONF_PARSER]
|
||||
assert parser_selector.config["translation_key"] == CONF_PARSER
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"parse_todo_new": False, CONF_PARSER: "brute"}
|
||||
)
|
||||
|
||||
assert not result["data"]["parse_todo_new"]
|
||||
assert result["data"]["parse_todo_edit"]
|
||||
assert result["data"][CONF_PARSER] == "brute"
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for the Mealie integration."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiomealie import About, MealieAuthenticationError, MealieConnectionError
|
||||
import pytest
|
||||
@@ -120,6 +120,27 @@ async def test_load_unload_entry(
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_update_options(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reloading the config entry when options updated."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with patch.object(
|
||||
hass.config_entries, "async_schedule_reload"
|
||||
) as async_schedule_reload:
|
||||
result = await hass.config_entries.options.async_init(
|
||||
mock_config_entry.entry_id
|
||||
)
|
||||
await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"parser": "brute"}
|
||||
)
|
||||
|
||||
async_schedule_reload.assert_called_once_with(mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "state"),
|
||||
[
|
||||
|
||||
@@ -3,12 +3,25 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
from aiomealie import MealieError, MutateShoppingItem, ShoppingListsResponse
|
||||
from aiomealie import (
|
||||
Ingredient,
|
||||
IngredientConfidence,
|
||||
MealieError,
|
||||
MutateShoppingItem,
|
||||
ParsedIngredient,
|
||||
RegisteredParser,
|
||||
ShoppingListsResponse,
|
||||
Unit,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.mealie import DOMAIN
|
||||
from homeassistant.components.mealie.const import (
|
||||
CONF_PARSE_TODO_EDIT,
|
||||
CONF_PARSE_TODO_NEW,
|
||||
)
|
||||
from homeassistant.components.todo import (
|
||||
ATTR_ITEM,
|
||||
ATTR_RENAME,
|
||||
@@ -80,6 +93,457 @@ async def test_todo_actions(
|
||||
getattr(mock_mealie_client, method).assert_called_once()
|
||||
|
||||
|
||||
async def test_add_todo_item_parsed(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test adding a parsed To-do item."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
options={**mock_config_entry.options, "parser": RegisteredParser.BRUTE},
|
||||
)
|
||||
shopping_item = mock_mealie_client.get_shopping_items.return_value.items[1]
|
||||
mock_mealie_client.parse_ingredient.return_value = ParsedIngredient(
|
||||
ingredient=Ingredient(
|
||||
quantity=1.0,
|
||||
note="",
|
||||
title="acorn squash",
|
||||
display="1 can acorn squash",
|
||||
unit=shopping_item.unit,
|
||||
food=shopping_item.food,
|
||||
reference_id="",
|
||||
),
|
||||
confidence=IngredientConfidence(average=1.0),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "1 can acorn squash"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.add_shopping_item.assert_called_once_with(
|
||||
MutateShoppingItem(
|
||||
list_id="27edbaab-2ec6-441f-8490-0283ea77585f",
|
||||
position=1,
|
||||
is_food=True,
|
||||
food_id="09322430-d24c-4b1a-abb6-22b6ed3a88f5",
|
||||
unit_id="7bf539d4-fc78-48bc-b48e-c35ccccec34a",
|
||||
quantity=1.0,
|
||||
)
|
||||
)
|
||||
mock_mealie_client.parse_ingredient.assert_awaited_once_with(
|
||||
"1 can acorn squash", parser=RegisteredParser.BRUTE
|
||||
)
|
||||
|
||||
|
||||
async def test_add_todo_item_parser_disabled(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test adding a to-do item without parsing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
options={
|
||||
**mock_config_entry.options,
|
||||
CONF_PARSE_TODO_NEW: False,
|
||||
},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "Misc Item"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.parse_ingredient.assert_not_awaited()
|
||||
mock_mealie_client.add_shopping_item.assert_called_once()
|
||||
|
||||
|
||||
async def test_add_todo_item_parse_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test falling back when adding an item cannot be parsed."""
|
||||
mock_mealie_client.parse_ingredient.return_value = None
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "Misc Item"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.add_shopping_item.assert_called_once_with(
|
||||
MutateShoppingItem(
|
||||
list_id="27edbaab-2ec6-441f-8490-0283ea77585f",
|
||||
position=1,
|
||||
note="Misc Item",
|
||||
quantity=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_add_todo_item_low_confidence_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test falling back when parsing has low confidence."""
|
||||
mock_mealie_client.parse_ingredient.return_value = ParsedIngredient(
|
||||
ingredient=Ingredient(
|
||||
quantity=0.0,
|
||||
note="",
|
||||
title="Misc Item",
|
||||
display="Misc Item",
|
||||
unit=None,
|
||||
food=None,
|
||||
reference_id="",
|
||||
),
|
||||
confidence=IngredientConfidence(average=0.0),
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "Misc Item"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.add_shopping_item.assert_called_once_with(
|
||||
MutateShoppingItem(
|
||||
list_id="27edbaab-2ec6-441f-8490-0283ea77585f",
|
||||
position=1,
|
||||
note="Misc Item",
|
||||
quantity=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_add_todo_item_without_food_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test falling back when parsing finds no food."""
|
||||
mock_mealie_client.parse_ingredient.return_value = ParsedIngredient(
|
||||
ingredient=Ingredient(
|
||||
quantity=1.0,
|
||||
note="",
|
||||
title="Misc Item",
|
||||
display="1 Misc Item",
|
||||
unit=None,
|
||||
food=None,
|
||||
reference_id="",
|
||||
),
|
||||
confidence=IngredientConfidence(average=1.0),
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "Misc Item"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.add_shopping_item.assert_called_once_with(
|
||||
MutateShoppingItem(
|
||||
list_id="27edbaab-2ec6-441f-8490-0283ea77585f",
|
||||
position=1,
|
||||
note="Misc Item",
|
||||
quantity=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_add_todo_item_without_unit_id_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Preserve the original item when a parsed unit cannot be linked."""
|
||||
shopping_item = mock_mealie_client.get_shopping_items.return_value.items[1]
|
||||
mock_mealie_client.parse_ingredient.return_value = ParsedIngredient(
|
||||
ingredient=Ingredient(
|
||||
quantity=1.0,
|
||||
note="",
|
||||
title="chicken",
|
||||
display="1 kg chicken",
|
||||
unit=Unit(name="kg"),
|
||||
food=shopping_item.food,
|
||||
reference_id="",
|
||||
),
|
||||
confidence=IngredientConfidence(average=1.0),
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "1 kg chicken"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.add_shopping_item.assert_called_once_with(
|
||||
MutateShoppingItem(
|
||||
list_id="27edbaab-2ec6-441f-8490-0283ea77585f",
|
||||
position=1,
|
||||
note="1 kg chicken",
|
||||
quantity=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_add_todo_item_parse_error_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test falling back when ingredient parsing raises an error."""
|
||||
mock_mealie_client.parse_ingredient.side_effect = MealieError
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ITEM: "Misc Item"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.add_shopping_item.assert_called_once_with(
|
||||
MutateShoppingItem(
|
||||
list_id="27edbaab-2ec6-441f-8490-0283ea77585f",
|
||||
position=1,
|
||||
note="Misc Item",
|
||||
quantity=0.0,
|
||||
)
|
||||
)
|
||||
assert "Unable to parse to-do item Misc Item" in caplog.text
|
||||
|
||||
|
||||
async def test_update_todo_item_parsed(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test updating an item with parsed data."""
|
||||
shopping_item = mock_mealie_client.get_shopping_items.return_value.items[1]
|
||||
mock_mealie_client.parse_ingredient.return_value = ParsedIngredient(
|
||||
ingredient=Ingredient(
|
||||
quantity=1.0,
|
||||
note="",
|
||||
title="acorn squash",
|
||||
display="1 can acorn squash",
|
||||
unit=shopping_item.unit,
|
||||
food=shopping_item.food,
|
||||
reference_id="",
|
||||
),
|
||||
confidence=IngredientConfidence(average=1.0),
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ITEM: "aubergine", ATTR_RENAME: "1 can acorn squash"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.update_shopping_item.assert_called_once_with(
|
||||
"69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
MutateShoppingItem(
|
||||
item_id="69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
list_id="9ce096fe-ded2-4077-877d-78ba450ab13e",
|
||||
position=2,
|
||||
is_food=True,
|
||||
food_id="09322430-d24c-4b1a-abb6-22b6ed3a88f5",
|
||||
unit_id="7bf539d4-fc78-48bc-b48e-c35ccccec34a",
|
||||
quantity=1.0,
|
||||
checked=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_update_todo_item_parser_disabled(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test updating a to-do item without parsing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
options={
|
||||
**mock_config_entry.options,
|
||||
CONF_PARSE_TODO_EDIT: False,
|
||||
},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ITEM: "aubergine", ATTR_RENAME: "Eggplant"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.parse_ingredient.assert_not_awaited()
|
||||
mock_mealie_client.update_shopping_item.assert_called_once()
|
||||
|
||||
|
||||
async def test_update_todo_item_parse_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test falling back when updating an item cannot be parsed."""
|
||||
mock_mealie_client.parse_ingredient.return_value = None
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ITEM: "aubergine", ATTR_RENAME: "Eggplant"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.update_shopping_item.assert_called_once_with(
|
||||
"69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
MutateShoppingItem(
|
||||
item_id="69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
list_id="9ce096fe-ded2-4077-877d-78ba450ab13e",
|
||||
note="Eggplant",
|
||||
display="aubergine",
|
||||
quantity=0.0,
|
||||
position=2,
|
||||
is_food=False,
|
||||
disable_amount=False,
|
||||
food_id=None,
|
||||
checked=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_update_todo_item_without_unit_id_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Preserve a renamed item's unit when the parsed unit has no ID."""
|
||||
shopping_item = mock_mealie_client.get_shopping_items.return_value.items[1]
|
||||
mock_mealie_client.parse_ingredient.return_value = ParsedIngredient(
|
||||
ingredient=Ingredient(
|
||||
quantity=1.0,
|
||||
note="",
|
||||
title="chicken",
|
||||
display="1 kg chicken",
|
||||
unit=Unit(name="kg"),
|
||||
food=shopping_item.food,
|
||||
reference_id="",
|
||||
),
|
||||
confidence=IngredientConfidence(average=1.0),
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ITEM: "aubergine", ATTR_RENAME: "1 kg chicken"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.update_shopping_item.assert_called_once_with(
|
||||
"69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
MutateShoppingItem(
|
||||
item_id="69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
list_id="9ce096fe-ded2-4077-877d-78ba450ab13e",
|
||||
note="1 kg chicken",
|
||||
display="aubergine",
|
||||
quantity=0.0,
|
||||
position=2,
|
||||
is_food=False,
|
||||
disable_amount=False,
|
||||
food_id=None,
|
||||
checked=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_update_todo_item_parse_error_fallback(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test falling back when updating an item raises a parsing error."""
|
||||
mock_mealie_client.parse_ingredient.side_effect = MealieError
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ITEM: "aubergine", ATTR_RENAME: "Eggplant"},
|
||||
target={ATTR_ENTITY_ID: "todo.mealie_supermarket"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_mealie_client.update_shopping_item.assert_called_once_with(
|
||||
"69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
MutateShoppingItem(
|
||||
item_id="69913b9a-7c75-4935-abec-297cf7483f88",
|
||||
list_id="9ce096fe-ded2-4077-877d-78ba450ab13e",
|
||||
note="Eggplant",
|
||||
display="aubergine",
|
||||
quantity=0.0,
|
||||
position=2,
|
||||
is_food=False,
|
||||
disable_amount=False,
|
||||
food_id=None,
|
||||
checked=False,
|
||||
),
|
||||
)
|
||||
assert "Unable to parse to-do item Eggplant" in caplog.text
|
||||
|
||||
|
||||
async def test_add_todo_list_item_error(
|
||||
hass: HomeAssistant,
|
||||
mock_mealie_client: AsyncMock,
|
||||
|
||||
Reference in New Issue
Block a user