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'}
)
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_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
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:
"""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'):
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)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == 'remote_control_disabled'
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)
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')
assert exc_info.value.translation_key == 'remote_control_disabled'
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)
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({}) is None
assert desc.translation_key({}) == 'cycle'
assert desc.options is laundry.cycle_options
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
assert desc.rep_fn(rep) == '16'
+25 -16
View File
@@ -1,7 +1,4 @@
"""Tests for LocalThingsEntity's display-name derivation
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
"""Tests for translated entity naming and dynamic instance placeholders."""
from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability
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)
def test_explicit_name_wins_over_everything():
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
def test_explicit_fallback_name_uses_translation_instead_of_attr_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')
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():
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
the href-derived instance label ("Icemaker One") as the name prefix,
keeping the same entity-specific suffix."""
desc = BinarySensorDesc(key='enabled')
def test_device_instance_name_becomes_translation_placeholder():
desc = BinarySensorDesc(
key='enabled', translation_key='instance_enabled', use_instance_name=True
)
entity = _make_entity(desc, key_override='icemaker_one_enabled',
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():
desc = BinarySensorDesc(key='enabled')
def test_href_instance_name_becomes_translation_placeholder():
desc = BinarySensorDesc(
key='enabled', translation_key='instance_enabled', use_instance_name=True
)
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
the same /course/vs/0 contract; FlexWash's older board reports a
different course table than every device the shipped translations
were confirmed against). The resolved key is built from whatever table
the device actually reports -- a table with no strings.json entries
yet just falls through Home Assistant's own missing-translation
handling to raw-code display, the same as any individual untranslated
code within an existing table."""
were confirmed against). The resolved key is built from a reported
table id. Devices without one use the generic translated Cycle entity
name while preserving raw option codes as a safe fallback."""
def _desc(self):
return laundry.cycle_select(
@@ -198,20 +196,17 @@ class TestCycleSelectTableGating:
assert callable(desc.translation_key)
assert desc.translation_key(resources) == 'washer_cycle_table_02'
def test_resolved_key_reflects_an_unbuilt_table_too(self):
"""No gating against a hardcoded 'known good' table -- a table we
haven't shipped translations for yet still gets a key built for
it, just one strings.json has nothing under (raw-code display)."""
def test_untranslated_table_uses_generic_cycle_key(self):
"""An unknown table does not claim another board's state labels."""
desc = self._desc()
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):
"""No href, or an empty rep, gets no translation_key at all --
there's nothing to build a key from."""
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
"""An absent table id still gets a translated generic entity name."""
desc = self._desc()
assert desc.translation_key({}) is None
assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None
assert desc.translation_key({}) == 'cycle'
assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle'
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'
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():
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'
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
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. The key is
built from whatever table the device reports, not gated against a
hardcoded 'known good' value."""
name was confirmed against) -- see laundry.cycle_select. Only a
verified table gets table-specific state translations."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert callable(desc.translation_key)
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': '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'}}
assert desc.translation_key(table_00) == 'washer_cycle_table_00'
assert desc.translation_key({}) is None
assert desc.translation_key(table_00) == 'cycle'
assert desc.translation_key({}) == 'cycle'
def test_reads_raw_course_code_from_options_array(self):
"""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'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
same shape as fridge.py's shared 'brightness_level' key); hardness
and concentration each have their own since their labels differ."""
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
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'
"""Each entity gets its own translated name and state vocabulary."""
assert self._desc('detergent_quantity').translation_key == 'detergent_quantity'
assert self._desc('softener_quantity').translation_key == 'softener_quantity'
assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness'
assert self._desc('softener_concentration').translation_key == 'softener_concentration'
def test_quantity_and_hardness_options_decode_supported_list(self):
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):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
assert msg == "Bubble soak isn't available on the selected cycle."
issue = self._desc('bubble_soak').validate_fn(
'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):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}