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.
585 lines
25 KiB
Python
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"
|