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.
870 lines
32 KiB
Python
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
|