Files
ha-nvidia-shield/tests/test_connection.py
T
James Mackay 7b32de789a t66: app names on a device that exposes none
The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.

  * the probe greps ON THE DEVICE for the three shapes Android prints
    (`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
    name, `null` and a resource id are not. The dump is never truncated.
  * after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
    is taken at its word: the sweep stops spending a shell per package
    (APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
    its retry — t57 UNIT 0 survives, driven by the app on screen).
  * ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
    by the media player, the Current App sensor, the discovery table, the source
    list and the apps editor: user map > device label > curated map > package id
    (the id shown ONCE, `name` empty when nothing names the app).
  * the curated table covers the user's 21 apps (community-maintained, not
    device truth) and the Configure row says when the device named nothing.
  * the real device shape is a test fixture (trimmed, and marked as such in the
    file: the raw capture is not in the repo) pinning that no label is
    extracted, that the probe count is bounded, and that `Netflix` surfaces.

Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.

No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
2026-10-12 10:54:38 +01:00

870 lines
32 KiB
Python

"""Unit tests for ShieldConnectionManager + StateStore (Phase 1).
Driven with a fake poller — no real ADB, no Home Assistant. Covers the
connect/reconnect backoff ladder, state expiration, listener notification,
command delegation, and full task lifecycle.
"""
import asyncio
import time
import types
import pytest
from ha_adb_common.media_session import MediaSessionInfo
from nsh import connection
from nsh.connection import (
FIELD_AWAKE,
FIELD_SESSIONS,
ShieldConnectionManager,
StateStore,
)
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER, APP_LABEL_GREP_SHAPES
def test_connection_module_depends_on_no_homeassistant():
"""The manager module must stay importable without HA installed."""
with open(connection.__file__, encoding="utf-8") as fh:
source = fh.read()
assert "homeassistant" not in source
class FakePoller:
"""AdbMediaSessionPoller shape — canned answers, no socket."""
def __init__(self):
self.connected = False
self.connect_failures = 0
self.disconnect_calls = 0
self.awake = None
self.sessions: list[MediaSessionInfo] = []
self.apps: list[dict] = []
self.audio_dump = ""
self.foreground: str | None = None
self.foreground_calls = 0
self.shell_calls: list[str] = []
self.pm_dumps: dict[str, str] = {}
self.head_lines_used: int | None = "unset"
async def connect(self) -> bool:
if self.connect_failures > 0:
self.connect_failures -= 1
self.connected = False
return False
self.connected = True
return True
async def disconnect(self) -> None:
self.disconnect_calls += 1
self.connected = False
async def health_check(self) -> bool:
return self.connected
async def is_awake(self) -> bool | None:
return self.awake if self.connected else None
async def poll_media_sessions(
self, foreground_package=None, head_lines: int | None = 200
) -> MediaSessionInfo | None:
self.head_lines_used = head_lines
return self.sessions[0] if self.sessions else None
@property
def all_sessions(self) -> list[MediaSessionInfo]:
return self.sessions
@property
def last_dump(self) -> str | None:
return self.audio_dump
async def poll_running_apps(self) -> list[dict]:
return list(self.apps)
async def poll_foreground(self) -> str | None:
self.foreground_calls += 1
return self.foreground
async def shell(self, cmd: str) -> str | None:
self.shell_calls.append(cmd)
import shlex
for package, dump in self.pm_dumps.items():
if package in cmd or shlex.quote(package) in cmd:
return dump
return None
async def send_media_command(self, command: str) -> bool:
return self.connected
async def bring_to_foreground(self, package: str) -> bool:
return self.connected
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
class TestStateStore:
def test_set_get_roundtrip(self):
store = StateStore(ttl=60, clock=time.monotonic)
store.set("awake", True)
assert store.get("awake") is True
def test_expired_field_returns_default(self):
"""B5: a field older than the TTL reads as expired, not last-known."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
store.set(FIELD_AWAKE, True)
store.set(FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)])
assert store.get(FIELD_AWAKE) is True
now[0] += 60.1
assert store.get(FIELD_AWAKE) is None
assert store.get(FIELD_SESSIONS, []) == []
def test_missing_field_returns_default(self):
store = StateStore(ttl=60, clock=time.monotonic)
assert store.get(FIELD_AWAKE) is None
assert store.get(FIELD_SESSIONS, []) == []
def test_clear_immediately_expires(self):
store = StateStore(ttl=60, clock=time.monotonic)
store.set(FIELD_AWAKE, False)
store.clear(FIELD_AWAKE)
assert store.get(FIELD_AWAKE) is None
class TestAppLabelCache:
"""P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused."""
def _manager_with_dump(self, clock=None):
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.pm_dumps["ar.tvplayer.tv"] = (
"application-label: 'TiviMate'\n"
"application-label-de: 'TiviMate DE'"
)
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
)
return manager, now
async def test_label_resolves_via_cycle_and_is_cached(self):
manager, _t = self._manager_with_dump()
assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues
await manager._watchdog_cycle(5.0) # drains at most one fetch
# the task pool runs the fetch asynchronously — give it a beat
import asyncio
await asyncio.sleep(0)
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
# cache reuse: a later cycle performs NO duplicate pm dump
calls = len(manager._poller.shell_calls)
manager.app_label("ar.tvplayer.tv") # cached now — no enqueue
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert len(manager._poller.shell_calls) == calls
async def test_resolution_failure_falls_through(self):
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
assert manager.app_label("com.unknown.app") is None
await manager._watchdog_cycle(5.0)
# the dump RAN and carried no label: definitive negative, and the
# caller's next tier (the raw package) takes over — still None here,
# never a crash or a fabricated label
assert manager.app_label("com.unknown.app") is None
async def test_label_less_package_is_a_definitive_negative(self):
"""t24 F1: a label-less package's ANSWER costs one pm dump, not one/cycle.
The old code additionally stamped this answer on FAILURE paths; t55
separates the two (see the retry tests below), but the negative cache
for a dump that really ran stays — the round-trip tax must not return.
"""
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
assert manager.app_label("com.unknown.app") is None # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0) # drain the fetch task
first_dump_count = len(manager._poller.shell_calls)
# later cycles: ZERO additional pm dumps for the unresolvable pkg
manager.app_label("com.unknown.app") # negative cache hit — not enqueued
manager.app_label("com.unknown.app")
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
assert len(manager._poller.shell_calls) == first_dump_count
assert manager.app_label("com.unknown.app") is None # falls through
async def test_failed_fetch_is_not_cached_and_retries(self):
"""t55 root cause: a fetch that could NOT run must not become a miss.
The live defect was a metadata fetch failing and the failure being
written as ("", stamp) — an eternal "no label" for the whole 24 h TTL,
which left app_name showing the raw package id with no way back. Here
the failed fetch stamps nothing, the next render re-enqueues, and the
retry resolves the real label.
"""
manager, _t = self._manager_with_dump()
real_shell = manager._poller.shell
calls: list[str] = []
async def _flaky(cmd):
calls.append(cmd)
if "pm dump" in cmd:
raise RuntimeError("adb shell blew up")
return await real_shell(cmd)
manager._poller.shell = _flaky
assert manager.app_label("ar.tvplayer.tv") is None # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0)
# the fetch ran and failed → NOTHING is stamped as an answer
assert manager._app_labels.get("ar.tvplayer.tv") is None
assert [c for c in calls if "pm dump" in c]
# the next render enqueues again (the miss is retried, not silenced)
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"}
# the retry reaches the device and resolves the label
manager._poller.shell = real_shell
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
async def test_shell_failure_is_not_cached_and_retries(self):
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["ar.tvplayer.tv"] = "application-label: 'TiviMate'"
async def _boom(_cmd):
raise RuntimeError("adb shell blew up")
manager._poller.shell = _boom
manager.app_label("ar.tvplayer.tv")
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0)
assert manager._app_labels.get("ar.tvplayer.tv") is None
assert manager.app_label_requests == set()
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"} # re-enqueued
async def test_in_flight_fetch_is_not_enqueued_twice(self):
manager, _t = self._manager_with_dump()
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label("ar.tvplayer.tv") is None # same render loop
assert manager.app_label_requests == {"ar.tvplayer.tv"}
assert manager.app_label_inflight == set()
async def test_one_fetch_per_cycle_even_under_a_burst(self):
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.a"] = "application-label: 'A'"
manager._poller.pm_dumps["com.b"] = "application-label: 'B'"
manager.app_label("com.a")
manager.app_label("com.b")
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0)
dumps = [c for c in manager._poller.shell_calls if "pm dump" in c]
assert len(dumps) == 2 # one per cycle, both eventually resolved
assert sorted([manager.app_label("com.a"), manager.app_label("com.b")]) == [
"A",
"B",
]
async def test_pm_dump_command_is_shell_quoted(self):
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
import shlex
wicked = "com.evil;name"
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps[wicked] = "application-label: 'E'"
manager.app_label(wicked) # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0) # drain the fetch task
# t57: the probe carries the exit marker too, so a blank answer can be told
# from a FAILED fetch; the package is still quoted. t66: the label candidates
# are filtered ON THE DEVICE, and the 2241-line dump is never truncated —
# `head -300` cut it before the package section on the user's Shield.
assert manager._poller.shell_calls == [
f"pm dump {shlex.quote(wicked)} 2>/dev/null | "
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
]
assert "head -300" not in manager._poller.shell_calls[0]
class TestLogcatStream:
"""P5-3: the dedicated logcat fast-path behind CONF_LOGCAT_EVENTS."""
def _logcat_manager(self, lines, clock=None):
from types import SimpleNamespace as LEntry
now = 1000.0
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
)
manager.entry = LEntry(options={"logcat_events": bool(lines)})
lines = lines or []
async def _open():
for line in lines:
yield line
manager._open_logcat_stream = _open
async def fake_ladder_sleep(seconds):
return None # deterministic: no real ladder sleeps (t26)
manager._logcat_ladder_sleep = fake_ladder_sleep
return manager, now
async def test_start_stamps_foreground_instantly(self):
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
" START u0 {cmp=com.netflix.ninja/x}",
]
)
notified = []
manager.register_listener(lambda: notified.append(1))
await manager._watchdog_cycle(5.0) # starts the consumer
import asyncio
await asyncio.sleep(0) # drain the task
assert manager.store.get("foreground") == "com.netflix.ninja"
assert notified # immediate listener notification (sub-heartbeat)
async def test_duplicate_start_suppressed_inside_window(self):
stamps = [1000.0, 1005.0] # 5 s apart — inside the 15 s window
tick = {"i": 0}
def advancing():
tick["i"] = min(tick["i"] + 1, len(stamps)) - 1
return stamps[tick["i"]]
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
"03-21 10:19:43.000 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
],
clock=advancing,
)
store_sets = {"n": 0}
original_set = manager.store.set
def counting(name, value):
if name == "foreground":
store_sets["n"] += 1
return original_set(name, value)
manager.store.set = counting
await manager._watchdog_cycle(5.0)
await manager._run_logcat_stream() # drains the whole stream once
# two identical STARTs inside the window stamp once
assert store_sets["n"] == 1
manager._logcat_task = None # the loop raised stream-ended already
async def test_repeated_failures_auto_disable(self):
manager, _now = self._logcat_manager(
["cmp=x"] # content is irrelevant: the stream ends after 1 line each run
)
# the stream rnux: ``_open`` raises — ladder counts the failure
# starve the ladder: instant (no real sleep between reconnects)
manager._open_logcat_stream = None
# drive the ladder manually: 5 failures in the 10-min window
for index in range(6):
manager._note_logcat_failure(1000.0 + index)
assert manager._logcat_disabled is True # auto-disable at 5
assert manager._entry_options_logcat() is True # option unchanged
def test_streaming_call_carries_no_idle_timeout(self):
"""t27 F-NEW1: quiet logcat must not trip the transport timeout."""
import inspect
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
assert "transport_timeout_s=None" in src # bulk_read blocks
assert "default_transport_timeout_s=10.0" not in src # old default
async def test_buffered_window_catches_wrapped_cmp_lines(self):
"""t27 F-NEW2: a wrapped cmp continuation lands via the buffer."""
from types import SimpleNamespace as LEntry
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
)
manager.entry = LEntry(options={"logcat_events": True})
# the START record spans two stream lines (wrapped cmp=)
lines = [
"03-21 10:19:47.000 3104 3456 I"
" ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv",
" /.ui.MainActivity flg=0x1}",
]
async def _open():
for line in lines:
yield line
manager._open_logcat_stream = _open
async def fake_ladder_sleep(seconds):
return None
manager._logcat_ladder_sleep = fake_ladder_sleep
raised = None
try:
await manager._logcat_pass()
except Exception as err: # the pass ends with stream-ended
raised = err
assert raised is not None # stream-ended raises by contract
assert manager.store.get("foreground") == "ar.tvplayer.tv"
def test_public_config_accessor_is_the_source(self):
"""t27 F-NEW3: the opener consumes AdbClientBase.config publicly."""
import inspect
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
assert "poller.config" in src # public accessor
assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins
async def test_option_off_means_no_stream_task(self):
manager, _now = self._logcat_manager(None) # logcat_events: falsy
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
assert manager._logcat_task is None # zero behavior change
async def test_stream_never_touches_the_poller_socket(self):
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
" START u0 {cmp=com.netflix.ninja/x}",
]
)
await manager._watchdog_cycle(5.0)
assert manager._poller.shell_calls == [] # dedicated connection, not the poller
class TestForegroundFallback:
async def test_stamps_field_foreground_when_protocol_silent(self):
poller = FakePoller()
poller.awake = True
poller.foreground = "ar.tvplayer.tv" # TiviMate, no session (R2)
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 1
assert manager.store.get("foreground") == "ar.tvplayer.tv"
# store priority: protocol push wins over the ADB parse
assert manager.foreground_app == "ar.tvplayer.tv"
async def test_protocol_push_overrides_adb_foreground(self):
poller = FakePoller()
poller.awake = True
poller.foreground = "ar.tvplayer.tv"
manager = _manager(poller)
manager.store.set("current_app", "com.netflix.ninja") # live push
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 0 # skipped — push is fresh
assert manager.foreground_app == "com.netflix.ninja"
async def test_no_foreground_query_when_asleep(self):
poller = FakePoller()
poller.awake = False
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 0
assert manager.store.get("foreground") is None
class TestAudioPlaybackFilter:
async def test_system_media_packages_filtered(self):
poller = FakePoller()
poller.awake = True
poller.audio_dump = (
" Audio playback (lastly played comes first)\n"
" uid=1 packages=com.google.android.apps.mediashell\n"
" uid=2 packages=ar.tvplayer.tv\n"
" uid=3 packages=com.android.systemui\n"
)
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert manager.audio_playback == ["ar.tvplayer.tv"]
class TestLastKnownStateCache:
async def test_serves_last_known_when_no_session(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
# later: TiviMate stops publishing; foreground has no session
poller.sessions = []
await manager._watchdog_cycle(5.0)
served = manager.best_session("ar.tvplayer.tv")
assert served is not None
assert served.playing is True # the last known state
info, stamp = manager.last_known_state("ar.tvplayer.tv")
assert info is served
assert stamp is not None
async def test_expiry_never_resurrects_playing(self):
"""P3-2d expiry contract, deterministic via the injected clock."""
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller, clock=lambda: now)
await manager._watchdog_cycle(5.0)
assert manager.last_known_session("ar.tvplayer.tv") is not None
# TTL passes with no further caching — 'playing' must NOT come back
now += manager.store.ttl + 0.05
assert manager.last_known_session("ar.tvplayer.tv") is None
assert manager.last_known_state("ar.tvplayer.tv") == (None, None)
assert manager.best_session("ar.tvplayer.tv") is None
async def test_state_inside_ttl_is_served(self):
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller, clock=lambda: now)
await manager._watchdog_cycle(5.0)
now += manager.store.ttl - 1.0 # inside the TTL
info, stamp = manager.last_known_state("ar.tvplayer.tv")
assert info is not None and info.playing is True
assert stamp == 1000.0 # the cycle's cache stamp
async def test_live_session_beats_last_known(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="app.old", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
# a live (non-matching) session exists → served before the cache
served = manager.best_session("ar.tvplayer.tv")
assert served is not None and served.package == "app.old"
async def test_cache_cleared_on_stop(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
await manager.async_stop()
assert manager._last_known == {}
class TestConnectAndRefresh:
async def test_cycle_connects_and_populates_store(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="app.a", playing=True)]
poller.apps = [{"package": "app.a"}]
poller.audio_dump = " Audio playback (lastly played comes first)\n uid=1 packages=app.a\n"
manager = _manager(poller)
notified = []
unsub = manager.register_listener(lambda: notified.append(True))
delay = await manager._watchdog_cycle(5.0)
assert delay == ADB_POLL_INTERVAL
assert manager.connected is True
assert manager.awake is True
assert manager.best_session("app.a").package == "app.a"
assert manager.running_apps == [{"package": "app.a"}]
assert manager.audio_playback == ["app.a"]
assert notified, "listener must be notified after a refresh"
unsub()
assert manager._listeners == []
async def test_query_is_untruncated(self):
"""The manager always asks for the full dump (plan blocker B4)."""
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.head_lines_used is None
async def test_standby_expires_playback_fields(self):
"""Asleep → sessions/audio/apps are expired, awake stays False."""
poller = FakePoller()
poller.awake = False
manager = _manager(poller)
manager.store.set(FIELD_SESSIONS, [MediaSessionInfo(package="old.app")])
manager.store.set(FIELD_AWAKE, True) # stale truth, about to flip
await manager._watchdog_cycle(5.0)
assert manager.awake is False
assert manager.active_sessions == []
assert manager.audio_playback == []
assert manager.running_apps == []
async def test_awake_none_is_a_cycle_failure(self):
"""Wake check returning None must back off instead of storing unknown."""
poller = FakePoller()
poller.connected = True
manager = _manager(poller)
manager.store.set(FIELD_AWAKE, True)
delay = await manager._watchdog_cycle(5.0)
assert delay is not None
assert delay < 10.0 # backoff-scaled, not the heartbeat interval
assert manager.awake is None # stale True dropped
async def test_foreground_preference_and_playing_order(self):
poller = FakePoller()
poller.awake = True
# parse order convention: playing first
poller.sessions = [
MediaSessionInfo(package="app.playing", playing=True),
MediaSessionInfo(package="app.paused", paused=True),
]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert manager.best_session("app.paused").package == "app.paused"
assert manager.best_session().package == "app.playing"
assert manager.best_session("app.unknown").package == "app.playing"
class TestReconnectBackoff:
async def test_failed_connect_returns_backoff_scaled_delay(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
delay = await manager._watchdog_cycle(5.0)
assert not poller.connected
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # ±jitter around the backoff
assert manager._failures == 1
async def test_backoff_doubles_up_to_the_cap(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
delays = []
backoffs = []
backoff = 5.0
for _ in range(7):
backoffs.append(backoff)
delays.append(await manager._watchdog_cycle(backoff))
backoff = min(backoff * 2, 300.0)
# F5 soundness: the previous ±25 % jitter could flake the claim
# delays[1] > delays[0] * 1.5: BACKOFF=10 jittered to 9.17 vs
# BACKOFF=5 jittered to 6.25 → 6.25*1.5 = 9.375). Assert only what
# jitter guarantees: each delay sits inside ITS OWN backoff's band
# (non-overlapping consecutive bands ⇒ delays[1] >= delays[0],
# since min next = 7.5 >= max first = 6.25), and never above the cap.
for backoff, delay in zip(backoffs, delays, strict=True):
assert 0.75 * backoff <= delay <= 1.25 * backoff
assert delays[1] >= delays[0]
assert delays[-1] <= 300.0 * 1.25
async def test_repeated_failures_do_not_spam_listeners(self):
poller = FakePoller()
poller.connect_failures = 99
manager = _manager(poller)
notified = []
manager.register_listener(lambda: notified.append(True))
for _ in range(3):
await manager._watchdog_cycle(5.0)
assert notified.count(None) == 0
assert len(notified) == 1, "down-notified exactly once until recovery"
async def test_recovery_resets_failures_and_backoff(self):
poller = FakePoller()
poller.connect_failures = 0
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0) # fresh connect → ok
assert manager._failures == 0
assert await manager._watchdog_cycle(5.0) == ADB_POLL_INTERVAL
async def test_failed_wake_poll_forces_reconnect(self):
"""health_check drives the reconnect decision: a cycle where is_awake
fails closes the socket so the next cycle reconnects from scratch."""
poller = FakePoller()
poller.connected = True
poller.awake = None # wake poll fails while the flag says "connected"
manager = _manager(poller)
delay = await manager._watchdog_cycle(5.0)
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # F5-style symmetric bound
assert poller.disconnect_calls == 1
assert poller.connected is False
assert manager._failures == 1
async def test_connect_failure_clears_stale_store_state(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
manager.store.set(FIELD_AWAKE, True)
sessions = [MediaSessionInfo(package="app.a", playing=True)]
manager.store.set(FIELD_SESSIONS, sessions)
await manager._watchdog_cycle(5.0)
assert manager.awake is None
assert manager.active_sessions == []
class TestCommandDelegation:
async def test_send_media_command_requires_poller(self):
manager = _manager(poller=None)
assert manager.available is False
assert await manager.send_media_command("play") is False
async def test_send_media_command_delegates(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert await manager.send_media_command("play") is True
async def test_bring_to_foreground(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert await manager.bring_to_foreground("app.a") is True
class TestLifecycle:
async def test_passive_manager_exits_immediately(self):
"""No factory → no watchdog loop; entry stays functional without ADB."""
manager = ShieldConnectionManager(hass=None, entry=types.SimpleNamespace())
await manager.async_start()
await asyncio.sleep(0.01)
assert manager.available is False
assert manager.connected is False
assert await manager.send_media_command("play") is False
await manager.async_stop()
async def test_async_stop_disconnects_and_cancels_tasks(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager.async_start()
await asyncio.sleep(0) # let the watchdog task start
assert manager.available
await manager.async_stop()
assert poller.disconnect_calls == 1
assert manager._poller is None
assert manager._tasks == []
assert manager._stopping is True
async def test_stop_is_idempotent(self):
manager = _manager(FakePoller())
await manager.async_stop()
await manager.async_stop()
async def test_factory_used_when_no_poller_given(self):
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller_factory=lambda: poller
)
await manager.async_start()
await asyncio.sleep(0)
await manager.async_stop()
assert poller.disconnect_calls == 1
class TestStoreTtlInManager:
async def test_expired_session_reads_as_none(self):
"""Full manager view: TTL expiry means 'state unavailable', not
'last known playing'."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
manager = _manager(store=store)
manager.store.set(
FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)]
)
now[0] += 60.1
assert manager.best_session() is None
@pytest.mark.parametrize("owned", [True, False])
async def test_watchdog_cycle_never_raises(owned):
"""A poller can throw anything — the cycle converts to a backoff delay."""
poller = FakePoller()
class ExplodingPoller(FakePoller):
async def is_awake(self):
raise RuntimeError("boom")
manager = _manager(ExplodingPoller() if owned else poller)
delay = await manager._watchdog_cycle(5.0)
assert delay > 0