Files
ha-nvidia-shield/tests/test_media_sources.py
T
James Mackay 97615408c0 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.
2026-10-11 07:17:56 +01:00

259 lines
9.9 KiB
Python

"""tests/test_media_sources.py — source_list, source and native select_source (t47).
The last piece a universal-media-player replacement needs from this integration:
a dropdown of the apps (``source_list``), an answer to "what is on screen"
(``source``), and a way to pick one (``media_player.select_source``). All three read
the SAME app directory — the user's Apps map, else the curated default — so the
dropdown cannot offer a name the launch path cannot resolve.
The t44 rule still holds: a configured ``select_source`` script takes precedence,
because selecting a source can be more than launching an app. Without one, the name
resolves back to its package and goes out over the shared ``play_media("app", …)``
path; an unknown name is a translated error, never a silent no-op.
"""
import entity_stublib
import pytest
from nsh.const import CONF_APP_NAME_MAP, CONF_COMMAND_SCRIPTS, SCRIPT_VAR_APP_NAME
from test_command_routing import make_player
TIVIMATE = "ar.tvplayer.tv"
SCRIPT = "script.shield_source"
@pytest.fixture(name="modules", autouse=True)
def scoped_modules():
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
snapshot = entity_stublib.snapshot_modules()
entity_stublib.install()
try:
yield entity_stublib.load_entity_modules()
finally:
entity_stublib.restore_modules(snapshot)
def _player(modules, *, app_map=None, scripts=None, foreground=None):
player, served, _intents = make_player(modules)
if app_map is not None:
player._entry.options[CONF_APP_NAME_MAP] = app_map
if scripts is not None:
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
if foreground is not None:
player._manager._on_current_app(foreground)
return player, served
class TestSourceList:
"""The dropdown: friendly names, in a stable order, from the one directory."""
def test_it_comes_from_the_apps_map(self, modules):
player, _served = _player(
modules,
app_map={"com.custom.one": "One", "com.custom.two": "Two"},
)
assert player.source_list == ["One", "Two"] # map order, not shuffled
def test_it_falls_back_to_the_curated_directory(self, modules):
player, _served = _player(modules) # no map configured
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"}):
player, _served = _player(modules, app_map=app_map)
for name in player.source_list:
assert player._source_package(name) is not None, name
class TestSource:
"""`source` is the foreground app's resolved name — the app_name chain."""
def test_it_resolves_through_the_apps_map(self, modules):
player, _served = _player(
modules, app_map={TIVIMATE: "TiviMate"}, foreground=TIVIMATE
)
assert player.source == "TiviMate"
assert player.source == player.app_name # one truth, not two
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
player, _served = _player(modules, foreground="com.netflix.ninja")
player._manager._app_labels["com.netflix.ninja"] = (
"Netflix",
player._manager._clock(),
)
assert player.source == "Netflix"
def test_it_falls_back_to_the_raw_package(self, modules):
player, _served = _player(modules, foreground="com.unknown.app")
assert player.source == "com.unknown.app"
def test_it_is_unknown_when_nothing_has_reported(self, modules):
player, _served = _player(modules)
assert player.source is None
class TestSelectSource:
"""Picking a source: script first, else the native launch, else an error."""
async def test_a_name_launches_its_package(self, modules):
player, served = _player(
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
)
await player.async_select_source("TiviMate")
assert player._manager._protocol.launch_calls == [TIVIMATE]
assert served == [] # no script was configured, and nothing else left
async def test_the_curated_names_launch_too(self, modules):
player, _served = _player(modules)
await player.async_select_source("Netflix")
assert player._manager._protocol.launch_calls == ["com.netflix.ninja"]
async def test_a_raw_package_and_any_case_are_accepted(self, modules):
player, _served = _player(
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
)
await player.async_select_source(TIVIMATE)
await player.async_select_source("tivimate")
await player.async_select_source(" NETFLIX ")
assert player._manager._protocol.launch_calls == [
TIVIMATE,
TIVIMATE,
"com.netflix.ninja",
]
async def test_a_configured_script_still_wins(self, modules):
player, served = _player(
modules,
app_map={TIVIMATE: "TiviMate"},
scripts={"select_source": SCRIPT},
)
await player.async_select_source("TiviMate")
assert served == [
(
"script",
"turn_on",
{"entity_id": SCRIPT, "variables": {SCRIPT_VAR_APP_NAME: "TiviMate"}},
)
]
assert player._manager._protocol.launch_calls == [] # native path untouched
async def test_an_unknown_name_raises_a_translated_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
player, _served = _player(modules, app_map={TIVIMATE: "TiviMate"})
with pytest.raises(ServiceValidationError) as raised:
await player.async_select_source("Not An App")
assert raised.value.translation_domain == "nvidia_shield"
assert raised.value.translation_key == "unknown_source"
assert raised.value.translation_placeholders == {"source": "Not An App"}
assert player._manager._protocol.launch_calls == [] # nothing was sent
async def test_an_empty_name_is_an_error_too(self, modules):
from homeassistant.exceptions import ServiceValidationError
player, _served = _player(modules)
with pytest.raises(ServiceValidationError):
await player.async_select_source(" ")
class TestAdvertisedFeatures:
"""HA routes a service by its feature flag, so the flag has to be honest."""
def test_a_source_list_advertises_select_source(self, modules):
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules)
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
def test_a_script_advertises_it_even_with_no_directory(self, modules, monkeypatch):
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, scripts={"select_source": SCRIPT})
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
def test_neither_means_it_is_not_offered(self, modules, monkeypatch):
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules)
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
assert not player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
def test_select_source_is_not_gated_on_the_volume_set_script(self, modules):
"""The two script-only commands stay independent."""
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, scripts={"volume_set": "script.volume"})
assert player.supported_features & MediaPlayerEntityFeature.VOLUME_SET
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE