"""Translation catalog architecture tests. Home Assistant loads a custom integration's ``translations/.json`` directly -- there is no ``strings.json`` step and no ``[%key:...%]`` resolution, both of which belong to Core's build tooling. So ``translations/en.json`` is the source of truth here, and these tests hold the two invariants that follow from that: every translation key the Python side names has to exist in it, and every other language has to mirror its shape. """ 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 INTEGRATION = Path(__file__).parents[1] / "custom_components" / "localthings" TRANSLATIONS = INTEGRATION / "translations" def _load(language: str) -> dict: return json.loads((TRANSLATIONS / f"{language}.json").read_text(encoding="utf-8")) def _languages() -> list[str]: return sorted(path.stem for path in TRANSLATIONS.glob("*.json")) 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_every_language_mirrors_the_english_catalog(): """English is the complete catalog; the rest must match it key for key. A missing key silently falls back to English at runtime, so checking the shape is the only way to notice a half-finished translation. """ english = _load("en") english_strings = dict(_walk_strings(english)) for language in _languages(): if language == "en": continue translated = _load(language) assert _topology(english) == _topology(translated), language translated_strings = dict(_walk_strings(translated)) for path, value in english_strings.items(): # Placeholders are substituted by name, so a translation that # drops or invents one renders a literal '{...}' in the UI. assert _placeholders(value) == _placeholders(translated_strings[path]), (language, path) def test_no_catalog_carries_unresolved_core_references(): """``[%key:...%]`` never resolves for a custom integration. Core's build tooling expands these; nothing does for us, so a reference left in a catalog would reach the UI verbatim. """ for language in _languages(): unresolved = [ (path, value) for path, value in _walk_strings(_load(language)) if "[%key:" in value ] assert unresolved == [], language def test_confirmed_korean_table_02_washer_course_names(): states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"] assert { code: states[code] for code in ( "69", "6a", "6b", "6c", "6d", "6e", "6f", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "88", ) } == { "69": "AI Wash", "6a": "Wool", "6b": "Denim", "6c": "Blouses", "6d": "Delicates", "6e": "Active Wear", "6f": "Bedding", "70": "Towels", "71": "Quick Wash", "72": "Shirts", "73": "Sanitize", "74": "Drum Clean", "75": "Outdoor", "76": "Baby Care", "77": "Cottons", "78": "Rinse + Spin", "79": "Spin Only", "88": "Pet Care", } def test_confirmed_washer_table_02_towels_bedding_are_not_swapped(): """Issue #343: DA_WM_TP1_21_COMMON's Table_02 had 24/33 transposed -- selecting 'Towels' in HA ran the washer's Bedding cycle and vice versa. 24 must agree with the 69/6A-79/88 family's own Bedding/Towels pair (6f/70), not with each other. Checked in every locale catalog, not just English: the underlying bug is a device-code mapping error, not a wording issue, and test_every_language_mirrors_the_english_catalog only checks key topology -- a locale-specific 24/33 swap (the exact shape of #343) would still pass that test.""" for language in _languages(): states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"] assert states["24"] == states["6f"], language assert states["33"] == states["70"], language def test_confirmed_washer_table_02_missing_course_names(): """Issue #342: 06/08/a0 had no translation and fell back to the raw device code in the UI; 74 was already translated by the time this landed and is pinned here only as a "didn't regress" anchor.""" states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"] assert {code: states[code] for code in ("06", "08", "74", "a0")} == { "06": "XXL Laundry", "08": "Rinse+Spin", "74": "Drum Clean", "a0": "15' Quick Wash", } def test_reported_washer_standard_courses_all_have_table_02_labels(): """Every non-personal code in the reported washer's live course list must resolve through the Table_02 catalog instead of appearing as raw text. F1/F3 deliberately come from device-provided personal names. """ states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"] reported = { "69", "6f", "73", "75", "78", "01", "71", "96", "88", "70", "6d", "6a", "76", "72", "6c", "6e", "6b", "77", "74", "79", } assert reported <= states.keys() def test_confirmed_dishwasher_course_names(): states = _load("en")["entity"]["select"]["dishwasher_cycle"]["state"] assert { code: states[code] for code in ( "82", "8a", "a7", "a8", "8c", "88", ) } == { "82": "Auto", "8a": "Normal", "a7": "Heavy", "a8": "Express", "8c": "Extra Silence", "88": "Self Clean", } def test_confirmed_course_names_are_localized(): washer_codes = ( "69", "6a", "6b", "6c", "6d", "6e", "6f", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "88", ) dishwasher_codes = ("82", "8a", "a7", "a8", "8c", "88") expected = { "cs": { "washer": ( "AI praní", "Vlna", "Džíny", "Halenky", "Jemné prádlo", "Sportovní oblečení", "Ložní prádlo", "Ručníky", "Rychlé praní", "Košile", "Dezinfekce", "Čištění bubnu", "Outdoor", "Dětské potřeby", "Bavlna", "Máchání + odstřeďování", "Pouze odstřeďování", "Péče o domácí mazlíčky", ), "dishwasher": ( "Automatický", "Normální", "Intenzivní", "Expresní", "Extra tichý", "Samočištění", ), }, "nl": { "washer": ( "AI wassen", "Wol", "Spijkergoed", "Blouses", "Fijne was", "Sportkleding", "Beddengoed", "Handdoeken", "Snelle was", "Overhemden", "Hygiëne", "Trommel reinigen", "Outdoor", "Babyverzorging", "Katoen", "Spoelen + centrifugeren", "Alleen centrifugeren", "Huisdierverzorging", ), "dishwasher": ( "Auto", "Normaal", "Intensief", "Express", "Extra stil", "Zelfreiniging", ), }, } for language, translations in expected.items(): catalog = _load(language)["entity"]["select"] washer = catalog["washer_cycle_table_02"]["state"] dishwasher = catalog["dishwasher_cycle"]["state"] assert tuple(washer[code] for code in washer_codes) == translations["washer"] assert tuple(dishwasher[code] for code in dishwasher_codes) == (translations["dishwasher"]) # The hood fan is its device's primary feature: fan.py sets _attr_name = None # so it presents as the device itself, and never reads a catalog name. Same # for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed # levels, no presets, same _attr_name = None treatment. # # The EHS DHW water_heater is deliberately NOT in here: it is one loop of a # two-loop device rather than the device itself, so it carries a catalog # name like everything else (entity.water_heater.dhw, via the descriptor's # translation_key). That is independent of its *states* -- those are all # HA's own standard water_heater states (STATE_ECO/HEAT_PUMP/HIGH_DEMAND/ # PERFORMANCE/OFF), which Home Assistant translates itself via the # entity_component fallback, so no per-state entry is needed either way. UNNAMED_DESCRIPTORS = { ("fan", "fan"), ("fan", "airflow_fan"), } def test_every_descriptor_has_an_entity_catalog_entry(): """A descriptor's name comes from the catalog or nowhere. ``translation_key`` defaults to ``desc.key`` (entity.py), and there is no Python-side name to fall back on, so a descriptor with no catalog entry is an entity with no name. """ entity_strings = _load("en")["entity"] missing = [] for desc in _all_descriptions(): platform = PLATFORM_OF[type(desc)] if (platform, desc.key) in UNNAMED_DESCRIPTORS: continue translation_key = desc.translation_key if callable(translation_key): # Runtime table resolvers pick their key out of the catalog # itself (see laundry.cycle_select), so there's nothing static # to check here; the generic 'cycle' fallback is asserted below. continue if translation_key is None: translation_key = desc.key if translation_key not in entity_strings.get(platform, {}): missing.append((platform, desc.key, translation_key)) assert missing == [] # cycle_select falls back to 'cycle' for any course table without its # own entry, and resolves to '_cycle_' where there is one. select_strings = entity_strings["select"] for key in ("cycle", "washer_cycle_table_02", "dryer_cycle_table_03"): assert key in select_strings def test_all_entity_state_translation_keys_are_lowercase(): entity_strings = _load("en")["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() def test_every_ac_convenient_mode_code_has_a_preset_label(): """issue #91 review feedback #3: AC preset resolution is fully dynamic (climate._preset_to_ha), so every fixture's /mode/convenient/vs/0 supportedModes code surfaces as a preset -- an unlabelled one falls back to its raw device code in the UI. Cheap guard against repeating that gap: every non-'Off' code across every AC fixture must either resolve to one of HA's own auto-localized standard presets or have an explicit label in en.json. """ from homeassistant.components.climate.const import ( PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, PRESET_HOME, PRESET_SLEEP, ) standard = { PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, PRESET_HOME, PRESET_SLEEP, } preset_labels = set( _load("en")["entity"]["climate"]["airconditioner"]["state_attributes"]["preset_mode"][ "state" ] ) fixtures_dir = Path(__file__).parent / "fixtures" missing = [] for path in sorted(fixtures_dir.glob("airconditioner*_device.json")): dump = json.loads(path.read_text()) conv = next( ( item for item in dump.get("device0", []) if item.get("href") == "/mode/convenient/vs/0" ), None, ) if not conv: continue for code in conv["rep"].get("x.com.samsung.da.supportedModes", []): if code == "Off": continue label = code.lower() if label in standard or label in preset_labels: continue missing.append((path.name, code)) assert missing == [] def test_every_kimchi_zone_supportmode_code_has_a_state_label(): """Same guard as the AC preset one above, for KIMCHI_ZONE's kimchi_zone_mode select (fridge.py, issue #26): the write path resolved from options_field is fully dynamic too, so an unlabelled supportMode code across any /status/kimchi//vs/0 resource would silently render as its raw device token instead of the translated state. """ state_labels = set(_load("en")["entity"]["select"]["kimchi_zone_mode"]["state"]) fixtures_dir = Path(__file__).parent / "fixtures" missing = [] for path in sorted(fixtures_dir.glob("*_device.json")): dump = json.loads(path.read_text()) for item in dump.get("device0", []): href = item.get("href", "") if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")): continue missing.extend( (path.name, href, code) for code in item["rep"].get("x.com.samsung.da.supportMode", []) if code.lower() not in state_labels ) assert missing == []