"""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_decodes_reported_washer_course_table_including_personal_slots(self): """Real DA_WM_TP1_21_COMMON diagnostics, with every selectable standard and personal-course code preserved in the device's order. F1/F3 are part of the packed list; their labels come from the companion personal-course resource tested below. """ resources = { "/wm/editcourse/vs/0": { "x.com.samsung.da.editCourseList": ( "EditCourseList_696F73757801719688706D6A76726C6E6B777479F1F3" ), }, } assert laundry.cycle_options(resources) == [ "69", "6F", "73", "75", "78", "01", "71", "96", "88", "70", "6D", "6A", "76", "72", "6C", "6E", "6B", "77", "74", "79", "F1", "F3", ] 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 def test_decodes_device_provided_personal_course_names(self): """Real /wm/personalcourse/vs/0 entries from the reported washer. The first TLV field contains a byte-counted UTF-8 name. It is the only course-name metadata in the diagnostics and is safe to display without assigning an inferred meaning to any standard course code. """ resources = { "/wm/personalcourse/vs/0": { "x.com.samsung.da.courses": [ "F1_0106EC868DEC98B7021EED8CACED8BB020EB93B1", "F2_00", "F3_0109EC9A94EAB8B0EBB3B40220ECA084EC9AA9", ], }, } first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8") second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8") assert laundry.personal_course_labels(resources) == { "F1": first_name, "F3": second_name, } def test_personal_course_names_reject_malformed_payloads(self): resources = { "/wm/personalcourse/vs/0": { "x.com.samsung.da.courses": [ "F1_not-hex", "F2_0106AA", "F3_020141", None, ], }, } assert laundry.personal_course_labels(resources) == {} def test_washer_cycle_fallback_only_labels_personal_courses(self): """No invented English label for an unrecognized code (PR #251 review).""" resources = { "/wm/personalcourse/vs/0": { "x.com.samsung.da.courses": ["F1_0106EC868DEC98B7"], }, } expected = bytes.fromhex("EC868DEC98B7").decode("utf-8") assert laundry.washer_cycle_fallback("F1", resources) == expected assert laundry.washer_cycle_fallback("69", resources) is None assert laundry.washer_cycle_fallback("6f", resources) is None assert laundry.washer_cycle_fallback("Normal", resources) 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", ] def test_decodes_reported_dishwasher_course_table_without_editcourse(self): """Real DA_DW_A51_20_COMMON diagnostics: the board has no /wm/editcourse/vs/0, so every selectable code must come from its packed supportedOptions table, including the newer 82/8A/A7/A8/8C family that previously appeared as raw UI text. """ resources = { "/course/vs/0": { "x.com.samsung.da.options": ["Course_82"], "x.com.samsung.da.supportedOptions": [ "482E0026002F00270028AE0026002F0027002A7E0026002F00270028" "0E0006000F0027000A8E0026002F002700288E0006000F00070008C" "E0026002F00270028DE0026002F00270028EE0006000F00270028FE" "0006002F0027002" ], }, } assert laundry.cycle_options(resources) == [ "82", "8A", "A7", "80", "A8", "88", "8C", "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" # Behavior, not identity: the option list is the device's own live # course list (plus any named cloud programs -- see cycle_select). live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_161C"}} assert desc.options(live) == ["16", "1C"] 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_99"}} 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"