diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index e6dd719..beeed3c 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -10,17 +10,23 @@ the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this device shares those capabilities' catalog entries. This board additionally reports a CO2 reading the other two families don't. -A second `value` list element on the particulate-matter types (e.g. Dust's -`['31', '2']`) reads like a coarse quality-grade code, but nothing on this -board confirms what its scale means -- left unbound rather than guessed; -index 0 is the only slot any family has ever read. +A second `value` list element on the particulate-matter types (Dust's +`['31', '2']`) is the device's own graded air-quality level for that +reading -- see common.sensor_item_value. Still unbound here: the grade's +floor differs by board family, and CleanLevel already carries the +aggregate. This board's own readings are load-bearing evidence for the +PM mapping, though: 23 grading one step above the floor as FineDust is +what rules out a PM10-width band for that field. -Dust/FineDust/SuperFineDust stay without an HA `device_class`/`unit` on -this board. The three-tier mapping (Dust=PM10, FineDust=PM2.5, -SuperFineDust=PM1, µg/m³) is confirmed for the air-purifier family -(issue #325) but this standalone monitor has no same-moment app -correlation of its own, so it keeps the untyped measurement sensors -rather than inheriting that label. +Dust/FineDust/SuperFineDust nevertheless stay without an HA +`device_class`/`unit` here, which is now a migration call rather than an +evidence gap. The mapping confirmed for the purifier family (issue #325, +Dust=PM10 / FineDust=PM2.5 / SuperFineDust=PM1 in μg/m³) rests on +device-side grading that this board shares, so it would carry over. But +these five sensors have recorded unitless long-term statistics since +issue #210, and stamping a unit onto an existing statistic is what raises +Home Assistant's "units changed" repair -- a deliberate follow-up, not +something to fold into the purifier's first typed release. """ from datetime import time as dt_time @@ -35,8 +41,9 @@ from .common import int_or_none, sensor_item_value # graded indices on that family, while this board has stamped all five as # `measurement` since it was added (issue #210). Consuming state_class would # silently drop long-term statistics for two sensors on shipped devices, and -# the pm10/pm25/pm1 labels are confirmed only for the purifier family -# (issue #325). The shared rows supply only the key/icon/type here. +# the pm10/pm25/pm1 labels are held back pending the statistics migration +# the module docstring describes. The shared rows supply only the +# key/icon/type here. SENSORS = Capability( href="/sensors/vs/0", poll_tier="warm", diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index ada0709..f479970 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -67,14 +67,38 @@ def _has_top_level_modes(rep, resources): # indices instead, where the mean of a grade isn't obviously meaningful; left # without a state_class rather than guessing. # -# device_class/unit confirmed on a live ARTIK051_TVTL against the SmartThings -# app at the same moment (issue #325): Dust=PM10, FineDust=PM2.5, -# SuperFineDust=PM1, all µg/m³. Dust matched PM10 exactly; the other two -# were 1 µg/m³ off the app, same order. +# device_class/unit: Dust=PM10, FineDust=PM2.5, SuperFineDust=PM1, all +# μg/m³ (issue #325). Three independent lines, none of them naming order -- +# which is what the earlier "plausible but unconfirmed" note rejected: +# +# 1. The device grades its own readings. Each dust item's value[] is +# [concentration, grade] (see common.sensor_item_value); the grade band +# is not shared across the three fields -- a reading of 18 grades one +# step *above* the floor as SuperFineDust (air_monitor fixture) but *at* +# the floor as Dust (range_hood fixture), both 1-based families. So the +# firmware itself treats them as three different scales ordered +# coarse-to-fine, rather than one repeated measurement. +# 2. Where each field's floor/second-band boundary falls brackets the +# Korean CAI bands: Dust good at 18, graded up at 31 (CAI PM10 breaks +# at 30/31); FineDust good at 14, graded up at 23 (CAI PM2.5 breaks at +# 15/16); SuperFineDust good at 9, graded up at 18 (PM2.5-style, which +# is what a PM1 reading gets -- there is no standard PM1 index). +# 3. A live ARTIK051_TVTL read against the SmartThings app at the same +# moment: Dust matched the app's PM10 exactly, the other two were 1 +# μg/m³ off in the same order, and the app shows exactly these three +# tiers, so there is no fourth candidate to assign. +# +# Dust >= FineDust >= SuperFineDust holds on all 11 fixtures that report +# this resource, which is the cumulative-mass ordering PM10 >= PM2.5 >= PM1 +# requires by definition. The unit literal must stay HA's own spelling of +# μg/m³ (U+03BC GREEK SMALL LETTER MU, not U+00B5 MICRO SIGN) -- they render +# alike but only U+03BC is in DEVICE_CLASS_UNITS, and the mismatch is a +# runtime warning per entity, not a test failure. Pinned by +# tests/test_sensor_device_class_units.py. _AIR_QUALITY_SENSORS = ( - ("dust", "mdi:blur", "Dust", "measurement", "pm10", "µg/m³"), - ("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "µg/m³"), - ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "µg/m³"), + ("dust", "mdi:blur", "Dust", "measurement", "pm10", "μg/m³"), + ("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "μg/m³"), + ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "μg/m³"), ("odor", "mdi:scent", "Odor", None, None, None), ("clean_level", "mdi:air-filter", "CleanLevel", None, None, None), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 5c96473..ebcc5b9 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -101,8 +101,15 @@ def _threshold_write(payload, rep, href=None): def _sensor_item_value(items, type_): """First value of the /sensors/vs/0 item with the given x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element - array; only v[0] is used, since the second element's meaning is - unconfirmed. No device_class is set: the resource exposes no unit.""" + array; only v[0] is used. v[1] is the device's own graded air-quality + level for that reading, left unbound because its floor differs by + family -- see common.sensor_item_value for the full note. + + No device_class here: unlike air_purifier (issue #325), no AC family + has had its dust readings correlated against the app, and every AC + fixture reports permanent zeros or ties, so this file's own dumps + supply no grade-band evidence either. Returns a string rather than an + int, which these diagnostic entities have always done.""" for it in items or []: if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_: v = it.get("x.com.samsung.da.value") diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index dfab158..693420a 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -254,7 +254,29 @@ def sensor_item_value(items, sensor_type, index=0): item is `{type, value: [...]}`; `index` picks which slot to read (index 0 is the raw measurement on every family seen so far). Shared by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and - air_monitor.SENSORS, which all read the same resource shape.""" + air_monitor.SENSORS, which all read the same resource shape. + + value[] is 2-element on the fields that carry a magnitude + (Dust/FineDust/SuperFineDust/CO2) and 1-element on Odor/CleanLevel. + That asymmetry is what index 1 means: it is the device's own graded + air-quality level for that reading -- the same kind of value Odor and + CleanLevel already *are*, which is why those two have no second slot. + It reads 0-2 against index 0's observed 0-31, tracks index 0 within a + device, and CleanLevel equals the highest per-field grade on 9 of the + 11 fixtures reporting this resource (the range hood and one RAC report + a higher CleanLevel than any dust grade, so they fold in something + else). + + Index 1 is deliberately left unbound rather than exposed as an entity: + its floor is not portable. ARTIK051_TVTL grades good air as 0, while + AVT-WW-TP1 / A-VTWW-TP2 / TP1X / ASM-KR-TP1 / AHD-WW-TP1 all grade it + as 1, so a shared descriptor would need a per-family offset to mean + anything, and CleanLevel already carries the aggregate. The grade is + still load-bearing as *evidence*: it is what confirms the three dust + fields are three different scales rather than one repeated reading -- + see air_purifier._AIR_QUALITY_SENSORS and + tests/test_air_quality_grade_column.py. + """ for item in items or (): if not isinstance(item, dict): continue diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index cf479d2..af289b8 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -40,10 +40,11 @@ from .laundry import ( # implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels, # Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw # code after selecting each cycle on the appliance (issue #80). 2 more -# ('0A' Towels, 'B0' Mixed load) reported for a WW90DG5G34ABLE on the same -# Table_02 family (issue #363). Two code pairs ('21'/'65' Colors, -# '27'/'5E' Rinse+Spin, and '24'/'54' Towels) legitimately share a label -# across different course tables -- not typos. +# ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same +# Table_02 family (issue #363). Several codes legitimately share a label +# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78' +# Rinse+Spin, '0A'/'33'/'54'/'70' Towels -- not typos. (This list said +# "'24' Towels" until issue #343 found 24/33 transposed; 24 is Bedding.) # # No static fallback list is kept here: other models have different actual # course sets, so hardcoding one device's list would show/hide the wrong diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index a14396a..6fee358 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -657,7 +657,7 @@ "8f": "Kaltwäsche Intensiv", "96": "Weniger Mikrofasern", "a0": "Schnelle Wäsche 15'", - "b0": "Mischwäsche", + "b0": "Gemischte Beladung", "17": "Heruntergeladen", "69": "KI-Wäsche", "6a": "Wolle", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 7c76b55..2b22310 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -714,7 +714,7 @@ "8f": "Intense Cold", "96": "Less Microfiber", "a0": "15' Quick Wash", - "b0": "Mixed load" + "b0": "Mixed Load" } }, "washer_dry_level": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index b419826..51eeb3e 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -695,7 +695,7 @@ "8f": "강력 냉수 세탁", "96": "미세플라스틱저감", "a0": "15분 쾌속세탁", - "b0": "혼합", + "b0": "혼합 세탁", "69": "AI 맞춤세탁", "6a": "울", "6b": "데님", diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index 2669d99..fc9cadb 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -31,12 +31,20 @@ def test_graded_sensors_are_left_without_a_state_class(): def test_particulate_sensors_declare_pm_device_class_and_unit(): - """Live same-moment SmartThings correlation on ARTIK051_TVTL (issue #325) - maps Dust/FineDust/SuperFineDust to PM10/PM2.5/PM1 in µg/m³.""" + """Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 (issue #325) -- see + air_purifier._AIR_QUALITY_SENSORS for the three lines of evidence and + tests/test_air_quality_grade_column.py for the device-side ones. + + The expected unit comes from HA's own constant rather than a literal: + typing it out is how PR #365 landed U+00B5 MICRO SIGN where HA uses + U+03BC, which renders identically and would make this test agree with + the bug.""" + from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as UG_M3 + expected = { - "dust": ("pm10", "µg/m³"), - "fine_dust": ("pm25", "µg/m³"), - "super_fine_dust": ("pm1", "µg/m³"), + "dust": ("pm10", UG_M3), + "fine_dust": ("pm25", UG_M3), + "super_fine_dust": ("pm1", UG_M3), } for key, (device_class, unit) in expected.items(): desc = _desc(key) @@ -48,13 +56,17 @@ def test_particulate_sensors_declare_pm_device_class_and_unit(): assert desc.unit is None, key -def test_state_class_comes_from_the_shared_tuples_fourth_column(): - """The rows carry their own state_class rather than a parallel lookup, so - a new sensor can't be added here without deciding the question.""" +def test_metadata_comes_from_the_shared_tuples_own_columns(): + """The rows carry their own state_class/device_class/unit rather than a + parallel lookup, so a new sensor can't be added here without deciding + each question. Unit validity against HA is a separate guard -- + tests/test_sensor_device_class_units.py.""" for row in air_purifier._AIR_QUALITY_SENSORS: assert len(row) == 6, row assert row[3] in ("measurement", None), row assert row[4] in ("pm10", "pm25", "pm1", None), row + # A device_class without a unit would leave HA inferring one. + assert (row[4] is None) == (row[5] is None), row def test_air_monitor_keeps_stamping_every_shared_sensor(): diff --git a/tests/test_air_quality_grade_column.py b/tests/test_air_quality_grade_column.py new file mode 100644 index 0000000..ba93d42 --- /dev/null +++ b/tests/test_air_quality_grade_column.py @@ -0,0 +1,135 @@ +"""What the second element of a /sensors/vs/0 dust reading means, and why +it is what confirms Dust/FineDust/SuperFineDust are PM10/PM2.5/PM1. + +`x.com.samsung.da.value` is `[concentration, grade]` on the fields that +carry a magnitude and `[grade]` on Odor/CleanLevel, which are grades +already. Index 1 is never bound to an entity (its floor differs by board +family), but it is the device's own opinion about its own readings, and +that makes it the one piece of evidence for the PM mapping that doesn't +depend on Samsung's field names or on a user's screenshot. + +These assertions read the shipped fixtures rather than restating numbers, +so a re-captured dump that contradicts the mapping fails here instead of +silently weakening the argument in air_purifier.py's comment. +""" + +import json +import pathlib + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" +DUST_TYPES = ("Dust", "FineDust", "SuperFineDust") + + +def _items(fixture: str): + dump = json.loads((FIXTURES / f"{fixture}_device.json").read_text(encoding="utf-8")) + for entry in dump["device0"]: + if entry.get("href") == "/sensors/vs/0": + return { + item.get("x.com.samsung.da.type"): item.get("x.com.samsung.da.value") + for item in entry.get("rep", {}).get("x.com.samsung.da.items") or [] + } + raise AssertionError(f"{fixture} has no /sensors/vs/0") + + +def _fixtures_reporting_sensors(): + for path in sorted(FIXTURES.glob("*_device.json")): + dump = json.loads(path.read_text(encoding="utf-8")) + entries = dump.get("device0") + if not isinstance(entries, list): + continue + if any(e.get("href") == "/sensors/vs/0" for e in entries): + name = path.name.removesuffix("_device.json") + if any(t in _items(name) for t in DUST_TYPES): + yield name + + +def test_magnitude_fields_carry_a_grade_and_graded_fields_do_not(): + """The shape asymmetry is the whole argument for what index 1 is: the + fields that already *are* grades have no second slot.""" + checked = 0 + for fixture in _fixtures_reporting_sensors(): + items = _items(fixture) + for type_ in (*DUST_TYPES, "CO2"): + if type_ in items: + assert len(items[type_]) == 2, (fixture, type_, items[type_]) + checked += 1 + for type_ in ("Odor", "CleanLevel"): + if type_ in items: + assert len(items[type_]) == 1, (fixture, type_, items[type_]) + assert checked >= 30 + + +def test_concentration_falls_with_particle_size_on_every_fixture(): + """PM10 >= PM2.5 >= PM1 by definition -- they are cumulative masses, so + a violation would mean the three fields aren't nested size tiers at + all.""" + for fixture in _fixtures_reporting_sensors(): + items = _items(fixture) + if not all(t in items for t in DUST_TYPES): + continue + coarse, fine, finest = (int(items[t][0]) for t in DUST_TYPES) + assert coarse >= fine >= finest, (fixture, coarse, fine, finest) + + +def test_the_same_reading_grades_differently_as_dust_than_as_superfinedust(): + """18 is one step above the grade floor as SuperFineDust but sits *at* + the floor as Dust, on two families that both grade good air as 1. + + One shared threshold cannot produce both, so the firmware treats the + coarse field as tolerating more than the fine one -- three scales + ordered coarse-to-fine, which is what PM10/PM2.5/PM1 requires and what + "all three are the same kind of reading" cannot explain. + """ + monitor, hood = _items("air_monitor"), _items("range_hood") + assert monitor["SuperFineDust"] == ["18", "2"] + assert hood["Dust"] == ["18", "1"] + # Both families put good air at grade 1, so the two grades are + # comparable -- ARTIK051_TVTL's 0-based floor is the reason this + # comparison is drawn between these two fixtures and not against it. + assert monitor["Odor"] == ["1"] + assert hood["CleanLevel"] == ["2"] + assert _items("air_purifier")["Dust"] == ["11", "0"] + + +def test_grade_boundaries_bracket_the_korean_cai_bands(): + """Where each field crosses from its floor to the next grade lines up + with the band that field's PM tier is graded on in Korea's CAI: + PM10 breaks at 30/31, PM2.5 at 15/16. A PM1 reading has no standard + index and is graded on PM2.5-like widths. + """ + monitor, hood = _items("air_monitor"), _items("range_hood") + # Dust: still at the floor at 18, above it at 31 -> boundary in (18, 31]. + assert (hood["Dust"], monitor["Dust"]) == (["18", "1"], ["31", "2"]) + # FineDust: at the floor at 14, above it at 23 -> boundary in (14, 23]. + assert (hood["FineDust"], monitor["FineDust"]) == (["14", "1"], ["23", "2"]) + # SuperFineDust: at the floor at 9, above it at 18 -> boundary in (9, 18], + # strictly below where Dust's sits. + assert (hood["SuperFineDust"], monitor["SuperFineDust"]) == (["9", "1"], ["18", "2"]) + + +def test_clean_level_aggregates_the_per_field_grades(): + """CleanLevel is the highest per-field grade on every family except the + range hood and one RAC, which report a higher CleanLevel than any dust + grade -- those two fold in something this resource doesn't expose, so + CleanLevel is never derived from the dust grades in code.""" + exceptions = {"range_hood", "airconditioner_tp1x_da_ac_rac_01011"} + for fixture in _fixtures_reporting_sensors(): + items = _items(fixture) + if "CleanLevel" not in items: + continue + grades = [int(v[1]) for v in items.values() if len(v) == 2] + if not grades: + continue + aggregate = int(items["CleanLevel"][0]) + if fixture in exceptions: + assert aggregate > max(grades), (fixture, aggregate, grades) + else: + assert aggregate == max(grades), (fixture, aggregate, grades) + + +def test_grade_floor_is_zero_based_on_artik051_tvtl_and_one_based_elsewhere(): + """Why index 1 stays unbound: a shared descriptor would need a + per-family offset to mean anything.""" + assert _items("air_purifier")["CleanLevel"] == ["0"] + for fixture in ("air_monitor", "air_purifier_avt_ww", "air_purifier_vtww", "range_hood"): + assert int(_items(fixture)["CleanLevel"][0]) >= 1, fixture diff --git a/tests/test_sensor_device_class_units.py b/tests/test_sensor_device_class_units.py new file mode 100644 index 0000000..86873db --- /dev/null +++ b/tests/test_sensor_device_class_units.py @@ -0,0 +1,94 @@ +"""Guards against a SensorDesc unit Home Assistant won't accept for the +device_class it's paired with. + +Unlike the SwitchDesc case (issue #349), a bad sensor unit doesn't raise -- +sensor.py hands `unit` to `_attr_native_unit_of_measurement` and HA only +logs a warning per entity, once, telling the user to report a bug against +this integration. So the failure mode is a quiet stream of "not a valid +unit for the device class" warnings plus a support burden, with nothing in +the UI to hint anything is wrong. + +The specific trap this exists for: HA spells its micrograms-per-cubic-metre +unit with U+03BC GREEK SMALL LETTER MU, and DEVICE_CLASS_UNITS holds only +that spelling. U+00B5 MICRO SIGN renders identically in an editor, in a +terminal, and in a code review diff, but is a different string and fails +the membership test. PR #365 shipped all three particulate units with +U+00B5. + +Mirrors test_switch_device_class.py: scans every by_type registry rather +than a fixture, so a new capability making the same mistake fails here. +""" + +import importlib +import pkgutil + +from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS, SensorDeviceClass + +from custom_components.localthings.registry import by_type +from custom_components.localthings.registry.entities import SensorDesc + + +def _all_registries(): + for mod_info in pkgutil.iter_modules(by_type.__path__): + if mod_info.name.startswith("_"): + continue + mod = importlib.import_module( + f"custom_components.localthings.registry.by_type.{mod_info.name}" + ) + reg = getattr(mod, "REGISTRY", None) + if reg is not None: + yield reg + + +def _sensor_descs(): + seen = set() + for reg in _all_registries(): + caps = [c for cs in reg.capabilities.values() for c in cs] + list(reg.pattern_capabilities) + for cap in caps: + for entity in cap.entities: + if isinstance(entity, SensorDesc) and (reg.name, entity.key) not in seen: + seen.add((reg.name, entity.key)) + yield reg.name, entity + + +def test_every_sensordesc_device_class_is_valid_for_ha(): + bad = [] + for reg_name, desc in _sensor_descs(): + if desc.device_class is None: + continue + try: + SensorDeviceClass(desc.device_class) + except ValueError: + bad.append((reg_name, desc.key, desc.device_class)) + assert bad == [] + + +def test_every_declared_unit_is_valid_for_its_device_class(): + """Descriptors carrying a `unit_fn` are exempt: those resolve their unit + from the live rep (a device reporting Celsius vs Fahrenheit), so there + is no static value to check here.""" + bad = [] + for reg_name, desc in _sensor_descs(): + if desc.device_class is None or desc.unit_fn is not None: + continue + units = DEVICE_CLASS_UNITS.get(SensorDeviceClass(desc.device_class)) + if units is not None and desc.unit not in units: + bad.append( + (reg_name, desc.key, desc.device_class, desc.unit, sorted(str(u) for u in units)) + ) + assert bad == [] + + +def test_particulate_units_use_has_own_mu_codepoint(): + """The membership test above already fails on U+00B5, but only while a + PM device_class is attached. Asserting the codepoint directly keeps the + reason legible when someone re-types the literal.""" + from custom_components.localthings.registry.capabilities import air_purifier + + micro_sign, greek_mu = chr(0x00B5), chr(0x03BC) + for _key, _icon, _type, _state_class, device_class, unit in air_purifier._AIR_QUALITY_SENSORS: + if device_class is None: + continue + assert unit is not None, device_class + assert unit == f"{greek_mu}g/m³", (device_class, [hex(ord(c)) for c in unit]) + assert micro_sign not in unit, device_class diff --git a/tests/test_translations.py b/tests/test_translations.py index 2c9b7cd..8697510 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -190,6 +190,27 @@ def test_confirmed_washer_table_02_missing_course_names(): } +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