Review follow-up (Opus + /simplify) on the previous commit:
- power_energy_kwh/energy_saved_kwh/energy_last_month_kwh/
energy_this_month_kwh used a plain `field in rep` exists_fn, unlike their
siblings power_watts/energy_kwh in the same capability. Per
entity._is_included, an explicit exists_fn bypasses the stub carve-out
entirely -- an empty {} rep at platform setup (device/0 returned a
not-yet-fetched stub) would permanently drop these entities for the
session instead of picking them up once a sub-poll populates the
resource. Restore the same `not rep or ...` guard used above.
- fridge._flex_zone_current/_flex_zone_write independently rebuilt the same
supportedOptions set; factor into _flex_zone_supported.
- note in a comment that the flex-zone match assumes at most one
modes/supportedOptions overlap (true on every dump seen); add the
missing negative assertion that dry_level self-gates off on a plain
washer (was only positively asserted on the combo fixture).
295 lines
14 KiB
Python
295 lines
14 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):
|
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
|
assert desc.translation_key == 'washer_cycle'
|
|
|
|
def test_reads_raw_course_code_from_options_array(self):
|
|
"""rep_fn returns the raw device code; display names come from
|
|
strings.json via translation_key, not from 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):
|
|
"""detergent_quantity and softener_quantity share one translation_key
|
|
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
|
|
same shape as fridge.py's shared 'brightness_level' key); hardness
|
|
and concentration each have their own since their labels differ."""
|
|
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
|
|
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
|
|
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
|
|
assert self._desc('softener_concentration').translation_key == 'washer_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 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'
|