Files
localthings/tests/test_select_options.py
T
Marc Billowandgalaxysj 00db7890f5 Squash-merge PR #251: fix appliance course labels and AC setup timeouts
- Add confirmed Samsung Table_02 washer course mappings (69-79, 88)
- Add confirmed dishwasher course mappings (82, 8a, a7, a8, 8c, 88)
- Localize new washer/dishwasher course labels in en, cs, nl
- Decode device-provided personal washer course names from TLV payloads
- Show unrecognized washer enum bytes as 'Unknown (0xNN)'
- Normalize select current-state and options through one display path
- Bound first-setup subdevice enumeration with a shared time budget

Co-authored-by: galaxysj <224385302+galaxysj@users.noreply.github.com>
2026-08-08 21:17:43 +00:00

196 lines
6.8 KiB
Python

"""Tests for LocalThingsSelect's option-list resolution
(custom_components/localthings/select.py) -- the static tuple, options_field,
and callable forms of SelectDesc.options.
"""
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.laundry import (
cycle_select,
washer_cycle_fallback,
)
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SelectDesc
from custom_components.localthings.select import LocalThingsSelect
class _FakeCoordinator:
device_serial = "TEST-SERIAL"
def __init__(self, last_resources):
self.last_resources = last_resources
def canonical_resources(self, subdevice):
# Every entity built by _make_select uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
def _make_select(desc, href, last_resources):
capability = Capability(href=href, entities=(desc,))
bound = BoundEntity(href=href, capability=capability, desc=desc)
return LocalThingsSelect(cast(LocalThingsCoordinator, _FakeCoordinator(last_resources)), bound)
def test_static_options_unaffected():
desc = SelectDesc(key="x", options=("A", "B"))
entity = _make_select(desc, "/x/vs/0", {})
assert entity.options == ["A", "B"]
def test_options_field_unaffected():
desc = SelectDesc(key="x", options_field="supported")
entity = _make_select(desc, "/x/vs/0", {"/x/vs/0": {"supported": ["Lo", "Hi"]}})
assert entity.options == ["Lo", "Hi"]
def test_callable_options_receives_full_resource_snapshot():
"""A callable options is handed the coordinator's full href->rep
snapshot, not just this entity's own href's rep -- needed for course
lists decoded from a sibling resource (see laundry.cycle_options)."""
calls = []
def _options_fn(resources):
calls.append(resources)
return list(resources.get("/other/vs/0", {}).get("codes", []))
desc = SelectDesc(key="cycle", translation_key="fake_cycle", options=_options_fn)
resources = {
"/x/vs/0": {},
"/other/vs/0": {"codes": ["1C", "1D"]},
}
entity = _make_select(desc, "/x/vs/0", resources)
assert entity.options == ["1C", "1D"]
assert calls == [resources]
def test_callable_options_empty_result():
desc = SelectDesc(key="cycle", options=lambda resources: [])
entity = _make_select(desc, "/x/vs/0", {})
assert entity.options == []
def test_callable_translation_key_reresolves_live_not_once_at_construction():
"""A callable translation_key (laundry.cycle_select's table-id-gated
resolver) must be re-evaluated against current coordinator data on
every access, not baked in once at __init__ -- discovery can run while
a sibling resource (e.g. /st/washercourse/vs/0) is still an empty stub
(see entity.py's _is_included docstring), and a one-time resolution
would permanently show untranslated codes even after a later poll
populates the real value."""
desc = SelectDesc(key="cycle", translation_key=lambda resources: resources.get("key"))
resources = {"key": None}
entity = _make_select(desc, "/x/vs/0", resources)
assert entity.translation_key is None
resources["key"] = "washer_cycle_table_02"
assert entity.translation_key == "washer_cycle_table_02"
async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
"""Readable fallback labels must still write the exact Samsung token."""
class _WritableCoordinator(_FakeCoordinator):
data: ClassVar[dict] = {"mode": "FutureVendorMode"}
def __init__(self, last_resources):
super().__init__(last_resources)
self.writes = []
async def async_send_command(self, bound, value):
self.writes.append(value)
desc = SelectDesc(
key="mode",
translation_key="door_alert",
options=("Known", "FutureVendorMode"),
write_fn=lambda *args: None,
)
capability = Capability(href="/x/vs/0", entities=(desc,))
bound = BoundEntity(href="/x/vs/0", capability=capability, desc=desc)
coordinator = _WritableCoordinator({})
entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), bound)
assert entity.options[-1] == "Future Vendor Mode"
await entity.async_select_option("Future Vendor Mode")
assert coordinator.writes == ["FutureVendorMode"]
async def test_washer_diagnostic_cycle_values_share_one_display_and_write_path():
"""Regression for Course_69/EditCourseList_696F... from real hardware."""
class _WritableCoordinator(_FakeCoordinator):
data: ClassVar[dict] = {"cycle": "69"}
def __init__(self, last_resources):
super().__init__(last_resources)
self.writes = []
async def async_send_command(self, bound, value):
self.writes.append(value)
desc = cycle_select(
translation_key="washer_cycle",
icon="mdi:washing-machine",
table_href="/st/washercourse/vs/0",
display_fn=washer_cycle_fallback,
)
capability = Capability(href="/course/vs/0", entities=(desc,))
bound = BoundEntity(href="/course/vs/0", capability=capability, desc=desc)
resources = {
"/course/vs/0": {"x.com.samsung.da.options": ["Course_69"]},
"/st/washercourse/vs/0": {
"x.com.samsung.da.st.courseTable": "Table_02",
},
"/wm/editcourse/vs/0": {
"x.com.samsung.da.editCourseList": (
"EditCourseList_696F757801719688706D6A7376726C6E6B777479F1F3"
),
},
"/wm/personalcourse/vs/0": {
"x.com.samsung.da.courses": [
"F1_0106EC868DEC98B7",
"F3_0109EC9A94EAB8B0EBB3B4",
],
},
}
coordinator = _WritableCoordinator(resources)
entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), bound)
first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8")
second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8")
# Known catalog states stay as HA translation keys; the frontend renders
# this confirmed Table_02 mapping as "AI Wash".
assert entity.current_option == "69"
assert entity.options == [
"69",
"6f",
"75",
"78",
"01",
"71",
"96",
"88",
"70",
"6d",
"6a",
"73",
"76",
"72",
"6c",
"6e",
"6b",
"77",
"74",
"79",
first_name,
second_name,
]
await entity.async_select_option("6f")
await entity.async_select_option(first_name)
assert coordinator.writes == ["6F", "F1"]