diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py index 05a7e2ae998f..77dfb4a37628 100644 --- a/homeassistant/components/mealie/config_flow.py +++ b/homeassistant/components/mealie/config_flow.py @@ -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) + ) diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index c267a8a8d693..2cc13d6908c0 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -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") diff --git a/homeassistant/components/mealie/quality_scale.yaml b/homeassistant/components/mealie/quality_scale.yaml index cbb81d24c72d..cf3183a7d95e 100644 --- a/homeassistant/components/mealie/quality_scale.yaml +++ b/homeassistant/components/mealie/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index b21870b8ac3e..f2e3b03d38dc 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -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": { diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index 5db938da5746..7ab0202a8546 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -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( diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py index 223baa6e3eb3..f7dec7d4d75a 100644 --- a/tests/components/mealie/conftest.py +++ b/tests/components/mealie/conftest.py @@ -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", ) diff --git a/tests/components/mealie/test_config_flow.py b/tests/components/mealie/test_config_flow.py index dbd04e6af42b..71046ba3992b 100644 --- a/tests/components/mealie/test_config_flow.py +++ b/tests/components/mealie/test_config_flow.py @@ -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 diff --git a/tests/components/mealie/test_init.py b/tests/components/mealie/test_init.py index 8fce25bcfb25..95db0383c37e 100644 --- a/tests/components/mealie/test_init.py +++ b/tests/components/mealie/test_init.py @@ -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"), [ diff --git a/tests/components/mealie/test_todo.py b/tests/components/mealie/test_todo.py index 3ef6be13a253..9f0e1d273255 100644 --- a/tests/components/mealie/test_todo.py +++ b/tests/components/mealie/test_todo.py @@ -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,