Simplification (feedback: this was overcomplicated): drop the validated_table gate entirely. cycle_select's table_href now just builds the translation key directly from whatever course table the device reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of comparing against a hardcoded known-good value and falling back to no key on any mismatch. A table we haven't shipped translations for yet (e.g. FlexWash's Table_00) still gets a key built for it -- Home Assistant's own missing-translation handling takes it from there, the same graceful fallback already relied on for any individual untranslated code within an existing table. Adding a newly-confirmed table later is just new strings.json entries, no code change. Independent (Opus) review of the prior version caught two real issues, fixed here regardless of the simplification above: - translation_key was resolved once at entity construction from whatever coordinator.last_resources held at that moment. Discovery can run while a sibling resource is still an empty stub (documented precedent: see _is_included), so a callable translation_key could permanently bake in a stale value for the entity's lifetime. Moved resolution into a translation_key property override (Entity.translation_key is a property upstream, not a plain attribute), re-evaluated against live coordinator data on every access, matching how options/current_option already work. - The supportedOptions fallback's "smallest passing K wins" docstring claimed every larger passing K is an exact multiple of the true one. False: the shipped dishwasher fixture has passing K=7 (true) alongside 10, 14, and 35, none of which are multiples of 7 -- position 0 always lands on the same real course code regardless of K, which alone satisfies the current-course guard for several unrelated splits. Corrected the reasoning to what's actually true (an empirically-matched heuristic across six real dumps, not a proof) and added a regression test locking in the real dishwasher case so this isn't silently lost.
84 lines
3.3 KiB
Python
84 lines
3.3 KiB
Python
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
|
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
|
|
from custom_components.localthings.registry.discovery import discover
|
|
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
def _dryer():
|
|
resources = _load_device('dryer')
|
|
info = resources['/information/vs/0']
|
|
reg = for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
|
)
|
|
return reg, resources
|
|
|
|
|
|
def _state():
|
|
reg, resources = _dryer()
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
return flatten(bound, resources)
|
|
|
|
|
|
def test_dryer_model_resolves_to_dryer_registry():
|
|
reg, _ = _dryer()
|
|
assert reg is not None and reg.name == 'dryer'
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
"""Every resource in the issue #14 dump binds or is ignored -- clears the
|
|
coverage-gap repair."""
|
|
reg, resources = _dryer()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_expected_entities_present():
|
|
state = _state()
|
|
for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock',
|
|
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'):
|
|
assert key in state, key
|
|
|
|
|
|
def test_job_beginning_status_reads_current_status():
|
|
"""The dump carries x.com.samsung.da.currentStatus (not the old
|
|
jobBeginingStatus field the dryer descriptor used to read), so the sensor
|
|
is populated instead of blank."""
|
|
assert _state()['job_beginning_status'] == 'None' # the dump's currentStatus value
|
|
|
|
|
|
def test_power_watts_gated_for_dead_sentinel():
|
|
"""instantaneousPower is the dead '-500' sentinel on this dryer, so the
|
|
misleading 0 W power sensor is suppressed; cumulative energy still shows."""
|
|
state = _state()
|
|
assert 'power_watts' not in state
|
|
assert 'energy_kwh' in state
|
|
|
|
|
|
def test_course_bound_to_shared_course_vs_0():
|
|
"""Dryer course uses the shared /course/vs/0 cycle select, with the
|
|
translation key built from the device's own course table (see
|
|
laundry.cycle_select) -- confirmed dryers report Table_03, matching
|
|
the shipped dryer_cycle_table_03 translations, consistent with
|
|
washer/dishwasher."""
|
|
assert dryer.DRYER_COURSE.href == '/course/vs/0'
|
|
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle')
|
|
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.options is laundry.cycle_options
|
|
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
|
|
assert desc.rep_fn(rep) == '16'
|
|
|
|
|
|
def test_st_dryercourse_is_ignored():
|
|
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
|
|
is globally ignored -- the mirror of /st/washercourse/vs/0."""
|
|
ignored_hrefs = {c.href for c in ignored.IGNORED}
|
|
assert '/st/dryercourse/vs/0' in ignored_hrefs
|
|
assert '/st/washercourse/vs/0' in ignored_hrefs
|