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:
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:
|
||||||
|
|||||||
@@ -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"}):
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in new issue
Block a user