fix(media_player): keep blank app names out of the source list (t49 F1)

t48 F1: source_list stringified the app map's values without filtering, so a
hand-edited map with a blank name ("com.x=") put an empty string in the dropdown.
That entry is worse than useless — it is a dropdown item whose only possible outcome
is an error, because select_source has to reject an empty name as an unknown source.

source_list now drops blank and whitespace-only names. The values themselves are
untouched: no name is rewritten or trimmed here, because the reverse lookup matches on
them, so for a well-formed map the list stays byte-equal to the Browse tree's child
titles (same _app_directory accessor, same order) — pinned by comparing the two
directly, for the user's map and for the curated default. The reverse lookup is
unchanged.

Tests (tests/test_media_sources.py, 3 new; tests/test_standalone_setup.py, 1 new):
a hand-edited map with "" and "   " entries offers only the real names; the offered
list equals the Browse tree titles for well-formed maps (both map and curated
default); the surviving entries still resolve AND launch through select_source, and a
blank name still resolves to nothing; and the editor keeps rejecting "com.x="
(_app_map_from_text returns None, the page answers invalid_app_map and stores nothing
half-way), so a blank name cannot get in that way either.

RED PROOF: with the filter removed, exactly the two blank-name tests fail — the blank
appears in the list, and the entry has no package to resolve to (1 passed / 2 failed;
the byte-equality test passes either way, which is the "well-formed list unchanged"
half of the claim) — while the rest of the file is green; restored, 20 pass.

Verified: 458 passed / 2 skipped, 459 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls).

Residual, deliberately NOT touched here (out of this repair's scope, and pre-existing):
a blank map entry still reaches the Browse tree as an empty child title, and a PADDED
name ("  TiviMate ", hand-edited only — the editor strips) still fails the reverse
lookup because the map value is matched unstripped. Both are hand-edited-map edges;
say the word and either can be a follow-up.
This commit is contained in:
James Mackay committed 2026-10-11 07:17:56 +01:00
1 parent 1c6c223f27
commit 97615408c0
3 files changed
+81 -1

No files matched your search

@@ -729,8 +729,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
Directory order (the user's map order, or the curated list), so rebuilding Directory order (the user's map order, or the curated list), so rebuilding
the page does not shuffle it. 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 @property
def source(self) -> str | None: def source(self) -> str | None:
+48
View File
@@ -59,6 +59,54 @@ class TestSourceList:
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"] 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): def test_every_offered_name_resolves_to_a_package(self, modules):
"""The invariant the shared directory buys: no name without a destination.""" """The invariant the shared directory buys: no name without a destination."""
for app_map in (None, {"com.custom.one": "One"}): for app_map in (None, {"com.custom.one": "One"}):
+25
View File
@@ -545,6 +545,31 @@ class TestAppIconColumn:
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"} 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: class TestChannelEntryOptions:
"""P5-2: digit-delay + optional send-enter on the options page.""" """P5-2: digit-delay + optional send-enter on the options page."""