fix(media-player): ON means no power or playback fact reported (t58 UNIT 0)
The t59 F2 ruling, implemented. The ladder said ON was for "no source reported
anything at all" but tested for something looser — no transport data — so a
source that ANSWERED "not playing" still fell through to ON. A source that
answers is not a source that is silent, and `on` is the "we cannot know" state.
The ladder now tracks an explicit `reported` flag, set by every source that gave
us a power or playback fact, and the last tier is `IDLE if reported else ON`.
Each case the ruling names now lands on IDLE:
* a configured Jellyfin entity in ANY state — idle, off, standby, unavailable,
or even absent from the state machine. It reports "not playing" without an
early return, so a silent Jellyfin still cannot mask a live media session
below it (both directions pinned);
* a live media session that carries no playback flag — the app publishes a
session but is not playing, paused or buffering;
* a bare foreground app: either transport's answer counts (the protocol's
current_app push or the ADB mResumedActivity parse);
* a configured power entity, whatever it currently says;
* protocol is_on / ADB awake, plugged or not.
`on` survives only for an entry where none of those exists. The t55 rules are
untouched: an off power entity still outranks a playing session, and no source
may mask playing/paused/buffering.
The module docstring's tier 6, the `state` docstring and the README's ladder
(moving "no data" to the precise wording) all say the same thing now, and the
README's one-line state table was aligned with it.
RED PROOF (the new tests against the parent tree): five of the reporting cases
read `on` where they must read `idle` — `assert 'on' == 'idle'` — plus the
"ON is unreachable with any source configured" shape test.
SIMULATED vs EXECUTED: no real Shield was available, so these are the documented
source shapes, not captures; the state=6 → buffering mapping stays marked
inferred. No browser exists either, so the README pins are textual and the
production behaviour is exercised against real HA's state machine in
tests/test_ha_api_drift.py.
This commit is contained in:
1 parent
ff8dd1c454
commit
ef42682250
4 files changed
+228
-30
No files matched your search
@@ -535,11 +535,14 @@ def _state_player(
|
||||
protocol_on=None,
|
||||
power_entity_state=None,
|
||||
jellyfin_state=None,
|
||||
jellyfin_configured=False,
|
||||
):
|
||||
"""A player whose power/media sources are exactly what the test sets.
|
||||
|
||||
``None`` means "this source has not reported" — the distinction the t55
|
||||
ladder turns on, so every test spells out which sources spoke.
|
||||
``jellyfin_configured`` covers the entity being configured while ``_read``
|
||||
still answers None (the entity is gone).
|
||||
"""
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
@@ -556,7 +559,7 @@ def _state_player(
|
||||
entry.options = dict(entry.options or {})
|
||||
if power_entity_state is not None:
|
||||
entry.options["power_entity"] = "switch.shield_plug"
|
||||
if jellyfin_state is not None:
|
||||
if jellyfin_state is not None or jellyfin_configured:
|
||||
entry.options["jellyfin_entity"] = "media_player.jellyfin"
|
||||
|
||||
def _read(entity_id):
|
||||
@@ -1978,3 +1981,129 @@ class TestAvailabilityLadder:
|
||||
assert transport["entity_available"] is True
|
||||
assert transport["adb"] == {"configured": True, "connected": False, "awake": None}
|
||||
assert transport["protocol"]["active"] is True
|
||||
|
||||
|
||||
class TestReportingFactsT58:
|
||||
"""t58 UNIT 0 — the t59 F2 ruling: ON = "no power or playback fact reported".
|
||||
|
||||
The live report was "On overrides my playing states"; t55 fixed the loud half
|
||||
(a powered-on Shield with an app open). This is the quiet half: a source that
|
||||
ANSWERS "not playing" was still falling through to ON. Every case below is a
|
||||
source that answered, so the ladder must stop at IDLE.
|
||||
"""
|
||||
|
||||
# ── Jellyfin, in every state it can report ──
|
||||
|
||||
async def test_jellyfin_idle_is_idle_not_on(self, modules):
|
||||
player, _m = _state_player(modules, jellyfin_state="idle")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_jellyfin_off_is_idle_not_on(self, modules):
|
||||
"""`off` is a REPORT about the session, not the Shield's own power:
|
||||
only the configured power entity and the protocol push can say OFF."""
|
||||
player, _m = _state_player(modules, jellyfin_state="off")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_jellyfin_standby_is_idle_not_on(self, modules):
|
||||
player, _m = _state_player(modules, jellyfin_state="standby")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_jellyfin_unavailable_is_idle_not_on(self, modules):
|
||||
"""The one the ruling names explicitly: unavailable still answered."""
|
||||
player, _m = _state_player(modules, jellyfin_state="unavailable")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_a_configured_but_missing_jellyfin_entity_is_idle(self, modules):
|
||||
"""Configured IS the report: we are watching a source that exists.
|
||||
|
||||
`_read` returns None here (the entity is not in the state machine), and
|
||||
the old ladder fell straight through to ON — for an entry whose owner
|
||||
had explicitly configured a media source.
|
||||
"""
|
||||
player, _m = _state_player(modules, jellyfin_configured=True)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
# ── a live session with no playback flag, and a bare foreground app ──
|
||||
|
||||
async def test_a_session_with_no_playback_flag_is_idle_not_on(self, modules):
|
||||
"""The app publishes a session; that it is NOT playing is the report."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules, sessions=[MediaSessionInfo(package="com.netflix.ninja")]
|
||||
)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_a_bare_foreground_app_is_idle_not_on(self, modules):
|
||||
"""The protocol's current-app push answered — nothing else did."""
|
||||
player, _m = _state_player(modules, foreground="ar.tvplayer.tv")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
# ── the negative control: ON is still reachable, just narrowly ──
|
||||
|
||||
async def test_on_survives_when_truly_nothing_reported(self, modules):
|
||||
player, _m = _state_player(modules)
|
||||
|
||||
assert player.state.value == "on"
|
||||
|
||||
async def test_on_is_unreachable_with_any_source_configured(self, modules):
|
||||
"""The rule as a shape: each single source takes ON off the table."""
|
||||
for kwargs in (
|
||||
{"jellyfin_state": "idle"},
|
||||
{"jellyfin_state": "off"},
|
||||
{"jellyfin_state": "standby"},
|
||||
{"jellyfin_state": "unavailable"},
|
||||
{"jellyfin_configured": True},
|
||||
{"foreground": "ar.tvplayer.tv"},
|
||||
{"protocol_on": True},
|
||||
{"awake": True},
|
||||
{"power_entity_state": "on"},
|
||||
):
|
||||
player, _m = _state_player(modules, **kwargs)
|
||||
assert player.state.value != "on", kwargs
|
||||
|
||||
# ── a silent source must not MASK a live one (the ladder keeps going) ──
|
||||
|
||||
async def test_idle_jellyfin_does_not_mask_a_playing_session(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
jellyfin_state="idle",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
|
||||
assert player.state.value == "playing"
|
||||
|
||||
async def test_unavailable_jellyfin_does_not_mask_a_paused_session(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
jellyfin_state="unavailable",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
|
||||
assert player.state.value == "paused"
|
||||
|
||||
async def test_an_off_power_entity_still_outranks_a_playing_session(self, modules):
|
||||
"""The t55 rule is untouched by the F2 correction."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
power_entity_state="off",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
)
|
||||
|
||||
assert player.state.value == "off"
|
||||
|
||||
@@ -61,3 +61,12 @@ def test_the_replacement_guide_retires_the_old_stack(readme_text):
|
||||
assert needle in readme_text, needle
|
||||
# and it says the UI is the primary path
|
||||
assert "primary path" in readme_text
|
||||
|
||||
|
||||
def test_the_readme_states_the_on_rule_precisely(readme_text):
|
||||
"""t58 UNIT 0: "no data" was too loose; the README must not still say it."""
|
||||
assert "no source reported a power or playback fact" in readme_text
|
||||
# the four reporting-but-not-playing shapes, named
|
||||
for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"):
|
||||
assert shape in readme_text, shape
|
||||
assert "no source reported anything at all" not in readme_text
|
||||
Reference in new issue
Block a user