From fe11885caec4ea8525c30399e124af8f3c90ef0c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Sep 2026 12:29:42 -0500 Subject: [PATCH] Give intent match failures a message instead of a repr (#182343) Co-authored-by: Claude Opus 5 --- .../components/conversation/default_agent.py | 279 ++++++++++-------- homeassistant/helpers/intent.py | 66 ++++- .../conversation/test_default_agent.py | 241 ++++++++++++++- tests/components/intent/test_init.py | 5 +- tests/helpers/test_intent.py | 48 +++ 5 files changed, 515 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 7f13c4804f7f..e22d0e3cd416 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -8,7 +8,7 @@ from enum import Enum, auto import logging from pathlib import Path import time -from typing import IO, Any, cast, override +from typing import IO, Any, NamedTuple, assert_never, cast, override from gazetteer_matcher import FrameCandidate, GazetteerMatcher from hassil.expression import Expression, Group, ListReference @@ -642,7 +642,7 @@ class DefaultAgent(ConversationEntity): except intent.MatchFailedError as match_error: # Intent was valid, but no entities matched the constraints. error_response_type, error_response_args = _get_match_error_response( - self.hass, match_error + match_error ) intent_response = _make_error_result( language, @@ -1444,8 +1444,12 @@ class DefaultAgent(ConversationEntity): else: response_key = error_key + # A new error reaches here before every language has translated it, so + # prefer the language's own generic error over the English default response_str = ( - lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT + lang_intents.error_responses.get(response_key) + or lang_intents.error_responses.get(ErrorKey.NO_INTENT.value) + or _DEFAULT_ERROR_TEXT ) response_template = template.Template(response_str, self.hass) @@ -1738,132 +1742,165 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str return ErrorKey.NO_INTENT, {} -def _get_match_error_response( - hass: HomeAssistant, - match_error: intent.MatchFailedError, -) -> tuple[ErrorKey, dict[str, Any]]: - """Return key and template arguments for error when target matching fails.""" +# Map to errors in home-assistant-intents +_NO_TARGET_ERRORS: dict[tuple[str, str, bool], ErrorKey] = { + ("entity", "", False): ErrorKey.NO_ENTITY, + ("entity", "", True): ErrorKey.NO_ENTITY_EXPOSED, + ("entity", "area", False): ErrorKey.NO_ENTITY_IN_AREA, + ("entity", "area", True): ErrorKey.NO_ENTITY_IN_AREA_EXPOSED, + ("entity", "floor", False): ErrorKey.NO_ENTITY_IN_FLOOR, + ("entity", "floor", True): ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, + ("device_class", "", False): ErrorKey.NO_DEVICE_CLASS, + ("device_class", "", True): ErrorKey.NO_DEVICE_CLASS_EXPOSED, + ("device_class", "area", False): ErrorKey.NO_DEVICE_CLASS_IN_AREA, + ("device_class", "area", True): ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, + ("device_class", "floor", False): ErrorKey.NO_DEVICE_CLASS_IN_FLOOR, + ("device_class", "floor", True): ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, + ("domain", "", False): ErrorKey.NO_DOMAIN, + ("domain", "", True): ErrorKey.NO_DOMAIN_EXPOSED, + ("domain", "area", False): ErrorKey.NO_DOMAIN_IN_AREA, + ("domain", "area", True): ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, + ("domain", "floor", False): ErrorKey.NO_DOMAIN_IN_FLOOR, + ("domain", "floor", True): ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, +} + +class _NoTargetOrder(NamedTuple): + """Which constraints an error may name, most specific first.""" + + subjects: tuple[str, ...] + """What was asked for.""" + + scopes: tuple[str, ...] + """Where it was asked for.""" + + +# The failing constraint is what the message should be about +_ANY_TARGET = ("entity", "device_class", "domain") +_ANY_SCOPE = ("area", "floor") +_NO_TARGET_ORDER: dict[intent.MatchFailedReason, _NoTargetOrder] = { + intent.MatchFailedReason.NAME: _NoTargetOrder(("entity",), _ANY_SCOPE), + intent.MatchFailedReason.AREA: _NoTargetOrder(_ANY_TARGET, ("area",)), + intent.MatchFailedReason.FLOOR: _NoTargetOrder(_ANY_TARGET, ("floor",)), + intent.MatchFailedReason.DOMAIN: _NoTargetOrder( + ("device_class", "domain"), _ANY_SCOPE + ), + intent.MatchFailedReason.DEVICE_CLASS: _NoTargetOrder( + ("device_class", "domain"), _ANY_SCOPE + ), + intent.MatchFailedReason.ASSISTANT: _NoTargetOrder(_ANY_TARGET, _ANY_SCOPE), +} + + +def _get_no_target_response( + constraints: intent.MatchTargetsConstraints, + order: _NoTargetOrder, + *, + exposed_only: bool, +) -> tuple[ErrorKey, dict[str, Any]]: + """Return the error naming the failed constraint, scoped to an area or floor.""" + subjects: dict[str, dict[str, Any]] = {} + if constraints.name: + subjects["entity"] = {"entity": constraints.name} + if constraints.device_classes: + subjects["device_class"] = { + "device_class": next(iter(constraints.device_classes)) + } + if constraints.domains: + subjects["domain"] = {"domain": next(iter(constraints.domains))} + + for kind in order.subjects: + if (args := subjects.get(kind)) is not None: + break + else: + # The constraint that failed was not set, so there is nothing to name + return ErrorKey.NO_INTENT, {} + + scopes = {"area": constraints.area_name, "floor": constraints.floor_name} + scope = "" + for candidate in order.scopes: + if scopes[candidate]: + scope = candidate + args[candidate] = scopes[candidate] + break + + return _NO_TARGET_ERRORS[kind, scope, exposed_only], args + + +# Present in intents, but not the ErrorKey enum yet. +_DUPLICATE_TARGETS = "duplicate_targets" + + +def _get_duplicate_response( + name: str | None, constraints: intent.MatchTargetsConstraints +) -> tuple[ErrorKey | str, dict[str, Any]]: + """Return the error for a match ambiguous between more entities than can be used.""" + if not name: + # Nothing was named, so all the answer can say is that several matched + return _DUPLICATE_TARGETS, {} + + if constraints.area_name: + return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, { + "entity": name, + "area": constraints.area_name, + } + + if constraints.floor_name: + return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, { + "entity": name, + "floor": constraints.floor_name, + } + + return ErrorKey.DUPLICATE_ENTITIES, {"entity": name} + + +def _get_match_error_response( + match_error: intent.MatchFailedError, +) -> tuple[ErrorKey | str, dict[str, Any]]: + """Return key and template arguments for error when target matching fails.""" constraints, result = match_error.constraints, match_error.result reason = result.no_match_reason + if reason is None: + return ErrorKey.NO_INTENT, {} - if ( - reason - in (intent.MatchFailedReason.DEVICE_CLASS, intent.MatchFailedReason.DOMAIN) - ) and constraints.device_classes: - device_class = next(iter(constraints.device_classes)) # first device class - if constraints.area_name: - # device_class in area - return ErrorKey.NO_DEVICE_CLASS_IN_AREA, { - "device_class": device_class, - "area": constraints.area_name, + match reason: + case ( + intent.MatchFailedReason.NAME + | intent.MatchFailedReason.AREA + | intent.MatchFailedReason.FLOOR + | intent.MatchFailedReason.DOMAIN + | intent.MatchFailedReason.DEVICE_CLASS + | intent.MatchFailedReason.ASSISTANT + ): + return _get_no_target_response( + constraints, + _NO_TARGET_ORDER[reason], + exposed_only=reason is intent.MatchFailedReason.ASSISTANT, + ) + + case intent.MatchFailedReason.INVALID_AREA: + return ErrorKey.NO_AREA, {"area": result.no_match_name} + + case intent.MatchFailedReason.INVALID_FLOOR: + return ErrorKey.NO_FLOOR, {"floor": result.no_match_name} + + case intent.MatchFailedReason.FEATURE: + return ErrorKey.FEATURE_NOT_SUPPORTED, {} + + case intent.MatchFailedReason.STATE: + if not constraints.states: + return ErrorKey.NO_INTENT, {} + return ErrorKey.ENTITY_WRONG_STATE, { + "state": next(iter(constraints.states)) } - # device_class only - return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class} + case intent.MatchFailedReason.DUPLICATE_NAME: + return _get_duplicate_response(result.no_match_name, constraints) - if (reason is intent.MatchFailedReason.DOMAIN) and constraints.domains: - domain = next(iter(constraints.domains)) # first domain - if constraints.area_name: - # domain in area - return ErrorKey.NO_DOMAIN_IN_AREA, { - "domain": domain, - "area": constraints.area_name, - } + case intent.MatchFailedReason.MULTIPLE_TARGETS: + return _get_duplicate_response(constraints.name, constraints) - if constraints.floor_name: - # domain in floor - return ErrorKey.NO_DOMAIN_IN_FLOOR, { - "domain": domain, - "floor": constraints.floor_name, - } - - # domain only - return ErrorKey.NO_DOMAIN, {"domain": domain} - - if reason is intent.MatchFailedReason.DUPLICATE_NAME: - if constraints.floor_name: - # duplicate on floor - return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, { - "entity": result.no_match_name, - "floor": constraints.floor_name, - } - - if constraints.area_name: - # duplicate on area - return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, { - "entity": result.no_match_name, - "area": constraints.area_name, - } - - return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name} - - if reason is intent.MatchFailedReason.INVALID_AREA: - # Invalid area name - return ErrorKey.NO_AREA, {"area": result.no_match_name} - - if reason is intent.MatchFailedReason.INVALID_FLOOR: - # Invalid floor name - return ErrorKey.NO_FLOOR, {"floor": result.no_match_name} - - if reason is intent.MatchFailedReason.FEATURE: - # Feature not supported by entity - return ErrorKey.FEATURE_NOT_SUPPORTED, {} - - if reason is intent.MatchFailedReason.STATE: - # Entity is not in correct state - assert constraints.states - state = next(iter(constraints.states)) - - return ErrorKey.ENTITY_WRONG_STATE, {"state": state} - - if reason is intent.MatchFailedReason.ASSISTANT: - # Not exposed - if constraints.name: - if constraints.area_name: - return ErrorKey.NO_ENTITY_IN_AREA_EXPOSED, { - "entity": constraints.name, - "area": constraints.area_name, - } - if constraints.floor_name: - return ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, { - "entity": constraints.name, - "floor": constraints.floor_name, - } - return ErrorKey.NO_ENTITY_EXPOSED, {"entity": constraints.name} - - if constraints.device_classes: - device_class = next(iter(constraints.device_classes)) - - if constraints.area_name: - return ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, { - "device_class": device_class, - "area": constraints.area_name, - } - if constraints.floor_name: - return ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, { - "device_class": device_class, - "floor": constraints.floor_name, - } - return ErrorKey.NO_DEVICE_CLASS_EXPOSED, {"device_class": device_class} - - if constraints.domains: - domain = next(iter(constraints.domains)) - - if constraints.area_name: - return ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, { - "domain": domain, - "area": constraints.area_name, - } - if constraints.floor_name: - return ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, { - "domain": domain, - "floor": constraints.floor_name, - } - return ErrorKey.NO_DOMAIN_EXPOSED, {"domain": domain} - - # Default error - return ErrorKey.NO_INTENT, {} + assert_never(reason) def _collect_list_references(expression: Expression, list_names: set[str]) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index a6e53833a778..9c4ee4f676f6 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -192,7 +192,12 @@ class MatchFailedError(IntentError): @override def __str__(self) -> str: - """Return string representation.""" + """Return why matching failed.""" + return _match_failure_message(self.result, self.constraints) + + @override + def __repr__(self) -> str: + """Return the full result and constraints, for logs and debugging.""" return ( f" str: + """List the constraints that were set, in an order that does not vary.""" + described: list[str] = [] + + for label, value in ( + ("name", constraints.name), + ("area", constraints.area_name), + ("floor", constraints.floor_name), + ): + if value: + described.append(f"{label} {value!r}") + + # Sorted because a set's iteration order is not stable between runs + for label, values in ( + ("domains", constraints.domains), + ("device classes", constraints.device_classes), + ("states", constraints.states), + ): + if values: + described.append(f"{label} {', '.join(sorted(values))}") + + if constraints.features: + described.append(f"features {constraints.features}") + + return ", ".join(described) + + +def _match_failure_message( + result: MatchTargetsResult, constraints: MatchTargetsConstraints +) -> str: + """Describe a failed match as a sentence naming the constraints in play.""" + message = "No entities matched" + if (reason := result.no_match_reason) is not None: + message = _MATCH_FAILURE_REASONS.get(reason, message) + + if described := _describe_constraints(constraints): + return f"{message} (given {described})" + + return message + + @dataclass class MatchTargetsPreferences: """Preferences to disambiguate duplicate name matches.""" diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 828a0afa2dfa..931f5f1552e7 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -6,12 +6,15 @@ import tempfile from typing import Any from unittest.mock import AsyncMock, patch +from hassil.intents import Intents from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult +from home_assistant_intents import ErrorKey import pytest from syrupy.assertion import SnapshotAssertion import yaml from homeassistant.components import conversation, cover, media_player, weather +from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.conversation import ( DOMAIN, async_get_agent, @@ -24,7 +27,11 @@ from homeassistant.components.conversation.chat_log import ( async_get_chat_log, ) from homeassistant.components.conversation.const import ATTR_LANGUAGE, ATTR_TEXT -from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE +from homeassistant.components.conversation.default_agent import ( + METADATA_CUSTOM_SENTENCE, + LanguageIntents, + _get_match_error_response, +) from homeassistant.components.conversation.models import ConversationInput from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.homeassistant.exposed_entities import ( @@ -39,6 +46,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, STATE_CLOSED, STATE_OFF, STATE_ON, @@ -993,6 +1001,237 @@ async def test_error_no_device_on_floor_exposed( ) +@pytest.mark.usefixtures("init_components") +async def test_error_device_in_other_area( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test error message when a known device exists, but not in the asked-for area.""" + area_kitchen = area_registry.async_update( + area_registry.async_get_or_create("kitchen_id").id, name="kitchen" + ) + area_bedroom = area_registry.async_update( + area_registry.async_get_or_create("bedroom_id").id, name="bedroom" + ) + + # The kitchen has a light, so the area itself is a valid target + for object_id, name, area in ( + ("1234", "ceiling", area_kitchen), + ("5678", "test light", area_bedroom), + ): + entry = entity_registry.async_get_or_create("light", "demo", object_id) + entry = entity_registry.async_update_entity( + entry.entity_id, name=name, area_id=area.id + ) + hass.states.async_set(entry.entity_id, "off", {ATTR_FRIENDLY_NAME: name}) + expose_entity(hass, entry.entity_id, True) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on the test light in the kitchen", None, Context(), None + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any device called test light in the kitchen area" + ) + + +@pytest.mark.parametrize("reason", list(intent.MatchFailedReason)) +def test_match_error_response_never_falls_back( + reason: intent.MatchFailedReason, +) -> None: + """Test every failure keeps its own message when the constraint was set. + + A reason with no branch used to reach the generic "couldn't understand" + error, which says nothing about what was actually wrong. + """ + match_error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, reason, no_match_name="test light"), + constraints=intent.MatchTargetsConstraints( + name="test light", + area_name="kitchen", + domains={"light"}, + device_classes={"garage"}, + states={"on"}, + ), + ) + + error_key, _error_args = _get_match_error_response(match_error) + + assert error_key is not ErrorKey.NO_INTENT + + +@pytest.mark.parametrize( + ("reason", "expected_key", "expected_args"), + [ + pytest.param( + intent.MatchFailedReason.NAME, + ErrorKey.NO_ENTITY, + {"entity": "test light"}, + id="name", + ), + pytest.param( + intent.MatchFailedReason.DOMAIN, + ErrorKey.NO_DOMAIN, + {"domain": "light"}, + id="domain", + ), + pytest.param( + intent.MatchFailedReason.ASSISTANT, + ErrorKey.NO_ENTITY_EXPOSED, + {"entity": "test light"}, + id="assistant", + ), + ], +) +def test_match_error_response_names_the_failed_constraint( + reason: intent.MatchFailedReason, + expected_key: ErrorKey, + expected_args: dict[str, str], +) -> None: + """Test the reason picks the subject, not whichever constraint was set first. + + A domain failure alongside a name means no entity of that domain exists, so + saying nothing is called that name would be wrong. + """ + match_error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, reason), + constraints=intent.MatchTargetsConstraints( + name="test light", domains={"light"} + ), + ) + + assert _get_match_error_response(match_error) == (expected_key, expected_args) + + +@pytest.mark.parametrize( + ("reason", "expected_key", "expected_args"), + [ + pytest.param( + intent.MatchFailedReason.FLOOR, + ErrorKey.NO_ENTITY_IN_FLOOR, + {"entity": "test light", "floor": "ground"}, + id="floor", + ), + pytest.param( + intent.MatchFailedReason.AREA, + ErrorKey.NO_ENTITY_IN_AREA, + {"entity": "test light", "area": "kitchen"}, + id="area", + ), + ], +) +def test_match_error_response_names_the_failed_scope( + reason: intent.MatchFailedReason, + expected_key: ErrorKey, + expected_args: dict[str, str], +) -> None: + """Test the reason picks the scope when both an area and a floor were asked for. + + Floors are filtered before areas, so a floor failure says nothing about + whether the area would have matched. + """ + match_error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, reason), + constraints=intent.MatchTargetsConstraints( + name="test light", area_name="kitchen", floor_name="ground" + ), + ) + + assert _get_match_error_response(match_error) == (expected_key, expected_args) + + +@pytest.mark.usefixtures("init_components") +async def test_error_multiple_targets_without_name_is_spoken( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test an ambiguous match with nothing named speaks a real response. + + duplicate_targets is not an ErrorKey, so this asserts the rendered speech to + catch the key failing to resolve and falling back to the generic error. + """ + area_kitchen = area_registry.async_update( + area_registry.async_get_or_create("kitchen_id").id, name="kitchen" + ) + for object_id, name in (("1234", "Thermostat One"), ("5678", "Thermostat Two")): + entry = entity_registry.async_get_or_create("climate", "demo", object_id) + entry = entity_registry.async_update_entity( + entry.entity_id, name=name, area_id=area_kitchen.id + ) + hass.states.async_set( + entry.entity_id, + "heat", + { + ATTR_FRIENDLY_NAME: name, + "current_temperature": 20, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + }, + ) + expose_entity(hass, entry.entity_id, True) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "what is the temperature in the kitchen", None, Context(), None + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, more than one device matched your request" + ) + + +def test_match_error_response_multiple_targets_without_name() -> None: + """Test an ambiguous match with nothing named still reports the ambiguity. + + Several single_target callers constrain only a domain or device class, so + there is no name to say which one was meant. + """ + match_error = intent.MatchFailedError( + result=intent.MatchTargetsResult( + False, intent.MatchFailedReason.MULTIPLE_TARGETS + ), + constraints=intent.MatchTargetsConstraints( + area_name="kitchen", domains={"climate"} + ), + ) + + assert _get_match_error_response(match_error) == ("duplicate_targets", {}) + + +@pytest.mark.usefixtures("init_components") +async def test_error_text_prefers_the_language_over_english( + hass: HomeAssistant, +) -> None: + """Test a language that translates only some errors still answers in itself. + + Most languages translate a subset of the errors, and a missing one used to + fall through to English no matter what language was being spoken. + """ + generic = "Ho sento, no entenc això" + lang_intents = LanguageIntents( + intents=Intents.from_dict({"language": "ca", "intents": {}}), + intents_dict={}, + intent_responses={}, + error_responses={ErrorKey.NO_INTENT.value: generic}, + language_variant="ca", + ) + agent = conversation.async_get_agent(hass) + + assert ( + agent._get_error_text( + ErrorKey.NO_ENTITY_EXPOSED, lang_intents, entity="test light" + ) + == generic + ) + + @pytest.mark.usefixtures("init_components") async def test_error_no_device_in_area_exposed( hass: HomeAssistant, diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 6d45674f63e9..d28547bdd900 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -177,7 +177,10 @@ async def test_http_handle_intent_match_failure( assert resp.status == 200 data = await resp.json() - assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"] + # The API speaks the failure, so it must be a sentence and not a repr + assert data["speech"]["plain"]["speech"] == ( + "Multiple entities share the name (given name 'Garage Door')" + ) async def test_http_assistant( diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 1c0d5226bded..227ffa9dfdc9 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1118,3 +1118,51 @@ async def test_intent_response_dict() -> None: # The original dict should not be affected by the mutations assert response_dict1 == response_dict2 + + +@pytest.mark.parametrize("reason", list(intent.MatchFailedReason)) +def test_match_failed_error_describes_every_reason( + reason: intent.MatchFailedReason, +) -> None: + """Test every reason has wording, since str() is spoken by the REST API.""" + error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, reason), + constraints=intent.MatchTargetsConstraints(name="Lamp"), + ) + + assert str(error) == f"{intent._MATCH_FAILURE_REASONS[reason]} (given name 'Lamp')" + + +def test_match_failed_error_str_names_the_constraints() -> None: + """Test the message names what was asked for, and only what was set.""" + error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, intent.MatchFailedReason.AREA), + constraints=intent.MatchTargetsConstraints( + name="Lamp", area_name="Kitchen", assistant="conversation" + ), + ) + + assert ( + str(error) == "No entities were in the area (given name 'Lamp', area 'Kitchen')" + ) + + +def test_match_failed_error_str_without_constraints() -> None: + """Test the message stands alone when nothing was constrained.""" + error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, intent.MatchFailedReason.DOMAIN), + constraints=intent.MatchTargetsConstraints(), + ) + + assert str(error) == "No entities matched the domain" + + +def test_match_failed_error_repr_keeps_the_detail() -> None: + """Test the full state is still available for logs and debugging.""" + error = intent.MatchFailedError( + result=intent.MatchTargetsResult(False, intent.MatchFailedReason.NAME), + constraints=intent.MatchTargetsConstraints(name="Lamp"), + ) + + assert repr(error).startswith("