Files
localthings/tests/test_washer_capabilities.py
T
Marc Billow f45bd6a72c laundry: discover and offer cloud "Download" cycles (issue #342)
A washer whose course table includes "Download"/"Downloaded" runs whichever
program the SmartThings cloud last pushed down. Those programs are now
selectable from the ordinary cycle select, so a downloaded Jeans or Sports
cycle can be started without giving the appliance internet access.

The device turns out to enumerate them itself. `CloudExtraCourse_` on
/course/vs/0 lists one byte per downloaded program, and byte 2 of a
program's payload is exactly that slot id -- verified against all nine
programs on the reporter's WW5000C and against the WA55A7700AV dump already
in the corpus. So nothing here is hardcoded: the appliance says which
programs exist, the payloads are learned by watching what it reports, and
the names come from the user.

That last part is unavoidable rather than a shortcut. A payload is only
visible while its program is loaded, and the appliance never reports a name
for one. So cloudcourse.py persists what has been seen (same rationale as
learned.py's mode store), a Repairs issue tells the owner how many programs
are still unaccounted for, and an options-flow step collects the names. A
program appears in the cycle select only once it is both learned and named.

Selecting one issues the only two-token options write in the codebase --
the course token has to switch to Download in the same write, or the
appliance accepts the program token and silently ignores it (confirmed on
hardware). The Download course code is learned by observation but never
applied until the user confirms it: tokens in this array are replaced by
prefix and never evicted, so a stale program token can appear alongside an
unrelated course, and acting on that would start the wrong wash cycle. For
the same reason a stale token is never reported as the running program.

Also of note:

- There is no single "Download" course code. The WW5000C uses 87, the
  WA55A7700AV uses 17 -- same Table_02. Any per-table lookup would have
  been wrong on one of the only two devices available to check.
- Payloads are replayed byte-for-byte and never decomposed or rebuilt.
  Bytes 5/7/9 do decode to temperature/rinse/spin on the WW5000C, 9 for 9,
  and produce nonsense on the WA55A7700AV -- so that decode is written up
  in docs/investigations/download-cycle.md and not shipped, and the
  read-only sensors it would have enabled were dropped.
- The store reaches the registry as a namespaced synthetic field merged
  onto /course/vs/0's rep at read time, so exists_fn/rep_fn/options/write_fn
  all see it through their existing signatures. It never enters the state
  cache, so it can't be polled over, written to the device, or land in a
  diagnostics dump.
- A name that would render identically to another cycle in the same
  dropdown is rejected in the flow: the select maps a chosen label back to
  a raw value by matching display text.

Non-English catalogs carry the new strings in English for now; they need
real translations.
2026-08-09 21:21:10 +00:00

585 lines
25 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 datetime import UTC
from custom_components.localthings.registry.capabilities import washer
from custom_components.localthings.registry.entities import SelectDesc
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" and isinstance(e, SelectDesc)
)
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" and isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("60", {})
assert result is not None
path, body = result
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" and isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("1400", {})
assert result is not None
path, body = result
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" and isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("3", {})
assert result is not None
path, body = result
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 is not None
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" and isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("Cupboard", {})
assert result is not None
path, body = result
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")
assert desc.rep_fn is not None
rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_1C", "GMT_04"]}
assert desc.rep_fn(rep) == "1C"
def test_reported_table_02_course_codes_are_translated(self):
"""The owner confirmed this washer's newer Table_02 course family."""
from custom_components.localthings.catalog import translated_states
confirmed = {
"69",
"6a",
"6b",
"6c",
"6d",
"6e",
"6f",
"70",
"71",
"72",
"73",
"74",
"75",
"76",
"77",
"78",
"79",
"88",
}
assert confirmed <= translated_states("select", "washer_cycle_table_02")
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 is not None
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" and isinstance(e, SelectDesc)
)
# Behavior, not identity: cycle_select wraps both hooks to fold in
# cloud "Download" programs (issue #342), so the plain functions are
# no longer handed through as-is.
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C1D"}}
assert desc.options(live) == ["1C", "1D"]
# display_fn still resolves a personal course name through
# washer_cycle_fallback for a non-cloud value.
assert desc.display_fn is not None
personal = {
"/wm/personalcourse/vs/0": {
"x.com.samsung.da.courses": ["A1_01044D79436F"],
}
}
assert desc.display_fn("A1", personal) == "MyCo"
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 not None
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):
"""Confirmed on real hardware (issue #54): the write only needs to
carry the changed token -- the device matches by prefix, evicts the
stale token, and merges the result into the array itself."""
desc = next(
e
for e in washer.WASHER_COURSE.entities
if e.key == "cycle" and isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_1C", "GMT_04"]}
result = desc.write_fn("1D", rep)
assert result is not None
path, body = result
assert path == ["course", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Course_1D"]}
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"
)
assert desc.rep_fn is not None
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"
)
assert desc.rep_fn is not None
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 is not None
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 is not None
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")
assert desc.rep_fn is not None
rep = {"x.com.samsung.da.options": ["DrumCleanLog_2026-07-01T20:18:07"]}
from datetime import datetime
assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=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 is not None
assert desc.rep_fn({"x.com.samsung.da.options": []}) is None
assert desc.exists_fn is not 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.
Confirmed on real hardware (issue #54): the write only needs to carry
the changed token -- the device matches by prefix, evicts the stale
token, and merges the result into the array itself. No need to read
the current array back and rewrite it whole."""
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 body == {"x.com.samsung.da.options": ["DetergentLevelCtrl_1"]}
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 body == {"x.com.samsung.da.options": ["SoftenerLevel2Ctrl_3"]}
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.
The write body carries only the changed token (issue #54: confirmed
the device merges by prefix itself), not the whole options array."""
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 body == {"x.com.samsung.da.options": [f"{prefix}_On"]}
rep = {"x.com.samsung.da.options": [f"{prefix}_On"]}
path, body = self._desc(key).write_fn("Off", rep)
assert body == {"x.com.samsung.da.options": [f"{prefix}_Off"]}
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", # noqa: E501
},
}
_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"