From 76bf1328f90d2dee2a391eb4fb5d7f2488db8148 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 10 Sep 2026 15:48:35 +0200 Subject: [PATCH] Avoid duplicates when using HassShoppingListAddItem to modify shopping list (#179726) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/shopping_list/intent.py | 32 +++++--- tests/components/shopping_list/test_init.py | 18 +++++ tests/components/shopping_list/test_intent.py | 80 +++++++++++++++---- 3 files changed, 104 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 0e796d4b53bc..4ea080154404 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -1,12 +1,12 @@ """Intents for the Shopping List integration.""" -from typing import override +from typing import cast, override from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent from .common import NoMatchingShoppingListItem, _get_shopping_data -from .const import DOMAIN, EVENT_SHOPPING_LIST_UPDATED +from .const import DOMAIN INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_COMPLETE_ITEM = "HassShoppingListCompleteItem" @@ -32,12 +32,28 @@ class AddItemIntent(intent.IntentHandler): async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) - item = slots["item"]["value"].strip() - await _get_shopping_data(intent_obj.hass).async_add(item) + item_name = slots["item"]["value"].strip() + shopping_data = _get_shopping_data(intent_obj.hass) + completed_match = None + normalized_name = item_name.casefold() + for item in shopping_data.items: + name = item["name"] + if not isinstance(name, str) or name.casefold() != normalized_name: + continue + if not item["complete"]: + return intent_obj.create_response() + if completed_match is None: + completed_match = item - response = intent_obj.create_response() - intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) - return response + if completed_match is None: + await shopping_data.async_add(item_name) + else: + await shopping_data.async_update( + cast(str, completed_match["id"]), + {"name": cast(str, completed_match["name"]), "complete": False}, + ) + + return intent_obj.create_response() class CompleteItemIntent(intent.IntentHandler): @@ -61,8 +77,6 @@ class CompleteItemIntent(intent.IntentHandler): except NoMatchingShoppingListItem: complete_items = [] - intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) - response = intent_obj.create_response() response.async_set_speech_slots({"completed_items": complete_items}) diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 7bc18746894e..126570695d06 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -51,12 +51,15 @@ async def test_add_item( hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion ) -> None: """Test adding an item intent.""" + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) response = await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": " beer "}} ) assert len(_get_shopping_data(hass).items) == 1 assert _get_shopping_data(hass).items[0]["name"] == "beer" # name was trimmed + assert len(events) == 1 + assert events[0].data["action"] == "add" # Response text is now handled by default conversation agent assert response.response_type is intent.IntentResponseType.ACTION_DONE @@ -797,6 +800,21 @@ async def test_add_item_service( assert_shopping_list_data(hass, snapshot) +async def test_add_item_service_allows_duplicates( + hass: HomeAssistant, sl_setup: None +) -> None: + """Test adding duplicate items with the shopping list service.""" + for _ in range(2): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_ITEM, + {ATTR_NAME: "beer"}, + blocking=True, + ) + + assert len(_get_shopping_data(hass).items) == 2 + + async def test_remove_item_service( hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion ) -> None: diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index 918ae0340ff6..ff0ce2d0cd42 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -1,24 +1,70 @@ """Test Shopping List intents.""" from homeassistant.components.shopping_list.common import _get_shopping_data +from homeassistant.components.shopping_list.const import EVENT_SHOPPING_LIST_UPDATED from homeassistant.core import HomeAssistant from homeassistant.helpers import intent +from tests.common import async_capture_events -async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: + +async def test_add_item_intent_reactivates_first_completed_match( + hass: HomeAssistant, sl_setup: None +) -> None: + """Test reactivating the first completed matching item.""" + shopping_data = _get_shopping_data(hass) + first_item = await shopping_data.async_add("Beer", complete=True) + second_item = await shopping_data.async_add("BEER", complete=True) + first_item_id = first_item["id"] + second_item_id = second_item["id"] + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + + response = await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} + ) + + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(shopping_data.items) == 2 + assert shopping_data.items[0]["id"] == first_item_id + assert shopping_data.items[0]["name"] == "Beer" + assert shopping_data.items[0]["complete"] is False + assert shopping_data.items[1]["id"] == second_item_id + assert shopping_data.items[1]["complete"] is True + assert len(events) == 1 + assert events[0].data["action"] == "update" + + +async def test_add_item_intent_keeps_existing_active_match( + hass: HomeAssistant, sl_setup: None +) -> None: + """Test keeping an existing active matching item.""" + shopping_data = _get_shopping_data(hass) + completed_item = await shopping_data.async_add("Beer", complete=True) + active_item = await shopping_data.async_add("BEER") + completed_item_id = completed_item["id"] + active_item_id = active_item["id"] + events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED) + + response = await intent.async_handle( + hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} + ) + + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(shopping_data.items) == 2 + assert shopping_data.items[0]["id"] == completed_item_id + assert shopping_data.items[0]["complete"] is True + assert shopping_data.items[1]["id"] == active_item_id + assert shopping_data.items[1]["complete"] is False + assert not events + + +async def test_complete_item_intent(hass: HomeAssistant, sl_setup: None) -> None: """Test complete item.""" - await intent.async_handle( - hass, "test", "HassShoppingListAddItem", {"item": {"value": "soda"}} - ) - await intent.async_handle( - hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} - ) - await intent.async_handle( - hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} - ) - await intent.async_handle( - hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} - ) + shopping_data = _get_shopping_data(hass) + await shopping_data.async_add("soda") + await shopping_data.async_add("beer") + await shopping_data.async_add("beer") + await shopping_data.async_add("wine") response = await intent.async_handle( hass, "test", "HassShoppingListCompleteItem", {"item": {"value": "beer"}} @@ -28,8 +74,8 @@ async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: completed_items = response.speech_slots.get("completed_items") assert len(completed_items) == 2 assert completed_items[0]["name"] == "beer" - assert _get_shopping_data(hass).items[1]["complete"] - assert _get_shopping_data(hass).items[2]["complete"] + assert shopping_data.items[1]["complete"] + assert shopping_data.items[2]["complete"] # Complete again response = await intent.async_handle( @@ -38,8 +84,8 @@ async def test_complete_item_intent(hass: HomeAssistant, sl_setup) -> None: assert response.response_type is intent.IntentResponseType.ACTION_DONE assert response.speech_slots.get("completed_items") == [] - assert _get_shopping_data(hass).items[1]["complete"] - assert _get_shopping_data(hass).items[2]["complete"] + assert shopping_data.items[1]["complete"] + assert shopping_data.items[2]["complete"] async def test_complete_item_intent_not_found(hass: HomeAssistant, sl_setup) -> None: