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:
1 parent
c884c7e1ab
commit
431b7cd8f4
8 files changed
+448
-133
No files matched your search
+40
-1
@@ -398,6 +398,10 @@ def install() -> None:
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
ha_const.STATE_PLAYING = "playing"
|
||||
ha_const.STATE_PAUSED = "paused"
|
||||
# t61: a receiver switch is read and switched by the media player, so the
|
||||
# two switch states have to exist with their real values.
|
||||
ha_const.STATE_ON = "on"
|
||||
ha_const.STATE_OFF = "off"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player",
|
||||
SENSOR="sensor",
|
||||
@@ -609,8 +613,43 @@ def load_entity_modules() -> dict:
|
||||
}
|
||||
|
||||
|
||||
class FakeState:
|
||||
"""The fields entity code actually reads off an HA ``State`` (t61)."""
|
||||
|
||||
def __init__(self, entity_id, state, attributes=None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = dict(attributes or {})
|
||||
|
||||
|
||||
class FakeStates:
|
||||
"""A dict-backed stand-in for HA's StateMachine (t61).
|
||||
|
||||
Only the read shape entities use: ``get(entity_id)`` returns the state
|
||||
object, or None for an entity that does not exist — exactly what the real
|
||||
machine returns. ``set`` is a test's way of giving an entity a state.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._states: dict = {}
|
||||
|
||||
def set(self, entity_id, state, attributes=None):
|
||||
self._states[entity_id] = FakeState(entity_id, state, attributes)
|
||||
|
||||
def get(self, entity_id):
|
||||
return self._states.get(entity_id)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later."""
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later.
|
||||
|
||||
``states`` is the real HomeAssistant shape (every hass has one), so entity
|
||||
code that reads a linked entity works unmodified; it is empty until a test
|
||||
sets something.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.states = FakeStates()
|
||||
|
||||
def async_create_task(self, coro):
|
||||
return asyncio.create_task(coro)
|
||||
|
||||
@@ -118,7 +118,11 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||
player._entity_tasks = set()
|
||||
player._channel_task = None
|
||||
player.async_write_ha_state = lambda: None
|
||||
player._read = lambda eid: None
|
||||
# t61: FakeHass carries the real `states` shape, so the entity's own `_read`
|
||||
# is the one under test — an entity no test set still reads as None.
|
||||
# t61: `__init__` gives the entity an (empty) power-sensor map; the harness
|
||||
# mirrors it so the attribute payload can be read here too.
|
||||
player._power_sensors = {}
|
||||
|
||||
# t41: nothing may leave this integration, so an HA service call is now the
|
||||
# failure signal rather than a supported tier. `served` stays empty unless a
|
||||
@@ -1455,9 +1459,10 @@ class TestFaithfulHaStubs:
|
||||
|
||||
|
||||
class TestBrowseMediaDirectory:
|
||||
"""P4-2c: the app directory root and its launch path."""
|
||||
"""t61: the HA media browser publishes NO media — apps are sources."""
|
||||
|
||||
async def test_root_lists_the_options_map(self, modules):
|
||||
async def test_the_browser_is_an_empty_root(self, modules):
|
||||
"""The user's report: apps must not appear in the media browser."""
|
||||
player, _s, _i = make_player(modules)
|
||||
player._entry.options["app_name_map"] = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
@@ -1466,53 +1471,56 @@ class TestBrowseMediaDirectory:
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert root.title == "Apps"
|
||||
assert root.can_play is False and root.can_expand is True
|
||||
ids = [(c.media_content_id, c.title) for c in root.children]
|
||||
assert ids == [
|
||||
("ar.tvplayer.tv", "TiviMate"),
|
||||
("com.netflix.ninja", "Netflix"),
|
||||
]
|
||||
assert all(c.media_content_type == "app" for c in root.children)
|
||||
# t37 D1: a standard media_player.BrowseMedia tree — explicit
|
||||
# media_content_id/-type at every level, the directory naming the class
|
||||
# of its children, and NO media_source `domain` (that parameter belongs
|
||||
# to media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids).
|
||||
assert root.children == [] # not one app row
|
||||
assert root.can_play is False
|
||||
assert root.can_expand is False # there is nothing to open
|
||||
assert root.title == "mdarcy" # the device, not a second app list
|
||||
# t37 D1: still a standard media_player.BrowseMedia root — explicit
|
||||
# class/type, NO media_source `domain` (that parameter belongs to
|
||||
# media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids and is not exportable from media_player at all).
|
||||
assert root.media_class == "directory"
|
||||
assert root.media_content_id == "apps"
|
||||
assert root.media_content_type == "apps"
|
||||
assert root.children_media_class == "app"
|
||||
assert root.media_content_id is None # no node to select
|
||||
assert root.children_media_class is None
|
||||
assert not hasattr(root, "domain")
|
||||
assert [c.media_class for c in root.children] == ["app", "app"]
|
||||
# ...and it serialises into the payload the frontend consumes
|
||||
payload = root.as_dict()
|
||||
assert payload["media_content_id"] == "apps"
|
||||
assert payload["children_media_class"] == "app"
|
||||
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
|
||||
assert payload["children"][0]["media_content_type"] == "app"
|
||||
assert payload["children"][0]["can_play"] is True
|
||||
assert payload["children"] == []
|
||||
assert payload["can_expand"] is False
|
||||
|
||||
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||
player, _s, _i = make_player(modules) # no apps map configured
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
ids = [c.media_content_id for c in root.children]
|
||||
assert "ar.tvplayer.tv" in ids # TiviMate
|
||||
assert "com.github.damontecres.wholphin" in ids # Wholphin
|
||||
assert "com.netflix.ninja" in ids # Netflix
|
||||
assert "com.google.android.youtube.tv" in ids # YouTube
|
||||
|
||||
async def test_selection_launches_via_play_media_app(self, modules):
|
||||
async def test_apps_stay_reachable_through_the_sources(self, modules):
|
||||
"""What the browser no longer offers, source_list/select_source still do."""
|
||||
player, _s, _i = make_player(modules)
|
||||
protocol = player._manager._protocol
|
||||
player._entry.options["app_name_map"] = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
}
|
||||
|
||||
root = await player.async_browse_media()
|
||||
selected = root.children[0]
|
||||
|
||||
await player.async_play_media("app", selected.media_content_id)
|
||||
assert root.children == []
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
|
||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
||||
protocol = player._manager._protocol
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert protocol.launch_calls == ["com.netflix.ninja"] # still launchable
|
||||
|
||||
async def test_any_rebrowse_id_stays_a_valid_empty_root(self, modules):
|
||||
"""A frontend that re-browses an id cannot make this crash or lie."""
|
||||
player, _s, _i = make_player(modules)
|
||||
|
||||
for content_type, content_id in (
|
||||
(None, None),
|
||||
("apps", "apps"),
|
||||
("app", "ar.tvplayer.tv"),
|
||||
("channel", "12"),
|
||||
):
|
||||
root = await player.async_browse_media(content_type, content_id)
|
||||
|
||||
assert root.children == []
|
||||
assert root.media_content_id is None
|
||||
|
||||
|
||||
class DownAdbPoller(FakeAdbPoller):
|
||||
|
||||
+35
-35
@@ -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"})
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""tests/test_receiver_switch.py — a plain switch as the Receiver (t61).
|
||||
|
||||
The user's request, verbatim: "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". So the playback entry
|
||||
points — ``turn_on``, ``play_media`` and ``select_source`` — ensure a
|
||||
``switch.*`` receiver is ON, and only when it is actually off.
|
||||
|
||||
The four states the contract names are all pinned here:
|
||||
|
||||
* switch off + playback starts → exactly one ``switch.turn_on``;
|
||||
* switch already on → NO call (idempotent, not a toggle);
|
||||
* switch unavailable/unknown/missing/failing → playback continues, nothing raises;
|
||||
* a ``media_player.*`` receiver → never switched; its behaviour is unchanged.
|
||||
|
||||
No real Shield, no real switch and no browser take part: the entity comes from
|
||||
the same stub harness every other entity test uses, the receiver's state is a
|
||||
value in ``FakeStates`` and the service call is recorded.
|
||||
"""
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import CONF_APP_NAME_MAP, CONF_RECEIVER_ENTITY
|
||||
from test_command_routing import make_player
|
||||
|
||||
TIVIMATE = "ar.tvplayer.tv"
|
||||
SOUNDBAR = "switch.soundbar"
|
||||
AVR = "media_player.avr"
|
||||
|
||||
|
||||
@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, *, receiver=SOUNDBAR, state=None, app_map=None):
|
||||
"""A player with a Receiver configured (a switch unless told otherwise)."""
|
||||
player, served, _intents = make_player(modules)
|
||||
player._entry.options[CONF_RECEIVER_ENTITY] = receiver
|
||||
if app_map is not None:
|
||||
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||
if state is not None:
|
||||
player.hass.states.set(receiver, state)
|
||||
return player, served
|
||||
|
||||
|
||||
class TestTheSwitchReceiverIsEnsuredOn:
|
||||
"""Every playback entry point turns an off switch receiver on — once."""
|
||||
|
||||
async def test_play_media_turns_an_off_receiver_on(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
# the ensure is the ONLY service call: no volume, no toggle, and the
|
||||
# app still launches (which is why the volume scripts are unnecessary)
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_turn_on_turns_an_off_receiver_on(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
|
||||
async def test_select_source_turns_an_off_receiver_on_once(self, modules):
|
||||
"""select_source dispatches internally, so the switch is read once."""
|
||||
player, served = _player(
|
||||
modules, state="off", app_map={TIVIMATE: "TiviMate"}
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_an_on_receiver_is_never_called(self, modules):
|
||||
"""Idempotent: every entry point may run, and NOT ONE call goes out."""
|
||||
player, served = _player(modules, state="on")
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == []
|
||||
|
||||
async def test_no_volume_service_is_ever_aimed_at_the_receiver(self, modules):
|
||||
"""The user's point: a switch receiver means no volume scripts."""
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert [(domain, service) for domain, service, _data in served] == [
|
||||
("switch", "turn_on")
|
||||
]
|
||||
|
||||
|
||||
class TestAnUnusableReceiverNeverBreaksPlayback:
|
||||
"""A receiver we cannot read, or that fails, is logged — never raised."""
|
||||
|
||||
async def test_an_unavailable_switch_is_left_alone(self, modules):
|
||||
player, served = _player(modules, state="unavailable")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == [] # never guessed at
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE] # still plays
|
||||
|
||||
async def test_an_unknown_state_is_not_guessed_at(self, modules):
|
||||
player, served = _player(modules, state="unknown")
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == []
|
||||
|
||||
async def test_a_missing_entity_is_left_alone(self, modules):
|
||||
player, served = _player(modules) # the receiver is not in the machine
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_a_failing_service_call_does_not_stop_playback(self, modules):
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
player, _served = _player(modules, state="off")
|
||||
|
||||
class _BrokenServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise HomeAssistantError("the switch entity is gone")
|
||||
|
||||
player.hass.services = _BrokenServices()
|
||||
|
||||
await player.async_play_media("app", TIVIMATE) # must not raise
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
|
||||
class TestAMediaPlayerReceiverIsUntouched:
|
||||
"""An AVR stays an AVR: input selection and enrichment, never a switch call."""
|
||||
|
||||
async def test_a_media_player_receiver_is_never_switched(self, modules):
|
||||
player, served = _player(modules, receiver=AVR, state="off")
|
||||
player.hass.states.set(AVR, "idle")
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_the_receiver_option_still_reaches_the_attributes(self, modules):
|
||||
from nsh.const import ATTR_MEDIA_DAG_RECEIVER_ENTITY
|
||||
|
||||
player, _served = _player(modules, receiver=AVR)
|
||||
|
||||
assert (
|
||||
player.extra_state_attributes[ATTR_MEDIA_DAG_RECEIVER_ENTITY] == AVR
|
||||
)
|
||||
|
||||
|
||||
class TestAnIdleShieldLeavesTheReceiverAlone:
|
||||
"""Switching the receiver on is a PLAYBACK action, not a synchronisation."""
|
||||
|
||||
async def test_reading_state_and_attributes_calls_nothing(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
assert player.state is not None # a state read
|
||||
assert isinstance(player.extra_state_attributes, dict) # what the frontend reads
|
||||
player._on_manager_update() # the connection manager pushed an update
|
||||
|
||||
assert served == []
|
||||
Reference in new issue
Block a user