Test translation coverage and dynamic fallbacks

This commit is contained in:
Hmmbob
2026-07-24 18:08:29 +02:00
parent f9ebc8e286
commit b85af10ae1
9 changed files with 252 additions and 49 deletions
+34
View File
@@ -38,6 +38,7 @@ async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
DOMAIN, context={'source': 'user'} DOMAIN, context={'source': 'user'}
) )
assert result['type'] == FlowResultType.FORM assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'user_reuse'
assert CONF_CA_CERT_PEM not in result['data_schema'].schema assert CONF_CA_CERT_PEM not in result['data_schema'].schema
assert CONF_CA_KEY_PEM not in result['data_schema'].schema assert CONF_CA_KEY_PEM not in result['data_schema'].schema
@@ -219,6 +220,39 @@ async def test_unknown_type_shows_confirmation_step(
assert result['data'][CONF_HOST] == MOCK_HOST assert result['data'][CONF_HOST] == MOCK_HOST
async def test_unknown_type_without_version_uses_localized_step(
hass: HomeAssistant,
) -> None:
"""No English placeholder sentinel leaks into a translated description."""
probe_result = {
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': 'leaf cert',
'leaf_key_pem': 'leaf key',
'one_ui_version': '',
'device_type_recognized': False,
}
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
return_value=probe_result,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'confirm_unknown_type_no_version'
assert not result.get('description_placeholders')
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None: async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
"""Second add of same serial: flow aborts. """Second add of same serial: flow aborts.
+6 -2
View File
@@ -914,9 +914,12 @@ async def test_send_command_blocked_when_remote_control_disabled(
with patch.object(fake, 'subscribe'): with patch.object(fake, 'subscribe'):
fake.post = _post fake.post = _post
with pytest.raises(ServiceValidationError, match="Remote control is turned off"): with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, 5) await coordinator.async_send_command(bound, 5)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == 'remote_control_disabled'
assert posted is False assert posted is False
@@ -977,8 +980,9 @@ async def test_send_command_remote_control_check_precedes_validate_fn(
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn) desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with pytest.raises(ServiceValidationError, match="Remote control is turned off"): with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, 'On') await coordinator.async_send_command(bound, 'On')
assert exc_info.value.translation_key == 'remote_control_disabled'
async def test_send_command_bypasses_remote_control_when_option_enabled( async def test_send_command_bypasses_remote_control_when_option_enabled(
+1 -1
View File
@@ -69,7 +69,7 @@ def test_course_bound_to_shared_course_vs_0():
assert callable(desc.translation_key) assert callable(desc.translation_key)
table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}} table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}}
assert desc.translation_key(table_03) == 'dryer_cycle_table_03' assert desc.translation_key(table_03) == 'dryer_cycle_table_03'
assert desc.translation_key({}) is None assert desc.translation_key({}) == 'cycle'
assert desc.options is laundry.cycle_options assert desc.options is laundry.cycle_options
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']} rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
assert desc.rep_fn(rep) == '16' assert desc.rep_fn(rep) == '16'
+25 -16
View File
@@ -1,7 +1,4 @@
"""Tests for LocalThingsEntity's display-name derivation """Tests for translated entity naming and dynamic instance placeholders."""
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
from custom_components.localthings.entity import LocalThingsEntity from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity from custom_components.localthings.registry.discovery import BoundEntity
@@ -23,23 +20,35 @@ def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_
return LocalThingsEntity(_FakeCoordinator(), bound) return LocalThingsEntity(_FakeCoordinator(), bound)
def test_explicit_name_wins_over_everything(): def test_explicit_fallback_name_uses_translation_instead_of_attr_name():
desc = BinarySensorDesc(key='enabled', name='Explicit Name') """An _attr_name would take precedence over HA's translation catalog."""
desc = BinarySensorDesc(key='enabled', name='Explicit name')
entity = _make_entity(desc, instance_name='Cubed Ice') entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity._attr_name == 'Explicit Name' assert entity.translation_key == 'enabled'
assert not hasattr(entity, '_attr_name')
def test_instance_name_prefixes_the_derived_suffix(): def test_device_instance_name_becomes_translation_placeholder():
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces desc = BinarySensorDesc(
the href-derived instance label ("Icemaker One") as the name prefix, key='enabled', translation_key='instance_enabled', use_instance_name=True
keeping the same entity-specific suffix.""" )
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled', entity = _make_entity(desc, key_override='icemaker_one_enabled',
instance_name='Cubed Ice') instance_name='Cubed Ice')
assert entity._attr_name == 'Cubed Ice Enabled' assert entity.translation_key == 'instance_enabled'
assert entity.translation_placeholders == {'instance_name': 'Cubed Ice'}
assert not hasattr(entity, '_attr_name')
def test_no_instance_name_falls_back_to_derived_state_key(): def test_href_instance_name_becomes_translation_placeholder():
desc = BinarySensorDesc(key='enabled') desc = BinarySensorDesc(
key='enabled', translation_key='instance_enabled', use_instance_name=True
)
entity = _make_entity(desc, key_override='icemaker_one_enabled') entity = _make_entity(desc, key_override='icemaker_one_enabled')
assert entity._attr_name == 'Icemaker One Enabled' assert entity.translation_placeholders == {'instance_name': 'Icemaker One'}
def test_untranslated_vendor_entity_keeps_readable_fallback_name():
desc = BinarySensorDesc(key='vendor_feature')
entity = _make_entity(desc, key_override='vendor_feature_1')
assert entity.translation_key is None
assert entity._attr_name == 'Vendor Feature 1'
+10 -15
View File
@@ -173,11 +173,9 @@ class TestCycleSelectTableGating:
codes aren't guaranteed consistent across board generations sharing codes aren't guaranteed consistent across board generations sharing
the same /course/vs/0 contract; FlexWash's older board reports a the same /course/vs/0 contract; FlexWash's older board reports a
different course table than every device the shipped translations different course table than every device the shipped translations
were confirmed against). The resolved key is built from whatever table were confirmed against). The resolved key is built from a reported
the device actually reports -- a table with no strings.json entries table id. Devices without one use the generic translated Cycle entity
yet just falls through Home Assistant's own missing-translation name while preserving raw option codes as a safe fallback."""
handling to raw-code display, the same as any individual untranslated
code within an existing table."""
def _desc(self): def _desc(self):
return laundry.cycle_select( return laundry.cycle_select(
@@ -198,20 +196,17 @@ class TestCycleSelectTableGating:
assert callable(desc.translation_key) assert callable(desc.translation_key)
assert desc.translation_key(resources) == 'washer_cycle_table_02' assert desc.translation_key(resources) == 'washer_cycle_table_02'
def test_resolved_key_reflects_an_unbuilt_table_too(self): def test_untranslated_table_uses_generic_cycle_key(self):
"""No gating against a hardcoded 'known good' table -- a table we """An unknown table does not claim another board's state labels."""
haven't shipped translations for yet still gets a key built for
it, just one strings.json has nothing under (raw-code display)."""
desc = self._desc() desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(resources) == 'washer_cycle_table_00' assert desc.translation_key(resources) == 'cycle'
def test_resolves_to_none_when_table_id_is_unknown(self): def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
"""No href, or an empty rep, gets no translation_key at all -- """An absent table id still gets a translated generic entity name."""
there's nothing to build a key from."""
desc = self._desc() desc = self._desc()
assert desc.translation_key({}) is None assert desc.translation_key({}) == 'cycle'
assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle'
class TestBuzzerSound: class TestBuzzerSound:
+9
View File
@@ -34,5 +34,14 @@ def test_display_lowercases_for_translation_key_lookup():
assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3' assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3'
def test_unknown_translated_vendor_value_keeps_readable_fallback():
"""A firmware-added value must remain readable instead of being mangled."""
assert _display('FutureVendorMode', _TRANSLATED) == 'Future Vendor Mode'
def test_known_camel_case_state_uses_snake_case_translation_key():
assert _display('ExtraHigh', 'heated_dry') == 'extra_high'
def test_display_passes_through_non_string_values(): def test_display_passes_through_non_string_values():
assert _display(None, _UNTRANSLATED) is None assert _display(None, _UNTRANSLATED) is None
+26
View File
@@ -74,3 +74,29 @@ def test_callable_translation_key_reresolves_live_not_once_at_construction():
resources['key'] = 'washer_cycle_table_02' resources['key'] = 'washer_cycle_table_02'
assert entity.translation_key == 'washer_cycle_table_02' assert entity.translation_key == 'washer_cycle_table_02'
async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
"""Readable fallback labels must still write the exact Samsung token."""
class _WritableCoordinator(_FakeCoordinator):
data = {'mode': 'FutureVendorMode'}
def __init__(self, last_resources):
super().__init__(last_resources)
self.writes = []
async def async_send_command(self, bound, value):
self.writes.append(value)
desc = SelectDesc(
key='mode', name='Mode', translation_key='door_alert',
options=('Known', 'FutureVendorMode'), write_fn=lambda *args: None,
)
capability = Capability(href='/x/vs/0', entities=(desc,))
bound = BoundEntity(href='/x/vs/0', capability=capability, desc=desc)
coordinator = _WritableCoordinator({})
entity = LocalThingsSelect(coordinator, bound)
assert entity.options[-1] == 'Future Vendor Mode'
await entity.async_select_option('Future Vendor Mode')
assert coordinator.writes == ['FutureVendorMode']
+127
View File
@@ -0,0 +1,127 @@
"""Translation architecture and catalog synchronization tests."""
from __future__ import annotations
import importlib
import json
from pathlib import Path
from string import Formatter
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.entities import PLATFORM_OF
from custom_components.localthings.select import TRANSLATED_SELECT_STATES
INTEGRATION = (
Path(__file__).parents[1] / "custom_components" / "localthings"
)
def _load(name: str) -> dict:
return json.loads((INTEGRATION / name).read_text(encoding="utf-8"))
def _topology(value):
if isinstance(value, dict):
return {key: _topology(child) for key, child in value.items()}
if isinstance(value, list):
return [_topology(child) for child in value]
return None
def _placeholders(value: str) -> set[str]:
return {
field_name
for _, field_name, _, _ in Formatter().parse(value)
if field_name is not None
}
def _walk_strings(value, path=()):
if isinstance(value, dict):
for key, child in value.items():
yield from _walk_strings(child, (*path, key))
elif isinstance(value, list):
for index, child in enumerate(value):
yield from _walk_strings(child, (*path, str(index)))
elif isinstance(value, str):
yield path, value
def _all_descriptions():
capabilities_dir = INTEGRATION / "registry" / "capabilities"
seen: set[int] = set()
for module_path in capabilities_dir.glob("*.py"):
if module_path.stem == "__init__":
continue
module = importlib.import_module(
f"custom_components.localthings.registry.capabilities.{module_path.stem}"
)
def visit(value):
if isinstance(value, Capability):
if id(value) in seen:
return
seen.add(id(value))
yield from value.entities
elif isinstance(value, (tuple, list, set)):
for child in value:
yield from visit(child)
for value in vars(module).values():
yield from visit(value)
def test_source_and_english_translation_are_identical():
assert _load("strings.json") == _load("translations/en.json")
def test_english_and_dutch_catalog_topology_and_placeholders_match():
english = _load("translations/en.json")
dutch = _load("translations/nl.json")
assert _topology(english) == _topology(dutch)
dutch_strings = dict(_walk_strings(dutch))
for path, value in _walk_strings(english):
assert _placeholders(value) == _placeholders(dutch_strings[path]), path
def test_every_translatable_descriptor_has_an_entity_catalog_entry():
entity_strings = _load("strings.json")["entity"]
missing = []
for desc in _all_descriptions():
translation_key = desc.translation_key
if callable(translation_key):
# Runtime table resolvers use the generic name-only fallback plus
# the static tables currently documented by this integration.
continue
if translation_key is None and desc.name is not None:
translation_key = desc.key
if translation_key is None:
continue # Main fan entity: device name + HA fan translations.
platform = PLATFORM_OF[type(desc)]
if translation_key not in entity_strings.get(platform, {}):
missing.append((platform, desc.key, translation_key))
assert missing == []
select_strings = entity_strings["select"]
for key in ("cycle", "washer_cycle_table_02", "dryer_cycle_table_03"):
assert key in select_strings
def test_select_state_normalization_is_synchronized_with_catalog():
select_strings = _load("strings.json")["entity"]["select"]
translated = {
key: frozenset(value["state"])
for key, value in select_strings.items()
if "state" in value
}
assert TRANSLATED_SELECT_STATES == translated
def test_all_entity_state_translation_keys_are_lowercase():
entity_strings = _load("strings.json")["entity"]
for platform in entity_strings.values():
for translation in platform.values():
for state_key in translation.get("state", {}):
assert state_key == state_key.lower()
+14 -15
View File
@@ -64,16 +64,15 @@ class TestWasherCourse:
"""Table-scoped (issue: course codes aren't guaranteed consistent """Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02 board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. The key is name was confirmed against) -- see laundry.cycle_select. Only a
built from whatever table the device reports, not gated against a verified table gets table-specific state translations."""
hardcoded 'known good' value."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert callable(desc.translation_key) assert callable(desc.translation_key)
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert desc.translation_key(table_02) == 'washer_cycle_table_02' assert desc.translation_key(table_02) == 'washer_cycle_table_02'
table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(table_00) == 'washer_cycle_table_00' assert desc.translation_key(table_00) == 'cycle'
assert desc.translation_key({}) is None assert desc.translation_key({}) == 'cycle'
def test_reads_raw_course_code_from_options_array(self): def test_reads_raw_course_code_from_options_array(self):
"""rep_fn returns the raw device code; display names come from """rep_fn returns the raw device code; display names come from
@@ -206,14 +205,11 @@ class TestDetergentSoftenerDosing:
assert self._desc('detergent_quantity').rep_fn(rep) == '7' assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self): def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key """Each entity gets its own translated name and state vocabulary."""
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers, assert self._desc('detergent_quantity').translation_key == 'detergent_quantity'
same shape as fridge.py's shared 'brightness_level' key); hardness assert self._desc('softener_quantity').translation_key == 'softener_quantity'
and concentration each have their own since their labels differ.""" assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness'
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity' assert self._desc('softener_concentration').translation_key == 'softener_concentration'
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration'
def test_quantity_and_hardness_options_decode_supported_list(self): def test_quantity_and_hardness_options_decode_supported_list(self):
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03'] assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
@@ -359,8 +355,11 @@ class TestWashOptionToggleValidation:
def test_rejected_on_an_unsupported_course(self): def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES) issue = self._desc('bubble_soak').validate_fn(
assert msg == "Bubble soak isn't available on the selected cycle." 'On', rep, _EDIT_COURSE_RESOURCES
)
assert issue.translation_key == 'bubble_soak_unavailable_for_cycle'
assert issue.translation_placeholders == {}
def test_pre_wash_and_intensive_use_their_own_availableset_field(self): def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}