Files
localthings/tests/test_washer_capabilities.py
T
Marc Billow 6281b40549 refactor(i18n): make the shipped catalog the single source of truth
PR #68 restated its own translation data in Python: a 60-line
TRANSLATED_SELECT_STATES table of frozensets duplicating every
entity.select.*.state key, a second _TRANSLATED_COURSE_TABLES table
naming which course tables have translations, and a strings.json that
was a 835-line byte-for-byte copy of translations/en.json save 43
[%key:...%] references. Each needed hand-syncing, and one was already
drifting.

Home Assistant loads exactly one file per language for a custom
integration -- translations/<lang>.json. It never reads strings.json and
never resolves [%key:...%]; both belong to Core's build tooling, which
custom integrations don't run through (hassfest skips a missing
strings.json and validates translations/en.json instead). So en.json is
the source, and the new catalog.py reads the keys and states back out of
it for the two decisions Python genuinely has to make:

  - select._display() normalizes a raw Samsung option to a lowercase
    state key only when the catalog knows it, else leaves the vendor's
    casing alone. Derived sets are identical to the removed literals.
  - laundry.cycle_select() keys off a device-reported course table only
    when that table has an entry, else falls back to the name-only
    'cycle' key. Translating Table_00 is now a translations-only change.

Also fixes six names that had already drifted between the Python
descriptors and the catalog, restoring HA's sentence case for two
generic ones (Auto release dry, Bubble soak) and taking the catalog's
wording for the rest, and adds a test so the vestigial descriptor names
can't silently disagree with the UI again.

Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
2026-07-24 19:32:09 +00:00

459 lines
21 KiB
Python

