Test translation coverage and dynamic fallbacks
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
@@ -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]}
|
||||
|
||||
Reference in New Issue
Block a user