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
@@ -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