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>
This commit is contained in:
AlCalzone
2026-09-10 15:48:35 +02:00
committed by GitHub
co-authored by Copilot App Copilot Autofix powered by AI
parent bb2e7f1629
commit 76bf1328f9
3 changed files with 104 additions and 26 deletions
@@ -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})
@@ -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:
+63 -17
View File
@@ -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: