Same isinstance/cast narrowing and Optional-field assert pattern as the prior commits, covering the airconditioner, fridge, washer, operational, subdevices, sensor_hysteresis, laundry, select_options, identity and entities test files.
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""Tests for the shared laundry capabilities (washer/dryer/dishwasher)."""
|
|
|
|
from custom_components.localthings.registry.capabilities import laundry
|
|
from custom_components.localthings.registry.entities import SelectDesc
|
|
|
|
|
|
class TestCourseHelpers:
|
|
def test_parses_edit_course_list(self):
|
|
raw = "EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A"
|
|
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",
|
|
"2B",
|
|
"2A",
|
|
]
|
|
|
|
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 TestCourseCodesFromSupportedOptions:
|
|
"""cycle_options()'s fallback for boards that populate
|
|
/wm/editcourse/vs/0 without ever filling in editCourseList itself
|
|
(issue #1) -- derives the course list from /course/vs/0's own
|
|
supportedOptions instead."""
|
|
|
|
# Real dump from issue #1 (DA_WM_TP1_21_COMMON, WW5000C): a 1-hex-nibble
|
|
# header followed by 14 self-indexed 7-byte-per-course records. '1C' (the
|
|
# first record) is confirmed as "Eco 40-60" both by this device's own
|
|
# currently-selected course matching the SmartThings app screenshot's
|
|
# checked item, and by six other independent devices' already-shipped
|
|
# translations agreeing on the same code -> name mapping.
|
|
_REAL_SUPPORTED_OPTIONS = (
|
|
"31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F"
|
|
"088000913FA67F7485209204A5208780009000A00006841E930FA30F"
|
|
"7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F"
|
|
"34841E923FA67FA0811E933FA33F"
|
|
)
|
|
|
|
def test_derives_codes_when_edit_course_list_is_empty(self):
|
|
resources = {
|
|
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": ""},
|
|
"/course/vs/0": {
|
|
"x.com.samsung.da.options": ["Course_1C"],
|
|
"x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS],
|
|
},
|
|
}
|
|
assert laundry.cycle_options(resources) == [
|
|
"1C",
|
|
"1B",
|
|
"25",
|
|
"20",
|
|
"08",
|
|
"74",
|
|
"87",
|
|
"06",
|
|
"7F",
|
|
"65",
|
|
"8F",
|
|
"96",
|
|
"34",
|
|
"A0",
|
|
]
|
|
|
|
def test_edit_course_list_still_takes_priority(self):
|
|
"""A live editCourseList wins even with supportedOptions present --
|
|
no reason to prefer a derived list over the authoritative one."""
|
|
resources = {
|
|
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_651C"},
|
|
"/course/vs/0": {
|
|
"x.com.samsung.da.options": ["Course_1C"],
|
|
"x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS],
|
|
},
|
|
}
|
|
assert laundry.cycle_options(resources) == ["65", "1C"]
|
|
|
|
def test_rejects_a_table_missing_the_current_course(self):
|
|
"""The device's own currently-selected course must be a member of
|
|
its derived list -- a mismatch means the guess is wrong, not that
|
|
the device selected something outside its own supported set."""
|
|
resources = {
|
|
"/course/vs/0": {
|
|
"x.com.samsung.da.options": ["Course_FF"],
|
|
"x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS],
|
|
},
|
|
}
|
|
assert laundry.cycle_options(resources) == []
|
|
|
|
def test_smallest_passing_split_wins_over_its_own_multiples(self):
|
|
"""K=2 and K=4 both trivially re-pass the same two checks here --
|
|
each is just a sparser sampling of the true, smaller K=1 table (its
|
|
first bytes are a subset of K=1's, so uniqueness and "contains the
|
|
current course" carry over for free) -- but K=1 is the real, most
|
|
specific table and must be the one returned."""
|
|
resources = {
|
|
"/course/vs/0": {
|
|
"x.com.samsung.da.options": ["Course_AA"],
|
|
"x.com.samsung.da.supportedOptions": ["0AABBCCDD"],
|
|
},
|
|
}
|
|
assert laundry.cycle_options(resources) == ["AA", "BB", "CC", "DD"]
|
|
|
|
def test_empty_without_supported_options_or_course_href(self):
|
|
assert laundry.cycle_options({}) == []
|
|
assert laundry.cycle_options({"/course/vs/0": {}}) == []
|
|
|
|
def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self):
|
|
"""Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also
|
|
pass both checks here, and none of them are multiples of 7 --
|
|
position 0 lands on the same real course code ('0e') regardless of
|
|
K, which alone satisfies the current-course guard for several
|
|
unrelated splits. Smallest-K-wins is a heuristic that matches every
|
|
real dump checked so far, not a proven guarantee -- see
|
|
_course_codes_from_supported_options's docstring."""
|
|
resources = {
|
|
"/course/vs/0": {
|
|
"x.com.samsung.da.options": ["Course_0E"],
|
|
"x.com.samsung.da.supportedOptions": [
|
|
"30E5434B102D102835034B002D002845034B002D002805034B000D000"
|
|
"865034B002D002075000B000D000905000B000D0008D5034B002D0028"
|
|
"E5034B000D0008F5034B000D000"
|
|
],
|
|
},
|
|
}
|
|
assert laundry.cycle_options(resources) == [
|
|
"0E",
|
|
"83",
|
|
"84",
|
|
"80",
|
|
"86",
|
|
"07",
|
|
"90",
|
|
"8D",
|
|
"8E",
|
|
"8F",
|
|
]
|
|
|
|
|
|
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_is_single_token(self):
|
|
"""Confirmed on real hardware (issue #54): the device merges a
|
|
single-token options[] write by prefix itself, so the write only
|
|
needs to carry the changed token, not the whole rewritten array."""
|
|
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": ["Course_1D"]}
|
|
|
|
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 TestCycleSelectTableGating:
|
|
"""translation_key becomes a resolver, not a plain string, once
|
|
table_href is given -- washer/dryer's real call sites (issue: course
|
|
codes aren't guaranteed consistent across board generations sharing
|
|
the same /course/vs/0 contract; FlexWash's older board reports a
|
|
different course table than every device the shipped translations
|
|
were confirmed against). The resolved key is built from a reported
|
|
table id. Devices without one use the generic translated Cycle entity
|
|
name while preserving raw option codes as a safe fallback."""
|
|
|
|
def _desc(self):
|
|
return laundry.cycle_select(
|
|
translation_key="washer_cycle",
|
|
icon="x",
|
|
table_href="/st/washercourse/vs/0",
|
|
)
|
|
|
|
def test_static_string_when_no_table_href_given(self):
|
|
"""dishwasher's call site -- no equivalent table-id resource in any
|
|
dump seen, no evidence of the same cross-board risk -- keeps the
|
|
plain static key unconditionally."""
|
|
desc = laundry.cycle_select(translation_key="dishwasher_cycle", icon="x")
|
|
assert desc.translation_key == "dishwasher_cycle"
|
|
|
|
def test_resolved_key_is_built_from_the_reported_table(self):
|
|
desc = self._desc()
|
|
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}}
|
|
assert callable(desc.translation_key)
|
|
assert desc.translation_key(resources) == "washer_cycle_table_02"
|
|
|
|
def test_untranslated_table_uses_generic_cycle_key(self):
|
|
"""An unknown table does not claim another board's state labels."""
|
|
desc = self._desc()
|
|
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
|
assert desc.translation_key(resources) == "cycle"
|
|
|
|
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
|
|
"""An absent table id still gets a translated generic entity name."""
|
|
desc = self._desc()
|
|
assert desc.translation_key({}) == "cycle"
|
|
assert desc.translation_key({"/st/washercourse/vs/0": {}}) == "cycle"
|
|
|
|
|
|
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" and isinstance(e, SelectDesc)
|
|
)
|
|
assert desc.options_field == "supportedBuzzerSound"
|
|
assert desc.write_fn is not None
|
|
result = desc.write_fn("On", {})
|
|
assert result is not None
|
|
path, body = result
|
|
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 is not None
|
|
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"
|