diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index d1a0a85..3bfa0fc 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -729,8 +729,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): Directory order (the user's map order, or the curated list), so rebuilding the page does not shuffle it. + + A blank or whitespace-only name is dropped (t49): the apps map is hand-editable + (``com.x=``), and an empty entry would sit in the dropdown as something to pick + that select_source then rejects as an unknown source — an option that can only + fail. The VALUES themselves are untouched, so for a well-formed map the list + stays byte-equal to the Browse tree's child titles (same accessor, same order); + names are never rewritten here, because the reverse lookup matches on them. """ - return [str(name) for name in self._app_directory.values()] + return [str(name) for name in self._app_directory.values() if str(name).strip()] @property def source(self) -> str | None: diff --git a/tests/test_media_sources.py b/tests/test_media_sources.py index 176cffd..02ae3ad 100644 --- a/tests/test_media_sources.py +++ b/tests/test_media_sources.py @@ -59,6 +59,54 @@ class TestSourceList: assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"] + def test_a_blank_name_never_reaches_the_dropdown(self, modules): + """t49: the map is hand-editable, so 'com.x=' must not offer an empty entry. + + An empty entry is worst than useless: it is a dropdown item that + select_source has to reject as an unknown source. + """ + player, _served = _player( + modules, + app_map={ + "com.blank": "", + TIVIMATE: "TiviMate", + "com.spaces": " ", + "com.netflix.ninja": "Netflix", + }, + ) + + assert player.source_list == ["TiviMate", "Netflix"] + assert "" not in player.source_list + + async def test_the_list_matches_the_browse_titles_exactly(self, modules): + """Same accessor, same order, same bytes — a blank entry excepted (t49).""" + for app_map in ( + None, # the curated default + {"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"}, + ): + player, _served = _player(modules, app_map=app_map) + + root = await player.async_browse_media() + + assert player.source_list == [child.title for child in root.children] + + async def test_the_survivors_still_resolve_and_launch(self, modules): + """The filter must not cost a resolvable name (t49).""" + player, _served = _player( + modules, + app_map={"com.blank": "", TIVIMATE: "TiviMate", "com.blank2": " "}, + ) + + for name in player.source_list: + assert player._source_package(name) is not None + + await player.async_select_source("TiviMate") + + assert player._manager._protocol.launch_calls == [TIVIMATE] + # the blank entries are not selectable, and never launch + assert player._source_package("") is None + assert player._source_package(" ") is None + def test_every_offered_name_resolves_to_a_package(self, modules): """The invariant the shared directory buys: no name without a destination.""" for app_map in (None, {"com.custom.one": "One"}): diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index a384f48..47bc795 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -545,6 +545,31 @@ class TestAppIconColumn: assert result["type"] == "form" assert result["errors"] == {"base": "invalid_app_map"} + async def test_a_nameless_row_is_rejected_by_the_editor(self, tmp_path): + """t49: the editor already refuses 'com.x=' — a blank name cannot get in. + + The source list filters blanks anyway, because the map can also be + hand-edited; this pins the other half: the page will not STORE one. + """ + module = _form_module() + + assert module._app_map_from_text("com.blank=", {}) is None + assert module._app_map_from_text("com.blank= ", {}) is None + + flow = module.NvidiaShieldOptionsFlow() + flow.config_entry = SimpleNamespace(options={}, title="Shield") + 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": "com.blank=\ncom.netflix.ninja=Netflix"} + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_app_map"} + assert "app_name_map" not in flow._options # nothing half-stored + class TestChannelEntryOptions: """P5-2: digit-delay + optional send-enter on the options page."""