Add Mealie parser to to-do lists (#183099)

This commit is contained in:
Andrew Jackson
2026-10-02 20:55:34 +01:00
committed by GitHub
parent 71ebfa50e5
commit 1430f81bb0
9 changed files with 782 additions and 42 deletions
+67 -3
View File
@@ -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)
)
+6
View File
@@ -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": {
+116 -30
View File
@@ -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(
+6
View File
@@ -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",
)
+76 -3
View File
@@ -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
+22 -1
View File
@@ -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"),
[
+465 -1
View File
@@ -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,