Simplification (feedback: this was overcomplicated): drop the validated_table gate entirely. cycle_select's table_href now just builds the translation key directly from whatever course table the device reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of comparing against a hardcoded known-good value and falling back to no key on any mismatch. A table we haven't shipped translations for yet (e.g. FlexWash's Table_00) still gets a key built for it -- Home Assistant's own missing-translation handling takes it from there, the same graceful fallback already relied on for any individual untranslated code within an existing table. Adding a newly-confirmed table later is just new strings.json entries, no code change. Independent (Opus) review of the prior version caught two real issues, fixed here regardless of the simplification above: - translation_key was resolved once at entity construction from whatever coordinator.last_resources held at that moment. Discovery can run while a sibling resource is still an empty stub (documented precedent: see _is_included), so a callable translation_key could permanently bake in a stale value for the entity's lifetime. Moved resolution into a translation_key property override (Entity.translation_key is a property upstream, not a plain attribute), re-evaluated against live coordinator data on every access, matching how options/current_option already work. - The supportedOptions fallback's "smallest passing K wins" docstring claimed every larger passing K is an exact multiple of the true one. False: the shipped dishwasher fixture has passing K=7 (true) alongside 10, 14, and 35, none of which are multiples of 7 -- position 0 always lands on the same real course code regardless of K, which alone satisfies the current-course guard for several unrelated splits. Corrected the reasoning to what's actually true (an empirically-matched heuristic across six real dumps, not a proof) and added a regression test locking in the real dishwasher case so this isn't silently lost.
77 lines
3.0 KiB
Python
77 lines
3.0 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 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 _make_select(desc, href, last_resources):
|
|
capability = Capability(href=href, entities=(desc,))
|
|
bound = BoundEntity(href=href, capability=capability, desc=desc)
|
|
return LocalThingsSelect(_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'
|