Files
localthings/tests/test_laundry_capabilities.py
T
Marc Billow 9264b912f9 feat: full dryer support + consistent laundry capability surface
First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound
hrefs and an "incomplete capability coverage" repair. Handle them and,
while here, make the washer/dryer/dishwasher families consistent instead
of each carrying a bespoke variant of the same controls.

Dryer coverage:
- /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor
  fallback pairs (prefer the standard OCF resource, fall back to -vs).
- /buzzersound/vs/0: new Buzzer sound select.
- /course/vs/0: cycle select shared with washer/dishwasher; ignore the
  /st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0).

Consistency / de-duplication:
- Move generic OCF controls (power/kids-lock/remote-control fallback
  pairs, energy meter) into common.py; every registry uses them.
- Move shared laundry controls (buzzer, job-beginning-status, and the
  /course/vs/0 cycle-select machinery) into laundry.py; washer and
  dishwasher stop hand-rolling their own copies.
- Energy meter is now sentinel-aware everywhere: the dead '-500'
  instantaneousPower reading no longer shows a misleading 0 W (fixes it
  on dryers and dishwashers, matching the earlier washer fix).
- Job-beginning-status reads x.com.samsung.da.currentStatus, the field
  every dump actually carries; the dryer sensor was previously blank.

Adds a scrubbed dryer fixture, golden, and capability tests, plus an
.claude/skills/adding-device-support skill capturing the dump-reading,
OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
2026-07-21 01:15:12 +00:00

96 lines
4.3 KiB
Python

"""Tests for the shared laundry capabilities (washer/dryer/dishwasher)."""
from custom_components.localthings.registry.capabilities import laundry
class TestCourseHelpers:
def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
assert laundry.parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
]
def test_parse_edit_course_list_handles_missing_or_malformed(self):
assert laundry.parse_edit_course_list(None) == []
assert laundry.parse_edit_course_list('') == []
assert laundry.parse_edit_course_list('no underscore') == []
def test_cycle_options_reads_live_edit_course_list(self):
"""The device's own course list -- including a code ('65') never seen
on the primary dump -- is used as-is; there is no hardcoded table."""
resources = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
},
}
assert laundry.cycle_options(resources) == ['65', '1C']
def test_cycle_options_empty_when_resource_absent_or_empty(self):
assert laundry.cycle_options({}) == []
assert laundry.cycle_options({'/wm/editcourse/vs/0': {}}) == []
def test_option_value(self):
opts = ['DeviceType_0167', 'Course_1C', 'GMT_04']
assert laundry.option_value(opts, 'Course') == '1C'
assert laundry.option_value(opts, 'Missing') is None
class TestCycleSelect:
def test_builds_labelled_cycle_select(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
assert desc.key == 'cycle'
assert desc.translation_key == 'dryer_cycle'
assert desc.icon == 'mdi:tumble-dryer'
assert desc.options is laundry.cycle_options
def test_reads_raw_course_code_from_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
assert desc.rep_fn(rep) == '16'
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
def test_exists_only_when_edit_course_list_is_live(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
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_16'}}
assert desc.exists_fn({}, live) is True
def test_cycle_write_rmw_on_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', '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'],
}
def test_cycle_write_noop_without_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
assert desc.write_fn('1D', {}) is None
class TestBuzzerSound:
def test_href(self):
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
def test_buzzer_sound_write(self):
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
assert desc.options_field == 'supportedBuzzerSound'
path, body = desc.write_fn('On', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'On'}
def test_finish_sound_exists_only_when_supported(self):
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'finish_sound')
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
class TestJobBeginningStatus:
def test_href_and_field(self):
assert laundry.JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
desc = laundry.JOB_BEGINNING_STATUS.entities[0]
assert desc.field == 'x.com.samsung.da.currentStatus'
assert desc.entity_category == 'diagnostic'