fix(select): stop lowercasing option display values that aren't translated
_normalize() lowercased every select's options/state for display, but that's only needed for entities with a translation_key (whose strings.json lookup requires lowercase keys, per hassfest). Untranslated selects (Cycle, Smart Dry, Sound mode, LED brightness) had no lookup to protect and were just getting mangled -- "AI Wash" became "ai wash", "ExtraHigh" became "extrahigh", etc. Replace with _display(), which only lowercases for translation_key entities and otherwise passes the device's own casing through, with two cosmetic fixups: a fully lowercase wire value (e.g. "voice") is title-cased, and a PascalCase value (e.g. "ExtraHigh") gets a space at the case boundary. Already human-friendly values pass through untouched. Avoids hand-authoring strings.json translations for open-ended, per-model option lists (e.g. dishwasher cycle names) that would silently regress on any value we didn't enumerate.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"""Select platform for Local Things."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -26,15 +28,33 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _normalize(value):
|
||||
"""HA option/state values must be lowercase to serve as translation keys.
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
||||
|
||||
Samsung's raw enum values are upper snake case (e.g.
|
||||
CV_TTYPE_RF9000A_FREEZE); the device still expects that exact casing
|
||||
back on write, so callers must map the normalized value back via
|
||||
_raw_options() before sending a command.
|
||||
|
||||
def _display(value, desc: SelectDesc):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
An entity with a translation_key looks its state up in strings.json,
|
||||
and hassfest requires those keys to be lowercase -- so those values
|
||||
must be lowercased exactly to match, and the device still expects
|
||||
that same raw casing back on write (callers map the displayed value
|
||||
back to raw via _raw_options()).
|
||||
|
||||
Everything else has no strings.json lookup, so there's no reason to
|
||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
||||
fully lowercase device-native token (e.g. "voice") is title-cased,
|
||||
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
|
||||
the case boundary ("Extra High"). A value that's already
|
||||
human-friendly (e.g. "AI Wash") matches neither pattern and passes
|
||||
through unchanged.
|
||||
"""
|
||||
return value.lower() if isinstance(value, str) else value
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if desc.translation_key:
|
||||
return value.lower()
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
|
||||
|
||||
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
@@ -43,7 +63,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
if not desc.options_field:
|
||||
self._attr_options = [_normalize(o) for o in desc.options]
|
||||
self._attr_options = [_display(o, desc) for o in desc.options]
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
@@ -56,16 +76,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
if desc.options_field:
|
||||
return [_normalize(o) for o in self._raw_options()]
|
||||
return [_display(o, desc) 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 _normalize(raw)
|
||||
return _display(raw, self._bound.desc)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _normalize(o) == option), option
|
||||
(o for o in self._raw_options() if _display(o, desc) == option), option
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for select-option display casing (custom_components/localthings/select.py)."""
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.select import _display
|
||||
|
||||
_UNTRANSLATED = SelectDesc(key='x', options=())
|
||||
_TRANSLATED = SelectDesc(key='y', options=(), translation_key='door_alert')
|
||||
|
||||
|
||||
def test_display_titlecases_a_fully_lowercase_device_native_token():
|
||||
"""Samsung's sound-mode field is genuinely lowercase on the wire
|
||||
('voice'/'tone'/'mute') -- these have no other casing signal to key
|
||||
off, so title-case them for display."""
|
||||
assert _display('voice', _UNTRANSLATED) == 'Voice'
|
||||
assert _display('mute', _UNTRANSLATED) == 'Mute'
|
||||
|
||||
|
||||
def test_display_inserts_a_space_at_a_camelcase_boundary():
|
||||
"""'ExtraHigh' (from supportedHeatedDry) should read as two words."""
|
||||
assert _display('ExtraHigh', _UNTRANSLATED) == 'Extra High'
|
||||
|
||||
|
||||
def test_display_passes_through_an_already_human_friendly_value():
|
||||
"""'AI Wash' etc. (dishwasher cycle names) already read fine and
|
||||
have no camelCase boundary or all-lowercase pattern -- must not be
|
||||
mangled."""
|
||||
assert _display('AI Wash', _UNTRANSLATED) == 'AI Wash'
|
||||
assert _display('Low', _UNTRANSLATED) == 'Low'
|
||||
assert _display('Off', _UNTRANSLATED) == 'Off'
|
||||
|
||||
|
||||
def test_display_lowercases_for_translation_key_lookup():
|
||||
"""An entity with a translation_key must match strings.json's
|
||||
lowercase keys exactly -- unlike the untranslated cases above, this
|
||||
is not a cosmetic transform."""
|
||||
assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3'
|
||||
|
||||
|
||||
def test_display_passes_through_non_string_values():
|
||||
assert _display(None, _UNTRANSLATED) is None
|
||||
Reference in New Issue
Block a user