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:
James Mackay committed 2026-10-09 06:00:17 +01:00
1 parent 3d026ae60e
commit a3b397920e
7 files changed
+320 -18

No files matched your search

+93
View File
@@ -724,6 +724,99 @@ class TestAdbCommandService:
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:
"""F4: the participant key is computed once — add/remove always match."""
+107 -8
View File
@@ -374,17 +374,116 @@ def _cmd_player(modules):
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:
"""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):
import entity_stublib