test(config_flow,media_player,remote): digit-delay/send-enter options (store + window/error validation), IME-first text entry with ADB fallback tier, icon column parse/prefill/store (P5-2)
This commit is contained in:
1 parent
3d026ae60e
commit
a3b397920e
7 files changed
+320
-18
No files matched your search
@@ -20,6 +20,7 @@ from .const import (
|
|||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_APP_ICON_MAP,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
CONF_CHANNEL_SEND_ENTER,
|
CONF_CHANNEL_SEND_ENTER,
|
||||||
@@ -80,13 +81,27 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _app_map_to_text(app_map: dict) -> str:
|
def _app_map_to_text(app_map: dict, icon_map: dict | None = None) -> str:
|
||||||
"""Serialize the apps map to one ``package=name`` per line."""
|
"""Serialize the apps map to one ``package=name[|icon]`` per line."""
|
||||||
return "\n".join(f"{pkg}={name}" for pkg, name in app_map.items())
|
icon_map = icon_map or {}
|
||||||
|
lines = []
|
||||||
|
for pkg, name in app_map.items():
|
||||||
|
icon = icon_map.get(pkg)
|
||||||
|
lines.append(f"{pkg}={name}|{icon}" if icon else f"{pkg}={name}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _app_map_from_text(text: str) -> dict[str, str] | None:
|
def _app_map_from_text(
|
||||||
"""Parse one ``package=name`` per line; None on malformed input."""
|
text: str, icons: dict[str, str] | None = None
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
"""Parse one ``package=name[|icon]`` per line; None on malformed input.
|
||||||
|
|
||||||
|
The optional second column (a picture URL or entity id) feeds
|
||||||
|
entity_picture through CONF_APP_ICON_MAP; single-column maps stay
|
||||||
|
valid. Malformed icon text (spaces) invalidates the row the same way
|
||||||
|
a malformed name does.
|
||||||
|
"""
|
||||||
|
icons = icons if icons is not None else {}
|
||||||
app_map: dict[str, str] = {}
|
app_map: dict[str, str] = {}
|
||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -94,11 +109,18 @@ def _app_map_from_text(text: str) -> dict[str, str] | None:
|
|||||||
continue
|
continue
|
||||||
if "=" not in line:
|
if "=" not in line:
|
||||||
return None
|
return None
|
||||||
package, name = line.split("=", 1)
|
package, value = line.split("=", 1)
|
||||||
|
parts = value.strip().split("|", 1)
|
||||||
|
name = parts[0].strip()
|
||||||
|
icon = parts[1].strip() if len(parts) > 1 else ""
|
||||||
package = package.strip()
|
package = package.strip()
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
if not package or not name or " " in package:
|
if not package or not name or " " in package:
|
||||||
return None
|
return None
|
||||||
|
if icon:
|
||||||
|
if " " in icon:
|
||||||
|
return None
|
||||||
|
icons[package] = icon
|
||||||
app_map[package] = name
|
app_map[package] = name
|
||||||
return app_map
|
return app_map
|
||||||
|
|
||||||
@@ -511,12 +533,13 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
if delay is not None and not (0.05 <= delay <= 1.0):
|
if delay is not None and not (0.05 <= delay <= 1.0):
|
||||||
errors["base"] = "invalid_digit_delay"
|
errors["base"] = "invalid_digit_delay"
|
||||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||||
|
icons: dict[str, str] = {}
|
||||||
if app_map_text is None:
|
if app_map_text is None:
|
||||||
app_map = self._options.get(CONF_APP_NAME_MAP, {})
|
app_map = self._options.get(CONF_APP_NAME_MAP, {})
|
||||||
elif app_map_text.strip() == "":
|
elif app_map_text.strip() == "":
|
||||||
app_map = {}
|
app_map = {}
|
||||||
else:
|
else:
|
||||||
app_map = _app_map_from_text(app_map_text)
|
app_map = _app_map_from_text(app_map_text, icons)
|
||||||
if app_map is None:
|
if app_map is None:
|
||||||
errors["base"] = "invalid_app_map"
|
errors["base"] = "invalid_app_map"
|
||||||
if not errors:
|
if not errors:
|
||||||
@@ -524,16 +547,22 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
|
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||||
self._options.update(user_input)
|
self._options.update(user_input)
|
||||||
self._options[CONF_APP_NAME_MAP] = app_map
|
self._options[CONF_APP_NAME_MAP] = app_map
|
||||||
|
# P5-2: the icon column — blank/sparse keeps prior entries?
|
||||||
|
# No: the editor is the source of truth, so a save without
|
||||||
|
# icons clears them (single-column stays valid).
|
||||||
|
self._options[CONF_APP_ICON_MAP] = icons
|
||||||
return self.async_create_entry(title="", data=self._options)
|
return self.async_create_entry(title="", data=self._options)
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
schema_dict = {}
|
schema_dict = {}
|
||||||
|
|
||||||
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||||||
# pattern, simplified): one package=name per line.
|
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||||||
|
# the optional icon column).
|
||||||
current_map = self._options.get(CONF_APP_NAME_MAP, {})
|
current_map = self._options.get(CONF_APP_NAME_MAP, {})
|
||||||
|
current_icons = self._options.get(CONF_APP_ICON_MAP, {})
|
||||||
if current_map:
|
if current_map:
|
||||||
app_map_default = _app_map_to_text(current_map)
|
app_map_default = _app_map_to_text(current_map, current_icons)
|
||||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
|
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
|
||||||
else:
|
else:
|
||||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||||||
|
|||||||
@@ -495,6 +495,41 @@ class ShieldConnectionManager:
|
|||||||
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
async def send_text_ime(self, text: str) -> bool:
|
||||||
|
"""Text entry via the protocol client's send_text (P5-2).
|
||||||
|
|
||||||
|
The Shield may swallow the text when its on-screen virtual keyboard
|
||||||
|
is up (RESEARCH_IDEAS §2 availability gate) — the call reports the
|
||||||
|
send outcome; callers fall back to the ADB input-text path only
|
||||||
|
when the protocol transport is down or the send failed.
|
||||||
|
"""
|
||||||
|
if self._protocol is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self._protocol.send_text(text)
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("protocol send_text failed: %s", err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_adb_input_text(self, text: str) -> bool:
|
||||||
|
"""ADB fallback for text entry: `input text` (P5-2).
|
||||||
|
|
||||||
|
Used only when the protocol send_text path is unavailable/failed.
|
||||||
|
Shell-quoted (shlex) so single quotes and spaces survive.
|
||||||
|
"""
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
poller = self._poller
|
||||||
|
if poller is None or not self.connected:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = await poller.shell(f"input text {shlex.quote(text)}")
|
||||||
|
return result is not None
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("adb input text failed: %s", err)
|
||||||
|
return False
|
||||||
|
|
||||||
def app_label(self, package: str | None) -> str | None:
|
def app_label(self, package: str | None) -> str | None:
|
||||||
"""Cached manifest label for a package (P5-1), or None.
|
"""Cached manifest label for a package (P5-1), or None.
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
|
|||||||
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
||||||
CONF_APP_NAME_MAP = "app_name_map"
|
CONF_APP_NAME_MAP = "app_name_map"
|
||||||
|
|
||||||
|
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
|
||||||
|
# entity_picture when no Jellyfin session picture is live.
|
||||||
|
CONF_APP_ICON_MAP = "app_icon_map"
|
||||||
|
|
||||||
# P5-1: device manifest label cache TTL (long — labels change with app
|
# P5-1: device manifest label cache TTL (long — labels change with app
|
||||||
# updates, not per poll).
|
# updates, not per poll).
|
||||||
APP_LABEL_TTL = 24 * 3600
|
APP_LABEL_TTL = 24 * 3600
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ from .const import (
|
|||||||
CONF_ADB_COMMAND_ENABLED,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_APP_ICON_MAP,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
CONF_CHANNEL_SEND_ENTER,
|
CONF_CHANNEL_SEND_ENTER,
|
||||||
@@ -629,7 +630,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def media_image_url(self):
|
def media_image_url(self):
|
||||||
return self._jf_attr("entity_picture") if self._jf_active() else None
|
"""Jellyfin session picture, else the P5-2 apps-map icon override."""
|
||||||
|
if self._jf_active():
|
||||||
|
jf = self._jf_attr("entity_picture")
|
||||||
|
if jf:
|
||||||
|
return jf
|
||||||
|
package = self._manager.foreground_app if self._manager else None
|
||||||
|
if package:
|
||||||
|
icon_map = self._entry.options.get(CONF_APP_ICON_MAP, {})
|
||||||
|
return icon_map.get(package)
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def volume_level(self) -> float | None:
|
def volume_level(self) -> float | None:
|
||||||
@@ -949,7 +959,25 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
Channel path replicates HA core androidtv_remote exactly: digits only,
|
Channel path replicates HA core androidtv_remote exactly: digits only,
|
||||||
configurable inter-digit delay (default 0.1 s), no ENTER — and the
|
configurable inter-digit delay (default 0.1 s), no ENTER — and the
|
||||||
padded prior in-flight channel sequence is cancelled first.
|
padded prior in-flight channel sequence is cancelled first.
|
||||||
|
TEXT entry (P5-2): play_media("text", "...") — protocol IME send_text
|
||||||
|
first; the ADB `input text` fallback tier runs only when the
|
||||||
|
protocol send is unavailable or failed (the Shield may swallow IME
|
||||||
|
text while its on-screen keyboard is up, RESEARCH_IDEAS §2).
|
||||||
"""
|
"""
|
||||||
|
if media_type == "text":
|
||||||
|
if not media_id:
|
||||||
|
_LOGGER.warning("play_media(text): empty text ignored")
|
||||||
|
return
|
||||||
|
if self._manager is not None and await self._manager.send_text_ime(media_id):
|
||||||
|
return
|
||||||
|
if self._manager is not None and await self._manager.send_adb_input_text(media_id):
|
||||||
|
return
|
||||||
|
_LOGGER.warning(
|
||||||
|
"play_media(text): no transport available (protocol down, ADB"
|
||||||
|
" unavailable) — text not sent"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if media_type == "channel":
|
if media_type == "channel":
|
||||||
digits = [c for c in media_id if c.isdigit()]
|
digits = [c for c in media_id if c.isdigit()]
|
||||||
if not digits:
|
if not digits:
|
||||||
|
|||||||
@@ -174,3 +174,17 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
await manager.send_protocol_key(key_name, direction)
|
await manager.send_protocol_key(key_name, direction)
|
||||||
if delay_secs > 0:
|
if delay_secs > 0:
|
||||||
await asyncio.sleep(delay_secs)
|
await asyncio.sleep(delay_secs)
|
||||||
|
|
||||||
|
async def async_send_text(self, text: str) -> bool:
|
||||||
|
"""Text entry (P5-2) via the protocol send_text (remote surface).
|
||||||
|
|
||||||
|
Falls back to ADB `input text` only when the protocol transport is
|
||||||
|
down or the send failed (the Shield may swallow IME text while its
|
||||||
|
on-screen keyboard is up, RESEARCH_IDEAS §2).
|
||||||
|
"""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if manager is None or not text:
|
||||||
|
return False
|
||||||
|
if await manager.send_text_ime(text):
|
||||||
|
return True
|
||||||
|
return await manager.send_adb_input_text(text)
|
||||||
@@ -724,6 +724,99 @@ class TestAdbCommandService:
|
|||||||
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
|
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextEntry:
|
||||||
|
"""P5-2: text entry — protocol IME first, ADB input-text fallback."""
|
||||||
|
|
||||||
|
def _player_with_texts(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
protocol.text_calls = []
|
||||||
|
protocol.send_text = lambda t: protocol.text_calls.append(t) or True
|
||||||
|
shell_texts = []
|
||||||
|
|
||||||
|
async def _shell(cmd):
|
||||||
|
shell_texts.append(cmd)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
player._manager._poller.shell = _shell
|
||||||
|
return player, protocol, shell_texts
|
||||||
|
|
||||||
|
async def test_protocol_ime_sends_first(self, modules):
|
||||||
|
player, protocol, shell_texts = self._player_with_texts(modules)
|
||||||
|
|
||||||
|
await player.async_play_media("text", "hello world")
|
||||||
|
|
||||||
|
assert protocol.text_calls == ["hello world"]
|
||||||
|
assert shell_texts == [] # ADB fallback not used
|
||||||
|
|
||||||
|
async def test_adb_fallback_when_the_protocol_send_fails(self, modules):
|
||||||
|
player, protocol, shell_texts = self._player_with_texts(modules)
|
||||||
|
|
||||||
|
def _fail(text):
|
||||||
|
protocol.text_calls.append(text)
|
||||||
|
raise RuntimeError("ime closed")
|
||||||
|
|
||||||
|
protocol.send_text = _fail
|
||||||
|
await player.async_play_media("text", "hello 'world'")
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
sample = "hello " + chr(39) + "world" + chr(39)
|
||||||
|
assert shell_texts == [f"input text {shlex.quote(sample)}"]
|
||||||
|
|
||||||
|
async def test_empty_text_is_ignored(self, modules):
|
||||||
|
player, protocol, shell_texts = self._player_with_texts(modules)
|
||||||
|
|
||||||
|
await player.async_play_media("text", "")
|
||||||
|
|
||||||
|
assert protocol.text_calls == [] and shell_texts == []
|
||||||
|
player._manager._protocol.send_text = lambda t: True
|
||||||
|
await player.async_play_media("text", "")
|
||||||
|
assert protocol.text_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIconOverride:
|
||||||
|
"""P5-2: optional icon column → CONF_APP_ICON_MAP → entity_picture."""
|
||||||
|
|
||||||
|
def _player_with_icon(self, modules, icon_map):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._entry.options["app_icon_map"] = icon_map
|
||||||
|
return player
|
||||||
|
|
||||||
|
async def test_icon_serves_when_no_jellyfin_picture(self, modules):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
player = self._player_with_icon(
|
||||||
|
modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"}
|
||||||
|
)
|
||||||
|
manager = player._manager
|
||||||
|
manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert player.media_image_url == "http://icons.local/tivi.png"
|
||||||
|
|
||||||
|
async def test_no_icon_for_an_unmatched_package(self, modules):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
player = self._player_with_icon(
|
||||||
|
modules, {"com.netflix.ninja": "http://icons.local/netflix.png"}
|
||||||
|
)
|
||||||
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert player.media_image_url is None
|
||||||
|
|
||||||
|
async def test_jellyfin_picture_wins_over_the_icon_map(self, modules):
|
||||||
|
|
||||||
|
player = self._player_with_icon(
|
||||||
|
modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"}
|
||||||
|
)
|
||||||
|
player._jf_active = lambda: True
|
||||||
|
player._jf_attr = lambda attr: "http://jf.local/pic.jpg"
|
||||||
|
|
||||||
|
assert player.media_image_url == "http://jf.local/pic.jpg"
|
||||||
|
|
||||||
|
|
||||||
class TestServiceRegistryKey:
|
class TestServiceRegistryKey:
|
||||||
"""F4: the participant key is computed once — add/remove always match."""
|
"""F4: the participant key is computed once — add/remove always match."""
|
||||||
|
|
||||||
|
|||||||
@@ -374,17 +374,116 @@ def _cmd_player(modules):
|
|||||||
return player, None, None
|
return player, None, None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppIconColumn:
|
||||||
|
"""P5-2: the editor's optional icon column (package=name|icon)."""
|
||||||
|
|
||||||
|
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
|
||||||
|
|
||||||
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
||||||
|
|
||||||
|
async def test_single_column_map_stays_valid(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"})
|
||||||
|
|
||||||
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
|
||||||
|
|
||||||
|
async def test_prefill_round_trips_the_icon_column(self):
|
||||||
|
module = _form_module()
|
||||||
|
|
||||||
|
# the serializer writes the second column through on prefill
|
||||||
|
text = module._app_map_to_text(
|
||||||
|
{"ar.tvplayer.tv": "TiviMate"},
|
||||||
|
{"ar.tvplayer.tv": "http://i.local/t.png"},
|
||||||
|
)
|
||||||
|
assert text == "ar.tvplayer.tv=TiviMate|http://i.local/t.png"
|
||||||
|
|
||||||
|
# and the parser round-trips it back
|
||||||
|
icons: dict[str, str] = {}
|
||||||
|
app_map = module._app_map_from_text(text, icons)
|
||||||
|
assert app_map == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
assert icons == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
||||||
|
|
||||||
|
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_app_map"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelEntryOptions:
|
||||||
|
"""P5-2: digit-delay + optional send-enter on the options page."""
|
||||||
|
|
||||||
|
def _options_flow(self, tmp_path, options):
|
||||||
|
from types import SimpleNamespace as DefaultEntry
|
||||||
|
|
||||||
|
module = _form_module()
|
||||||
|
entry = DefaultEntry(options=dict(options) if options else {})
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
flow.hass = hass
|
||||||
|
flow._options = dict(options) if options else {}
|
||||||
|
return module, flow
|
||||||
|
|
||||||
|
async def test_digit_delay_and_send_enter_store(self, tmp_path):
|
||||||
|
module, flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True})
|
||||||
|
|
||||||
|
assert flow._options["channel_digit_delay"] == 0.3
|
||||||
|
assert flow._options["channel_send_enter"] is True
|
||||||
|
|
||||||
|
async def test_digit_delay_out_of_window_rejects(self, tmp_path):
|
||||||
|
module, flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"channel_digit_delay": "2.0"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
async def test_digit_delay_not_a_number_rejects(self, tmp_path):
|
||||||
|
module, flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"channel_digit_delay": "slow"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyUniqueIdMigration:
|
class TestLegacyUniqueIdMigration:
|
||||||
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
||||||
|
|
||||||
def _entry_module(self):
|
|
||||||
import entity_stublib
|
|
||||||
|
|
||||||
entity_stublib.install()
|
|
||||||
if "nsh.media_player" not in sys.modules:
|
|
||||||
entity_stublib.load_entity_modules()
|
|
||||||
return sys.modules["nsh.media_player"]
|
|
||||||
|
|
||||||
async def test_migrates_to_entry_id_without_protocol(self, tmp_path):
|
async def test_migrates_to_entry_id_without_protocol(self, tmp_path):
|
||||||
import entity_stublib
|
import entity_stublib
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user