feat(media-player): a switch Receiver is ensured on, and apps leave the browser (t61)

Two halves of the same live-run report.

RECEIVER AS A SWITCH. The user: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". `_ensure_receiver_on()`
runs at the top of the three playback entry points — turn_on, play_media,
select_source — and calls switch.turn_on only when the configured switch
receiver reports `off`: an `on` switch gets no call (idempotent), an
unavailable/unknown/missing state is left alone with a debug line rather than
guessed at, and a failing service call is logged, never raised — a receiver that
cannot be switched on must not stop the Shield from playing. Nothing else about
the switch is touched (no volume, no toggle), and nothing switches it from a
state read, a poll or a listener, so an off receiver stays off while the Shield
is idle. A media_player receiver keeps its behaviour exactly. select_source now
dispatches through the private `_dispatch_play_media`, so one user action looks
at the same switch once; `play_media` keeps its public signature and behaviour.
The Receiver field already accepted switch.* at the validation/strings layer
(t56, pinned by the real-HA tests); the strings copy now says what playback does
with it.

MEDIA BROWSER. The user: "Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps".
`async_browse_media` no longer publishes the app directory: it returns a valid,
standard BrowseMedia root (device title, DIRECTORY class, no content id, no
children) instead of a second, inconsistent copy of the list. Apps stay reachable
through source_list/source/select_source and play_media("app", <package>), which
is unchanged and complete; nothing else on this entity is browsable, so there is
no dead entry to select, and the drift gate's t37 D1 browse assertions still hold.

Tests: tests/test_receiver_switch.py pins all four promised states — off →
exactly one switch.turn_on; on → none; unavailable/unknown/missing/failing →
playback continues and nothing raises; media_player receiver → never switched —
plus the idle-Shield rule. The browse pins move from the removed tree to the
surviving surfaces (source_list, _source_package, _app_directory), and the harness
gains the real hass.states shape (FakeStates) and the real switch state constants.
README documents both changes; both strings files stay byte-identical.
This commit is contained in:
engineer committed 2026-10-12 08:34:53 +01:00
1 parent c884c7e1ab
commit 431b7cd8f4
8 files changed
+448 -133

No files matched your search

+35 -35
View File
@@ -78,17 +78,20 @@ class TestSourceList:
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)."""
def test_the_list_is_the_normalized_directory_exactly(self, modules):
"""Same accessor, same order, same bytes — a blank entry excepted (t49).
t61 removed the browse tree that used to be the second consumer of this
one accessor; the dropdown and the reverse lookup are still fed from it,
so a row can only launch the app it shows.
"""
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]
assert player.source_list == list(player._app_directory.values())
async def test_the_survivors_still_resolve_and_launch(self, modules):
"""The filter must not cost a resolvable name (t49)."""
@@ -279,6 +282,11 @@ class TestNormalizedDirectories:
accessor (strip names and keys, drop blanks, first-in-map-order wins a shared
name) and is pinned here, together with the promise that a well-formed map is
untouched.
t61 left two surfaces instead of three: the Browse tree that used to be the
second view is gone (apps are sources), so the pins below anchor the same
policy on `_app_directory` — the shared accessor itself — and on
`source_list`/`_source_package`, which are what a user can still reach.
"""
BLANKED = {
@@ -288,25 +296,21 @@ class TestNormalizedDirectories:
"": "Ghost",
}
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
"""The Browse tree and the dropdown are the same, resolved offer."""
def test_a_blank_entry_never_reaches_the_dropdown(self, modules):
"""Blanks are dropped once, in the accessor every offer reads (t49)."""
player, _served = _player(modules, app_map=dict(self.BLANKED))
root = await player.async_browse_media()
assert [child.title for child in root.children] == ["TiviMate"]
assert [child.media_content_id for child in root.children] == [TIVIMATE]
assert list(player._app_directory) == [TIVIMATE]
assert list(player._app_directory.values()) == ["TiviMate"]
assert player.source_list == ["TiviMate"]
async def test_a_blank_package_is_dropped_too(self, modules):
"""An entry with no package is a child that cannot be launched."""
def test_a_blank_package_is_dropped_too(self, modules):
"""An entry with no package is a row that cannot be launched."""
player, _served = _player(
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
)
root = await player.async_browse_media()
assert [child.media_content_id for child in root.children] == [TIVIMATE]
assert list(player._app_directory) == [TIVIMATE]
assert player.source_list == ["TiviMate"]
async def test_a_padded_name_is_stripped_once_and_then_resolves(self, modules):
@@ -315,11 +319,9 @@ class TestNormalizedDirectories:
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
)
root = await player.async_browse_media()
# display and match key are one string, everywhere
assert player.source_list == ["TiviMate", "Netflix"]
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
assert list(player._app_directory.values()) == ["TiviMate", "Netflix"]
# ...so every spelling the user may type finds the package
assert player._source_package("TiviMate") == TIVIMATE
assert player._source_package(" TiviMate ") == TIVIMATE
@@ -335,13 +337,11 @@ class TestNormalizedDirectories:
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
)
root = await player.async_browse_media()
# both are still offered in the tree, each by its own package id
assert [(c.media_content_id, c.title) for c in root.children] == [
("com.a", "Netflix"),
("com.b", "Netflix"),
]
# t61: the directory keeps BOTH entries, each under its own package id;
# the removal of the browse tree does not remove the reachability it
# used to expose — select_source-by-id is that surface now.
assert player._app_directory == {"com.a": "Netflix", "com.b": "Netflix"}
assert player._source_package("com.b") == "com.b" # the shadowed package
# the dropdown offers the name once, and resolves to the first entry
assert player.source_list == ["Netflix"]
assert player._source_package("Netflix") == "com.a"
@@ -363,10 +363,11 @@ class TestNormalizedDirectories:
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
)
root = await player.async_browse_media()
assert player._app_directory == {
TIVIMATE: "TiviMate Pro",
"com.netflix.ninja": "Netflix",
}
assert player.source_list == ["TiviMate Pro", "Netflix"]
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
assert player._source_package("TiviMate Pro") == TIVIMATE
@@ -386,12 +387,11 @@ class TestCaseInsensitiveNames:
# one row, keeping the first spelling the user typed
assert player.source_list == ["Dup"]
# ...and the Browse tree still holds both apps, each under its own id
root = await player.async_browse_media()
assert [(c.media_content_id, c.title) for c in root.children] == [
("com.a", "Dup"),
("com.b", "dup"),
]
# ...and the directory still holds both apps, each under its own id, so
# the shadowed one stays reachable by its package id (t61: that is what
# the removed browse tree used to publish)
assert player._app_directory == {"com.a": "Dup", "com.b": "dup"}
assert player._source_package("com.b") == "com.b"
async def test_the_offered_row_launches_the_app_it_shows(self, modules):
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})