From eb01998395ef091d11dabc66d940ca9c53e3fe70 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 3 Jan 2024 17:34:47 +0100 Subject: [PATCH] Add support for placeholders in entity name translations (#104453) * add placeholder support to entity name translation * add negativ tests * make property also available via description * fix doc string in translation_placeholders() * fix detection of placeholder * validate placeholders for localized strings * add test * Cache translation_placeholders property * Make translation_placeholders uncondotionally return dict * Fall back to unsubstituted name in case of mismatch * Only replace failing translations with English * Update snapshots * Blow up on non stable releases * Fix test * Update entity.py --------- Co-authored-by: Erik Co-authored-by: Joost Lekkerkerker --- homeassistant/helpers/entity.py | 49 ++++- homeassistant/helpers/translation.py | 49 ++++- tests/helpers/snapshots/test_entity.ambr | 51 +++-- tests/helpers/test_entity.py | 197 ++++++++++++++++++ tests/helpers/test_translation.py | 71 +++++++ .../test/translations/de.json | 10 + .../test/translations/en.json | 11 + .../test/translations/es.json | 11 + 8 files changed, 425 insertions(+), 24 deletions(-) create mode 100644 tests/testing_config/custom_components/test/translations/de.json create mode 100644 tests/testing_config/custom_components/test/translations/en.json create mode 100644 tests/testing_config/custom_components/test/translations/es.json diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3c3c8474e67..ea0267b21db 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -43,7 +43,13 @@ from homeassistant.const import ( STATE_UNKNOWN, EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + callback, + get_release_channel, +) from homeassistant.exceptions import ( HomeAssistantError, InvalidStateError, @@ -245,6 +251,7 @@ class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): has_entity_name: bool = False name: str | UndefinedType | None = UNDEFINED translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None unit_of_measurement: str | None = None @@ -429,6 +436,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "state", "supported_features", "translation_key", + "translation_placeholders", "unique_id", "unit_of_measurement", } @@ -473,6 +481,9 @@ class Entity( # If we reported this entity was added without its platform set _no_platform_reported = False + # If we reported the name translation placeholders do not match the name + _name_translation_placeholders_reported = False + # Protect for multiple updates _update_staged = False @@ -537,6 +548,7 @@ class Entity( _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None _attr_translation_key: str | None + _attr_translation_placeholders: Mapping[str, str] _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None @@ -628,6 +640,29 @@ class Entity( f".{self.translation_key}.name" ) + def _substitute_name_placeholders(self, name: str) -> str: + """Substitute placeholders in entity name.""" + try: + return name.format(**self.translation_placeholders) + except KeyError as err: + if not self._name_translation_placeholders_reported: + if get_release_channel() != "stable": + raise HomeAssistantError("Missing placeholder %s" % err) from err + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) has translation placeholders '%s' which do not " + "match the name '%s', please %s" + ), + self.entity_id, + type(self), + self.translation_placeholders, + name, + report_issue, + ) + self._name_translation_placeholders_reported = True + return name + def _name_internal( self, device_class_name: str | None, @@ -643,7 +678,7 @@ class Entity( ): if TYPE_CHECKING: assert isinstance(name, str) - return name + return self._substitute_name_placeholders(name) if hasattr(self, "entity_description"): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): @@ -853,6 +888,16 @@ class Entity( return self.entity_description.translation_key return None + @final + @cached_property + def translation_placeholders(self) -> Mapping[str, str]: + """Return the translation placeholders for translated entity's name.""" + if hasattr(self, "_attr_translation_placeholders"): + return self._attr_translation_placeholders + if hasattr(self, "entity_description"): + return self.entity_description.translation_placeholders or {} + return {} + # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they # are used to perform a very specific function. Overwriting these may diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index eac5cdb0a3f..4e13707257b 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping import logging +import string from typing import Any from homeassistant.core import HomeAssistant, callback @@ -242,6 +243,42 @@ class _TranslationCache: self.loaded[language].update(components) + def _validate_placeholders( + self, + language: str, + updated_resources: dict[str, Any], + cached_resources: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Validate if updated resources have same placeholders as cached resources.""" + if cached_resources is None: + return updated_resources + + mismatches: set[str] = set() + + for key, value in updated_resources.items(): + if key not in cached_resources: + continue + tuples = list(string.Formatter().parse(value)) + updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None} + + tuples = list(string.Formatter().parse(cached_resources[key])) + cached_placeholders = {tup[1] for tup in tuples if tup[1] is not None} + if updated_placeholders != cached_placeholders: + _LOGGER.error( + ( + "Validation of translation placeholders for localized (%s) string " + "%s failed" + ), + language, + key, + ) + mismatches.add(key) + + for mismatch in mismatches: + del updated_resources[mismatch] + + return updated_resources + @callback def _build_category_cache( self, @@ -274,12 +311,14 @@ class _TranslationCache: ).setdefault(category, {}) if isinstance(resource, dict): - category_cache.update( - recursive_flatten( - f"component.{component}.{category}.", - resource, - ) + resources_flatten = recursive_flatten( + f"component.{component}.{category}.", + resource, ) + resources_flatten = self._validate_placeholders( + language, resources_flatten, category_cache + ) + category_cache.update(resources_flatten) else: category_cache[f"component.{component}.{category}"] = resource diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr index cec9d05c8e1..70f86feaf79 100644 --- a/tests/helpers/snapshots/test_entity.ambr +++ b/tests/helpers/snapshots/test_entity.ambr @@ -11,11 +11,12 @@ 'key': 'blah', 'name': , 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_entity_description_as_dataclass.1 - "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None)" + "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, translation_placeholders=None, unit_of_measurement=None)" # --- # name: test_extending_entity_description dict({ @@ -30,11 +31,12 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.1 - "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.10 dict({ @@ -50,11 +52,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.11 - "test_extending_entity_description..ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.12 dict({ @@ -70,11 +73,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.13 - "test_extending_entity_description..ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.14 dict({ @@ -90,11 +94,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.15 - "test_extending_entity_description..ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.16 dict({ @@ -110,11 +115,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.17 - "test_extending_entity_description..ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.18 dict({ @@ -130,11 +136,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.19 - "test_extending_entity_description..ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.2 dict({ @@ -149,6 +156,7 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- @@ -166,11 +174,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.21 - "test_extending_entity_description..ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.22 dict({ @@ -186,11 +195,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.23 - "test_extending_entity_description..ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.24 dict({ @@ -206,11 +216,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.25 - "test_extending_entity_description..ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.26 dict({ @@ -226,11 +237,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.27 - "test_extending_entity_description..ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.28 dict({ @@ -246,14 +258,15 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.29 - "test_extending_entity_description..ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.3 - "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.30 dict({ @@ -267,11 +280,12 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.31 - "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" + "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None)" # --- # name: test_extending_entity_description.4 dict({ @@ -287,11 +301,12 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.5 - "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extension='ext', extra='foo')" + "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extension='ext', extra='foo')" # --- # name: test_extending_entity_description.6 dict({ @@ -307,11 +322,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.7 - "test_extending_entity_description..ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.8 dict({ @@ -327,9 +343,10 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.9 - "test_extending_entity_description..ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index ef23687a166..dd26b947f67 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1137,6 +1137,203 @@ async def test_friendly_name_description_device_class_name( ) +@pytest.mark.parametrize( + ( + "has_entity_name", + "translation_key", + "translations", + "placeholders", + "expected_friendly_name", + ), + ( + (False, None, None, None, "Entity Blu"), + (True, None, None, None, "Device Bla Entity Blu"), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "English ent" + }, + }, + None, + "Device Bla English ent", + ), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent" + }, + }, + {"placeholder": "special"}, + "Device Bla special English ent", + ), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "English ent {placeholder}" + }, + }, + {"placeholder": "special"}, + "Device Bla English ent special", + ), + ), +) +async def test_entity_name_translation_placeholders( + hass: HomeAssistant, + has_entity_name: bool, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + expected_friendly_name: str | None, +) -> None: + """Test friendly name when the entity name translation has placeholders.""" + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + ent = MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + ) + ent.entity_description = entity.EntityDescription( + "test", + has_entity_name=has_entity_name, + translation_key=translation_key, + name="Entity Blu", + ) + if placeholders is not None: + ent._attr_translation_placeholders = placeholders + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + await _test_friendly_name(hass, ent, expected_friendly_name) + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "release_channel", + "expected_error", + ), + ( + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "stable", + ( + "has translation placeholders '{'placeholder': 'special'}' which do " + "not match the name '{placeholder} English ent {2ndplaceholder}'" + ), + ), + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "beta", + "HomeAssistantError: Missing placeholder '2ndplaceholder'", + ), + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent" + }, + }, + None, + "stable", + ( + "has translation placeholders '{}' which do " + "not match the name '{placeholder} English ent'" + ), + ), + ), +) +async def test_entity_name_translation_placeholder_errors( + hass: HomeAssistant, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + release_channel: str, + expected_error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test entity name translation has placeholder issues.""" + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([ent]) + return True + + ent = MockEntity( + unique_id="qwer", + ) + ent.entity_description = entity.EntityDescription( + "test", + has_entity_name=True, + translation_key=translation_key, + name="Entity Blu", + ) + if placeholders is not None: + ent._attr_translation_placeholders = placeholders + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + caplog.clear() + + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ), patch( + "homeassistant.helpers.entity.get_release_channel", return_value=release_channel + ): + await entity_platform.async_setup_entry(config_entry) + + assert expected_error in caplog.text + + @pytest.mark.parametrize( ("has_entity_name", "entity_name", "expected_friendly_name"), ( diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 62152299932..350e706ca1d 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -98,6 +98,77 @@ def test_load_translations_files(hass: HomeAssistant) -> None: } +@pytest.mark.parametrize( + ("language", "expected_translation", "expected_errors"), + ( + ( + "en", + { + "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other2.name": "Other 2", + "component.test.entity.switch.other3.name": "Other 3", + "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.outlet.name": "Outlet {placeholder}", + }, + [], + ), + ( + "es", + { + "component.test.entity.switch.other1.name": "Otra 1", + "component.test.entity.switch.other2.name": "Otra 2", + "component.test.entity.switch.other3.name": "Otra 3", + "component.test.entity.switch.other4.name": "Otra 4", + "component.test.entity.switch.outlet.name": "Enchufe {placeholder}", + }, + [], + ), + ( + "de", + { + # Correct + "component.test.entity.switch.other1.name": "Anderes 1", + # Translation has placeholder missing in English + "component.test.entity.switch.other2.name": "Other 2", + # Correct (empty translation) + "component.test.entity.switch.other3.name": "", + # Translation missing + "component.test.entity.switch.other4.name": "Other 4", + # Mismatch in placeholders + "component.test.entity.switch.outlet.name": "Outlet {placeholder}", + }, + [ + "component.test.entity.switch.other2.name", + "component.test.entity.switch.outlet.name", + ], + ), + ), +) +async def test_load_translations_files_invalid_localized_placeholders( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, + language: str, + expected_translation: dict, + expected_errors: bool, +) -> None: + """Test the load translation files with invalid localized placeholders.""" + caplog.clear() + translations = await translation.async_get_translations( + hass, language, "entity", ["test"] + ) + assert translations == expected_translation + + assert ("Validation of translation placeholders" in caplog.text) == ( + len(expected_errors) > 0 + ) + for expected_error in expected_errors: + assert ( + f"Validation of translation placeholders for localized ({language}) string {expected_error} failed" + in caplog.text + ) + + async def test_get_translations( hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None ) -> None: diff --git a/tests/testing_config/custom_components/test/translations/de.json b/tests/testing_config/custom_components/test/translations/de.json new file mode 100644 index 00000000000..57d26f28ec0 --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/de.json @@ -0,0 +1,10 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Anderes 1" }, + "other2": { "name": "Anderes 2 {placeholder}" }, + "other3": { "name": "" }, + "outlet": { "name": "Steckdose {something}" } + } + } +} diff --git a/tests/testing_config/custom_components/test/translations/en.json b/tests/testing_config/custom_components/test/translations/en.json new file mode 100644 index 00000000000..56404508c4c --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/en.json @@ -0,0 +1,11 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Other 1" }, + "other2": { "name": "Other 2" }, + "other3": { "name": "Other 3" }, + "other4": { "name": "Other 4" }, + "outlet": { "name": "Outlet {placeholder}" } + } + } +} diff --git a/tests/testing_config/custom_components/test/translations/es.json b/tests/testing_config/custom_components/test/translations/es.json new file mode 100644 index 00000000000..62624ad5db6 --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/es.json @@ -0,0 +1,11 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Otra 1" }, + "other2": { "name": "Otra 2" }, + "other3": { "name": "Otra 3" }, + "other4": { "name": "Otra 4" }, + "outlet": { "name": "Enchufe {placeholder}" } + } + } +}