diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index bc7de8b..0f4fee5 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -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. diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index cd137e3..0a1b420 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -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( diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index a6885cc..f2e25a9 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -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' diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py index 1b9bacf..44b73d4 100644 --- a/tests/test_entity_naming.py +++ b/tests/test_entity_naming.py @@ -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' diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index a9ea555..c123410 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -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: diff --git a/tests/test_select_display.py b/tests/test_select_display.py index bfc03df..3476c9a 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -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 diff --git a/tests/test_select_options.py b/tests/test_select_options.py index 293cc41..6a64719 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -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'] diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 0000000..ab94b81 --- /dev/null +++ b/tests/test_translations.py @@ -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() diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 8e2005f..f467072 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -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]}