Files
localthings/tests/test_translations.py
T
Marc Billow 21af5708cb Fix PM unit codepoint and document the /sensors/vs/0 grade column
Review follow-up to PR #365, which landed the washer 0A/B0 labels and the
air-purifier PM device classes.

The three particulate units were spelled with U+00B5 MICRO SIGN. Home
Assistant's DEVICE_CLASS_UNITS holds only the U+03BC GREEK SMALL LETTER MU
spelling, so every purifier logged a per-entity "not a valid unit for the
device class" warning asking the user to file a bug against us. The two
characters render identically, and the PR's own test hardcoded the wrong
one, so the test agreed with the bug. That test now takes the expected
unit from HA's own constant, and a new registry-wide guard
(test_sensor_device_class_units.py, mirroring the SwitchDesc guard from
issue #349) checks every SensorDesc unit against HA -- these were the only
three invalid pairs among 29.

Getting this in before release matters more than usual: the recorder
writes unit_of_measurement into long-term statistics, so correcting it
afterwards would raise a "units changed" repair for anyone who had run the
released version.

Also settles what the second element of a dust reading's value[] is, which
was the open question behind issue #325's request for another dump. It is
the device's own graded air-quality level: it appears only on the fields
carrying a magnitude (Dust/FineDust/SuperFineDust/CO2) and not on
Odor/CleanLevel, which are grades already; it reads 0-2 against index 0's
0-31; and CleanLevel equals the highest per-field grade on 9 of the 11
fixtures reporting the resource. It stays unbound -- ARTIK051_TVTL grades
good air as 0 while every other family uses 1, so a shared descriptor
would need a per-family offset -- but it is what confirms the PM mapping
without relying on field names: 18 grades one step above the floor as
SuperFineDust yet sits at the floor as Dust, on two families that both
floor at 1, so the firmware itself treats the three fields as different
scales ordered coarse-to-fine. Each field's floor boundary also brackets
the Korean CAI band for its tier (PM10 at 30/31, PM2.5 at 15/16). Pinned
against the shipped fixtures in test_air_quality_grade_column.py.

air_monitor keeps its untyped sensors, but the docstring now gives the
real reason: the evidence carries over, and what is deliberately deferred
is the statistics migration for entities shipped unitless since issue #210.

Smaller fixes: en.json's "Mixed load" -> "Mixed Load" to match the
catalog's title casing and issue #363's own wording; de/ko gave B0 the
same string as the existing "34" Mixed, so a machine exposing both showed
two identical options; washer.py's shared-label list still said "'24'
Towels", which went stale when issue #343 found 24/33 transposed; and
0A/B0 now have a locale-wide translation guard like every other confirmed
code batch.
2026-08-15 00:23:53 +00:00

498 lines
16 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_confirmed_washer_table_02_ww90dg5g34able_course_names():
"""Issue #363: 0A/B0 rendered as raw hex on a WW90DG5G34ABLE
(DA_WM_TP1_21_COMMON), whose other Table_02 labels the reporter
confirmed were already correct.
0A joins 33/54/70 as a Towels code -- checked in every locale, since a
locale that translated 0A differently from the Towels codes it shares a
meaning with would still pass the key-topology test, the same gap
issue #343 fell through.
"""
for language in _languages():
states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
assert states["0a"] == states["33"], language
assert states["b0"] != states["34"], language
english = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
assert {code: english[code] for code in ("0a", "b0")} == {
"0a": "Towels",
"b0": "Mixed Load",
}
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 == []