Fix washer course enum display
This commit is contained in:
@@ -20,6 +20,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
||||
"""
|
||||
from datetime import time as dt_time
|
||||
import string
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
@@ -276,12 +277,56 @@ def cycle_write(p, rep, href=None):
|
||||
}
|
||||
|
||||
|
||||
def personal_course_labels(resources, href='/wm/personalcourse/vs/0'):
|
||||
"""Return device-provided personal course names keyed by course code.
|
||||
|
||||
Populated entries use a small TLV payload. The leading field is
|
||||
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
|
||||
settings and are intentionally left uninterpreted. Empty slots are
|
||||
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
|
||||
opaque device data can never become a misleading label.
|
||||
"""
|
||||
rep = resources.get(href) or {}
|
||||
labels = {}
|
||||
for entry in rep.get('x.com.samsung.da.courses') or []:
|
||||
if not isinstance(entry, str) or '_' not in entry:
|
||||
continue
|
||||
code, encoded = entry.split('_', 1)
|
||||
try:
|
||||
payload = bytes.fromhex(encoded)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(payload) < 3 or payload[0] != 0x01:
|
||||
continue
|
||||
name_length = payload[1]
|
||||
if name_length == 0 or len(payload) < 2 + name_length:
|
||||
continue
|
||||
try:
|
||||
name = payload[2:2 + name_length].decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if name.strip() and name.isprintable():
|
||||
labels[code.upper()] = name
|
||||
return labels
|
||||
|
||||
|
||||
def washer_cycle_fallback(value, resources):
|
||||
"""Label an untranslated washer course without guessing its meaning."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if label := personal_course_labels(resources).get(value.upper()):
|
||||
return label
|
||||
if len(value) == 2 and all(char in string.hexdigits for char in value):
|
||||
return f'Unknown (0x{value.upper()})'
|
||||
return None
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get('x.com.samsung.da.st.courseTable')
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The option list, current value, and write path are all shared across
|
||||
@@ -293,8 +338,11 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||
'washer_cycle_table_02'). An absent or unrecognized table id gets the
|
||||
name-only ``cycle`` translation key while the raw course code remains
|
||||
visible and writable.
|
||||
name-only ``cycle`` translation key. The raw course code remains writable;
|
||||
its display uses display_fn when supplied, otherwise it remains raw.
|
||||
|
||||
display_fn is an optional family-specific fallback for untranslated raw
|
||||
values. select.py applies it after catalog lookup to both state and options.
|
||||
|
||||
This matters because course codes are NOT guaranteed consistent across
|
||||
board generations sharing the same /course/vs/0 contract: every code in
|
||||
@@ -304,10 +352,9 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
there for all we've verified. So a table-specific key is used only when
|
||||
the shipped catalog actually has one; any other table (Table_00 today,
|
||||
whatever ships next) falls back to the name-only ``cycle`` key, which
|
||||
shows the raw course code rather than a label borrowed from another
|
||||
board generation. Translating a new table is therefore a
|
||||
translations-only change -- add the ``<family>_cycle_<table>`` entry and
|
||||
this resolver picks it up.
|
||||
does not borrow a label from another board generation. Translating a new
|
||||
table is therefore a translations-only change -- add the
|
||||
``<family>_cycle_<table>`` entry and this resolver picks it up.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource in any dump seen and no evidence its course codes vary by
|
||||
@@ -328,6 +375,7 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
display_fn=display_fn,
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from .laundry import (
|
||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||
option_write,
|
||||
option_write, washer_cycle_fallback,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -53,6 +53,10 @@ from .laundry import (
|
||||
# display name with the existing '24' Towels -- a different code on a
|
||||
# different course table legitimately landing on the same label, not a typo
|
||||
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
|
||||
# The owner of a Korean Table_02 washer confirmed the names for its newer
|
||||
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
|
||||
# live only in the table-scoped translation catalog; codes not confirmed by
|
||||
# the owner or device metadata continue to use the safe unknown-code fallback.
|
||||
#
|
||||
# No static fallback list of those codes is kept here, deliberately: other
|
||||
# washer models have a different actual course set (a second dump's active
|
||||
@@ -302,7 +306,8 @@ WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
||||
table_href='/st/washercourse/vs/0'),
|
||||
table_href='/st/washercourse/vs/0',
|
||||
display_fn=washer_cycle_fallback),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
|
||||
icon='mdi:washing-machine-alert',
|
||||
state_class='measurement',
|
||||
|
||||
@@ -17,6 +17,7 @@ WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||
# option list).
|
||||
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
||||
DisplayFn = Optional[Callable[[Any, dict], Any]]
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -76,6 +77,10 @@ class SelectDesc(SamsungEntityDescription):
|
||||
# snapshot (not just this entity's own href) and returns raw device
|
||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||
options_field: Optional[str] = None # resource field that contains the live options list
|
||||
# Optional device-specific fallback for values absent from the translation
|
||||
# catalog. Receives (raw_value, canonical_resources); select.py applies it
|
||||
# identically to the current state and every option.
|
||||
display_fn: DisplayFn = None
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: Optional[str]):
|
||||
def _display(value, translation_key: Optional[str], fallback_fn=None):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
||||
@@ -78,11 +78,17 @@ def _display(value, translation_key: Optional[str]):
|
||||
if not known:
|
||||
# No state table for this key: either the entity isn't translated
|
||||
# at all, or its name is translated but its options deliberately
|
||||
# aren't (an unrecognized course table, say). Either way the
|
||||
# opaque device value is the best thing to show.
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
# aren't (an unrecognized course table, say). Give an explicit
|
||||
# device-specific fallback the opportunity to make an opaque value
|
||||
# readable; otherwise the raw device value remains the best choice.
|
||||
if fallback_fn is None:
|
||||
return value
|
||||
elif translated := _translation_state(value, known):
|
||||
return translated
|
||||
if fallback_fn is not None:
|
||||
fallback = fallback_fn(value)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
@@ -94,7 +100,16 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
||||
self._attr_options = [self._display_option(o) for o in desc.options]
|
||||
|
||||
def _display_option(self, value):
|
||||
"""Normalize both current state and options through one path."""
|
||||
display_fn = self._bound.desc.display_fn
|
||||
fallback_fn = (
|
||||
(lambda raw: display_fn(raw, self._resources))
|
||||
if display_fn is not None else None
|
||||
)
|
||||
return _display(value, self.translation_key, fallback_fn)
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
@@ -116,17 +131,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return [self._display_option(o) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
return _display(raw, self.translation_key)
|
||||
return self._display_option(raw)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
||||
(o for o in self._raw_options() if self._display_option(o) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -590,12 +590,30 @@
|
||||
"60": "Samočištění+",
|
||||
"65": "Barevné prádlo",
|
||||
"66": "Džíny",
|
||||
"69": "AI praní",
|
||||
"6a": "Wool",
|
||||
"6b": "Denim",
|
||||
"6c": "Blouses",
|
||||
"6d": "Delicates",
|
||||
"6e": "Active Wear",
|
||||
"6f": "Bedding",
|
||||
"70": "Towels",
|
||||
"71": "Quick Wash",
|
||||
"72": "Shirts",
|
||||
"73": "Sanitize",
|
||||
"74": "Drum Clean",
|
||||
"75": "Outdoor",
|
||||
"76": "Baby Care",
|
||||
"77": "Cottons",
|
||||
"78": "Rinse + Spin",
|
||||
"79": "Spin Only",
|
||||
"7c": "Bílé prádlo",
|
||||
"7d": "Ložní prádlo/nepromokavé",
|
||||
"7e": "Samočištění",
|
||||
"7f": "Vlna/jemné",
|
||||
"86": "Hloubkové praní",
|
||||
"87": "Stažený program",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken"
|
||||
}
|
||||
|
||||
@@ -590,12 +590,30 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Colors",
|
||||
"66": "Denim",
|
||||
"69": "AI Wash",
|
||||
"6a": "Wool",
|
||||
"6b": "Denim",
|
||||
"6c": "Blouses",
|
||||
"6d": "Delicates",
|
||||
"6e": "Active Wear",
|
||||
"6f": "Bedding",
|
||||
"70": "Towels",
|
||||
"71": "Quick Wash",
|
||||
"72": "Shirts",
|
||||
"73": "Sanitize",
|
||||
"74": "Drum Clean",
|
||||
"75": "Outdoor",
|
||||
"76": "Baby Care",
|
||||
"77": "Cottons",
|
||||
"78": "Rinse + Spin",
|
||||
"79": "Spin Only",
|
||||
"7c": "Whites",
|
||||
"7d": "Bedding/Waterproof",
|
||||
"7e": "Self-Clean",
|
||||
"7f": "Wool/Delicate",
|
||||
"86": "Deep Wash",
|
||||
"87": "Download",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
}
|
||||
|
||||
@@ -590,12 +590,30 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Bonte was",
|
||||
"66": "Spijkergoed",
|
||||
"69": "AI Wash",
|
||||
"6a": "Wool",
|
||||
"6b": "Denim",
|
||||
"6c": "Blouses",
|
||||
"6d": "Delicates",
|
||||
"6e": "Active Wear",
|
||||
"6f": "Bedding",
|
||||
"70": "Towels",
|
||||
"71": "Quick Wash",
|
||||
"72": "Shirts",
|
||||
"73": "Sanitize",
|
||||
"74": "Drum Clean",
|
||||
"75": "Outdoor",
|
||||
"76": "Baby Care",
|
||||
"77": "Cottons",
|
||||
"78": "Rinse + Spin",
|
||||
"79": "Spin Only",
|
||||
"7c": "Witte was",
|
||||
"7d": "Beddengoed/waterdicht",
|
||||
"7e": "Self Clean",
|
||||
"7f": "Wol/fijne was",
|
||||
"86": "Diep wassen",
|
||||
"87": "Gedownload",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,54 @@ class TestCourseHelpers:
|
||||
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_uses_labels_then_safe_unknown_text(self):
|
||||
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) == 'Unknown (0x69)'
|
||||
assert laundry.washer_cycle_fallback('6f', resources) == 'Unknown (0x6F)'
|
||||
assert laundry.washer_cycle_fallback('Normal', resources) is None
|
||||
|
||||
|
||||
class TestCourseCodesFromSupportedOptions:
|
||||
"""cycle_options()'s fallback for boards that populate
|
||||
|
||||
@@ -45,3 +45,9 @@ def test_known_camel_case_state_uses_snake_case_translation_key():
|
||||
|
||||
def test_display_passes_through_non_string_values():
|
||||
assert _display(None, _UNTRANSLATED) is None
|
||||
|
||||
|
||||
def test_display_uses_fallback_when_translation_has_no_state_table():
|
||||
assert _display('69', 'cycle', lambda value: f'Unknown (0x{value})') == (
|
||||
'Unknown (0x69)'
|
||||
)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
(custom_components/localthings/select.py) -- the static tuple, options_field,
|
||||
and callable forms of SelectDesc.options.
|
||||
"""
|
||||
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
|
||||
@@ -106,3 +109,78 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
|
||||
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 = {'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(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']
|
||||
|
||||
@@ -125,6 +125,33 @@ def test_no_catalog_carries_unresolved_core_references():
|
||||
assert unresolved == [], language
|
||||
|
||||
|
||||
def test_confirmed_korean_table_02_washer_course_names():
|
||||
states = _load('en')['entity']['select']['washer_cycle_table_02']['state']
|
||||
assert {code: states[code] for code in (
|
||||
'69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71',
|
||||
'72', '73', '74', '75', '76', '77', '78', '79', '88',
|
||||
)} == {
|
||||
'69': 'AI Wash',
|
||||
'6a': 'Wool',
|
||||
'6b': 'Denim',
|
||||
'6c': 'Blouses',
|
||||
'6d': 'Delicates',
|
||||
'6e': 'Active Wear',
|
||||
'6f': 'Bedding',
|
||||
'70': 'Towels',
|
||||
'71': 'Quick Wash',
|
||||
'72': 'Shirts',
|
||||
'73': 'Sanitize',
|
||||
'74': 'Drum Clean',
|
||||
'75': 'Outdoor',
|
||||
'76': 'Baby Care',
|
||||
'77': 'Cottons',
|
||||
'78': 'Rinse + Spin',
|
||||
'79': 'Spin Only',
|
||||
'88': 'Pet Care',
|
||||
}
|
||||
|
||||
|
||||
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
|
||||
# so it presents as the device itself, and never reads a catalog name. Same
|
||||
# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
|
||||
@@ -233,4 +260,3 @@ def test_every_kimchi_zone_supportmode_code_has_a_state_label():
|
||||
if code.lower() not in state_labels:
|
||||
missing.append((path.name, href, code))
|
||||
assert missing == []
|
||||
|
||||
|
||||
@@ -82,6 +82,16 @@ class TestWasherCourse:
|
||||
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({'x.com.samsung.da.options': ['GMT_04']}) is None
|
||||
@@ -89,6 +99,7 @@ class TestWasherCourse:
|
||||
def test_cycle_desc_uses_cycle_options_callable(self):
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||
assert desc.options is laundry.cycle_options
|
||||
assert desc.display_fn is laundry.washer_cycle_fallback
|
||||
|
||||
def test_exists_only_when_edit_course_list_is_live(self):
|
||||
"""No hardcoded course table is kept -- the selector only appears
|
||||
|
||||
Reference in New Issue
Block a user