"""Tests for washer-specific capabilities.
Shared laundry capabilities (cycle-select machinery, buzzer, job status) are
tested in test_laundry_capabilities.py; the OCF power/kids-lock/remote-control
fallback pairs and the energy meter in test_common_capabilities.py.
"""
from custom_components.localthings.registry.capabilities import laundry, washer
def _rep_by_href(cap, href):
assert cap.href == href
return cap
class TestWasherSettings:
def test_href(self):
assert washer.WASHER_SETTINGS.href == '/washer/vs/0'
def test_wash_temperature_read(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature')
assert desc.field == 'x.com.samsung.da.waterTemperature'
assert desc.options_field == 'x.com.samsung.da.supportedWaterTemperature'
def test_wash_temperature_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature')
path, body = desc.write_fn('60', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.waterTemperature': '60'}
def test_spin_speed_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'spin_speed')
path, body = desc.write_fn('1400', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.spinLevel': '1400'}
def test_rinse_cycles_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'rinse_cycles')
path, body = desc.write_fn('3', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.rinseCycles': '3'}
class TestDryLevel:
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, self-gated off on plain washers -- issue #22."""
def test_exists_only_when_supported_dry_level_present(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True
assert desc.exists_fn({}, {}) is False
def test_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
path, body = desc.write_fn('Cupboard', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'}
class TestWasherCourse:
def test_href(self):
assert washer.WASHER_COURSE.href == '/course/vs/0'
def test_translation_key(self):
"""Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. Only a
verified table gets table-specific state translations."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert callable(desc.translation_key)
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert desc.translation_key(table_02) == 'washer_cycle_table_02'
table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(table_00) == 'cycle'
assert desc.translation_key({}) == 'cycle'
def test_reads_raw_course_code_from_options_array(self):
"""rep_fn returns the raw device code; display names come from
translations/en.json via translation_key, not Python (see select.py's
_display())."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
assert desc.rep_fn(rep) == '1C'
def test_missing_course_option_returns_none(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
def test_cycle_desc_uses_cycle_options_callable(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.options is laundry.cycle_options
def test_exists_only_when_edit_course_list_is_live(self):
"""No hardcoded course table is kept -- the selector only appears
when a device actually populates editCourseList (see
_cycle_options's docstring for why MostUsed_ isn't used either)."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.exists_fn({}, {}) is False
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_1C'}}
assert desc.exists_fn({}, live) is True
def test_cycle_write(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
path, body = desc.write_fn('1D', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
}
class TestDrumClean:
def test_cycles_remaining(self):
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
app screenshot's 'Potreba cistenia po 37 cykloch'."""
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
assert desc.rep_fn(rep) == 37
def test_cycles_remaining_never_negative(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
rep = {'x.com.samsung.da.options': ['WashingTimes_50', 'DrumCleanProposal_40']}
assert desc.rep_fn(rep) == 0
def test_cycles_remaining_missing_fields(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
def test_cycles_remaining_exists_only_when_computable(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
assert desc.exists_fn(rep, {}) is True
def test_last_cleaned(self):
"""DrumCleanLog_2026-07-01T20:18:07 -> a UTC-aware datetime,
matching the same screenshot's '10 days ago' (as of 2026-07-11)."""
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_last_cleaned')
rep = {'x.com.samsung.da.options': ['DrumCleanLog_2026-07-01T20:18:07']}
from datetime import datetime, timezone
assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=timezone.utc)
def test_last_cleaned_missing(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_last_cleaned')
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
# Raw options array from issue #9's diagnostic dump (trimmed to the fields
# relevant to detergent/softener dosing).
_DOSING_OPTIONS = [
'Course_1C',
'DetergentAlarm_Off',
'SoftenerAlarm_Off',
'DetergentLevelCtrl_3',
'SoftenerLevelCtrl_3',
'SupportedDetergentLevelCtrl_00010203',
'SupportedSoftenerLevelCtrl_00010203',
'DetergentLevel2Ctrl_2',
'SoftenerLevel2Ctrl_2',
'SupportedDetergentLevel2Ctrl_010203',
'SupportedSoftenerLevel2Ctrl_010203',
]
_DOSING_RESOURCES = {'/course/vs/0': {'x.com.samsung.da.options': _DOSING_OPTIONS}}
class TestDetergentSoftenerDosing:
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_quantity_and_hardness_read(self):
"""The device reports the level un-padded ('3'), but the options and
translation keys are zero-padded supported codes ('03'); rep_fn
normalizes to the supported code so the value is a valid option
(issue #9 -- otherwise HA renders the select 'unknown')."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
assert self._desc('softener_quantity').rep_fn(rep) == '03'
assert self._desc('softener_concentration').rep_fn(rep) == '02'
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
"""The core regression: HA shows a select 'unknown' when current_option
is not in options. Each dosing select's value must be one of its own
options."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
def test_read_passes_through_when_no_supported_match(self):
"""A value with no matching supported code is returned as-is rather than
dropped, so an unexpected device stays visible instead of blank."""
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
'SupportedDetergentLevelCtrl_00010203']}
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self):
"""Each entity gets its own translated name and state vocabulary."""
assert self._desc('detergent_quantity').translation_key == 'detergent_quantity'
assert self._desc('softener_quantity').translation_key == 'softener_quantity'
assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness'
assert self._desc('softener_concentration').translation_key == 'softener_concentration'
def test_quantity_and_hardness_options_decode_supported_list(self):
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
assert self._desc('softener_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
assert self._desc('detergent_water_hardness').options(_DOSING_RESOURCES) == ['01', '02', '03']
assert self._desc('softener_concentration').options(_DOSING_RESOURCES) == ['01', '02', '03']
def test_exists_only_when_supported_list_present(self):
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.exists_fn({}, {}) is False
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_low').rep_fn(rep) is False
assert self._desc('softener_low').rep_fn(rep) is False
def test_low_reservoir_on_when_alarm_active(self):
opts = ['DetergentAlarm_On', 'SoftenerAlarm_On']
rep = {'x.com.samsung.da.options': opts}
assert self._desc('detergent_low').rep_fn(rep) is True
assert self._desc('softener_low').rep_fn(rep) is True
def test_low_reservoir_exists_only_when_alarm_field_present(self):
assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_low').exists_fn(rep, {}) is True
class TestWashOptionToggles:
"""Bubble soak / pre-wash / intensive-wash switches, from the same
options[] array as the cycle select (issue #22 follow-up). Confirmed
On/Off shape from a dump with Bubble Soak toggled on in the app."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
@staticmethod
def _keys():
return ('bubble_soak', 'pre_wash', 'intensive')
@staticmethod
def _prefix(key):
return {'bubble_soak': 'BubbleSoak',
'pre_wash': 'PreWashSetting',
'intensive': 'IntensiveSetting'}[key]
def test_exists_only_when_field_present(self):
for key in self._keys():
desc = self._desc(key)
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert desc.exists_fn(rep, {}) is True
def test_reads_off(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).rep_fn(rep) is False
def test_reads_on(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
assert self._desc(key).rep_fn(rep) is True
def test_write_on_and_off(self):
"""write_fn receives the same 'On'/'Off' string switch.py sends
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
implementation always wrote 'On', since any non-empty string
(including 'Off') is truthy."""
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
path, body = self._desc(key).write_fn('On', rep)
assert path == ['course', 'vs', '0']
assert f'{prefix}_On' in body['x.com.samsung.da.options']
assert 'GMT_02' in body['x.com.samsung.da.options']
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
path, body = self._desc(key).write_fn('Off', rep)
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
def test_write_rejects_non_on_off_payload(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).write_fn('bogus', rep) is None
# editCourseList and availability bitmaps from the reporter's issue #22
# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just
# turned on in the app): 24 courses, course '30' at position 1 reads 'F0'
# (available) on all three bitmaps; course '1C' at position 0 reads '00' on
# BubbleSoakSet (matching the app graying that control out for Eco 40-60).
_EDIT_COURSE_RESOURCES = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList':
'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729',
},
}
_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000'
_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
class TestWashOptionToggleValidation:
"""validate_fn rejects turning a toggle on for a course whose byte in
its availability bitmap isn't 'F0', with a user-facing message switch.py
raises as ServiceValidationError -- distinct from write_fn's silent
no-op for a malformed payload."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_allowed_on_a_supported_course(self):
rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
translation_key = self._desc('bubble_soak').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES
)
assert translation_key == 'bubble_soak_unavailable_for_cycle'
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
assert self._desc('pre_wash').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]}
assert self._desc('intensive').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_turning_off_is_never_blocked(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'Off', rep, _EDIT_COURSE_RESOURCES) is None
def test_allows_write_when_course_unresolvable(self):
"""No editCourseList, no Course_ token, or a bitmap whose length
doesn't match editCourseList -- in every case, fail open rather than
block a write we can't actually verify."""
desc = self._desc('bubble_soak')
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, {}) is None
rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
class TestAiEnergyLevel:
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
The capability itself (common.AI_ENERGY_LEVEL) is tested in
test_common_capabilities.py; this just confirms it's wired into the
washer registry and that the fixture's single-entry supportedAiLevel
(['1'], matching the issue's dump) surfaces as a switch, not a select."""
def test_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] is False # aiLevel '0' -- off
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
"""FlexWash (issue #19, previously unrecognized entirely) and
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
zero unbound hrefs."""
def test_flexwash(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_flexwash')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert 'dry_level' not in state # plain washer -- no supportedDryLevel field
def test_dryer_combo(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_dryer_combo')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
# the device's own "off" sentinel is the literal string 'None', not absence
assert state['dry_level'] == 'None'