477 lines
15 KiB
Python
477 lines
15 KiB
Python
"""Translation catalog architecture tests.
|
|
|
|
Home Assistant loads a custom integration's ``translations/<lang>.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 '<family>_cycle_<table>' 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/<slot>/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 == []
|