washer_cycle_fallback no longer wraps an unrecognized code in an 'Unknown (0xNN)' label -- that baked untranslatable English into a component built to be fully translatable. It now only ever surfaces a device-provided personal-course name; an unrecognized standard code displays as its raw value, same as before PR #251. Also translates nl.json's '69'/'88' washer labels left in English (same review), and makes de.json's own 'smart' states consistent with the 'Intelligente Lüftung' translation already used for smartventilation.
340 lines
14 KiB
Python
340 lines
14 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
|
|
|
|
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",
|
|
]
|
|
|
|
|
|
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"
|