test(discovery): fixtures for the parsers, the bounded sweep and the surfaces
tests/test_installed_apps.py is new and is where the discovery claims are pinned. NO REAL SHIELD WAS AVAILABLE, so the command output is fixture-based in the documented Android shapes — `pm list packages` lines, the verbose `resolve-activity` header followed by `package/activity`, and `pm dump` carrying `application-label` — including a localized label (the default locale wins), a label-less app (a definitive negative, and still a row) and the launcher (a system app that must be present). Covered: both parsers plus their tolerances (noise, duplicates, blank, a bare `package/activity` line, the settings entry point that is not a launcher), the user+launcher merge and its de-duplication, the icon heuristic and that it is always a string, the sweep's commands, its concurrency bound (peak <= APP_SWEEP_CONCURRENCY and > 1, so it is bounded AND genuinely concurrent), cache reuse with zero shells, the TTL, shared sweeps for concurrent callers, and every degradation path (no poller, disconnected, mute transport — empty list, no loop, no raise). The sensor's count/table/reason and the "a failed refresh is a no-op, not an exception" behaviour are pinned too. The config-flow side is in tests/test_config_flow_ux.py: the table lists every discovered app, a discovered app the map never mentions is appended, the user's name wins for the same package, an `mdi:` value never reaches the icon column, rendering issues no shell, the Discover row runs one forced sweep and returns to the menu, a failed sweep still renders, and the merged table round-trips into the saved options. Also: tests/test_config_flow_real_ha.py learns the new row, the standalone stub accepts `description_placeholders` (which the apps form now passes), and the state=6 wording in media_session.py/media_player.py is the t57 UNIT 0c reword.
This commit is contained in:
1 parent
a56ec38170
commit
5eefd0c6d0
6 files changed
+669
-16
No files matched your search
@@ -20,9 +20,11 @@ class MediaSessionInfo:
|
|||||||
|
|
||||||
``playing``/``paused``/``buffering`` are independent flags mapped from the
|
``playing``/``paused``/``buffering`` are independent flags mapped from the
|
||||||
Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering
|
Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering
|
||||||
(t55). The Shield's own ExoPlayer/MediaSession sessions report state 6
|
(t55). 6 is the stock-Android buffering value and the mapping is INFERRED
|
||||||
while buffering just as stock Android does, so no vendor-specific state
|
from the ``state=PlaybackState {state=N, ...}`` line shape, not from a
|
||||||
integer is treated as buffering.
|
captured Shield dump: no fixture in this repo contains state=6, and no
|
||||||
|
real device was available. Nothing here is vendor-specific either way —
|
||||||
|
every integer is read straight out of the dump.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ source that has something to say, and the earlier steps always win:
|
|||||||
a slow Jellyfin poll confirms it; self-heal in 10 s).
|
a slow Jellyfin poll confirms it; self-heal in 10 s).
|
||||||
3. Jellyfin — STATE_PLAYING / STATE_PAUSED of the configured entity.
|
3. Jellyfin — STATE_PLAYING / STATE_PAUSED of the configured entity.
|
||||||
4. session — the ADB media session's PlaybackState, including BUFFERING
|
4. session — the ADB media session's PlaybackState, including BUFFERING
|
||||||
(Android state 6). Session state 3 → playing, 2 → paused.
|
(Android state 6, inferred from the dump's line shape rather
|
||||||
|
than observed on hardware). State 3 → playing, 2 → paused.
|
||||||
5. IDLE — ADB says the device is asleep (standby), OR the device is
|
5. IDLE — ADB says the device is asleep (standby), OR the device is
|
||||||
powered on (protocol is_on True / ADB awake / the plug is on)
|
powered on (protocol is_on True / ADB awake / the plug is on)
|
||||||
and NOTHING is playing. "Powered on, app open, nothing
|
and NOTHING is playing. "Powered on, app open, nothing
|
||||||
@@ -654,9 +655,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
One place decides what a session means, so ``state`` and
|
One place decides what a session means, so ``state`` and
|
||||||
``supported_features`` cannot disagree about it. Buffering is checked
|
``supported_features`` cannot disagree about it. Buffering is checked
|
||||||
FIRST: a session refilling its buffer is mid-playback, and while the
|
FIRST: a session refilling its buffer is mid-playback, and state 6 is
|
||||||
Shield never sets state 6 together with 3, BUFFERING is the more
|
the stock-Android buffering value (an inference from the dump's line
|
||||||
informative of the two if a future firmware ever did.
|
shape — no captured fixture here carries it), so BUFFERING is the
|
||||||
|
more informative of the two if a firmware ever set 6 together with 3.
|
||||||
"""
|
"""
|
||||||
if info is None:
|
if info is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ class TestRealMenuFlow:
|
|||||||
"connection",
|
"connection",
|
||||||
"receiver",
|
"receiver",
|
||||||
"apps",
|
"apps",
|
||||||
|
"discover",
|
||||||
"scripts",
|
"scripts",
|
||||||
"finish",
|
"finish",
|
||||||
}
|
}
|
||||||
@@ -229,7 +230,8 @@ class TestRealMenuFlow:
|
|||||||
# each row is opened on its own flow, because a real flow keeps ONE
|
# each row is opened on its own flow, because a real flow keeps ONE
|
||||||
# step at a time — reopening from a fresh menu is what the browser does
|
# step at a time — reopening from a fresh menu is what the browser does
|
||||||
for row in menu["menu_options"]:
|
for row in menu["menu_options"]:
|
||||||
if row == "finish":
|
if row in ("finish", "discover"):
|
||||||
|
# actions: `finish` commits and `discover` sweeps, neither renders
|
||||||
continue
|
continue
|
||||||
_manager, fresh = await open_options(ha, entry)
|
_manager, fresh = await open_options(ha, entry)
|
||||||
opened = await _manager.async_configure(
|
opened = await _manager.async_configure(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from nsh.const import (
|
|||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
CONF_COMMAND_SCRIPTS,
|
CONF_COMMAND_SCRIPTS,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
@@ -1506,7 +1507,7 @@ class TestCopyContract:
|
|||||||
(await getattr(flow, f"async_step_{step}")(None))["data_schema"]
|
(await getattr(flow, f"async_step_{step}")(None))["data_schema"]
|
||||||
)
|
)
|
||||||
for step in menu["menu_options"]
|
for step in menu["menu_options"]
|
||||||
if step != "finish"
|
if step not in ("finish", "discover")
|
||||||
}
|
}
|
||||||
|
|
||||||
for file in (_strings(), _en_strings()):
|
for file in (_strings(), _en_strings()):
|
||||||
@@ -1983,6 +1984,7 @@ class TestOptionsMenu:
|
|||||||
"connection",
|
"connection",
|
||||||
"receiver",
|
"receiver",
|
||||||
"apps",
|
"apps",
|
||||||
|
"discover",
|
||||||
"scripts",
|
"scripts",
|
||||||
"finish",
|
"finish",
|
||||||
]
|
]
|
||||||
@@ -1998,9 +2000,13 @@ class TestOptionsMenu:
|
|||||||
flow = _options_flow(tmp_path)
|
flow = _options_flow(tmp_path)
|
||||||
menu = await flow.async_step_init(None)
|
menu = await flow.async_step_init(None)
|
||||||
|
|
||||||
|
# `discover` and `finish` are ACTIONS, not sub-screens: HA dispatches
|
||||||
|
# them with no input and they return a terminal/menu result, so they are
|
||||||
|
# held to the handler-name rule only
|
||||||
|
action_rows = {"discover", "finish"}
|
||||||
for row in menu["menu_options"]:
|
for row in menu["menu_options"]:
|
||||||
assert hasattr(flow, f"async_step_{row}"), row
|
assert hasattr(flow, f"async_step_{row}"), row
|
||||||
if row == "finish":
|
if row in action_rows:
|
||||||
continue
|
continue
|
||||||
form = await getattr(flow, f"async_step_{row}")(None)
|
form = await getattr(flow, f"async_step_{row}")(None)
|
||||||
assert form["type"] == "form"
|
assert form["type"] == "form"
|
||||||
@@ -2009,7 +2015,7 @@ class TestOptionsMenu:
|
|||||||
async def test_every_sub_screen_is_reachable_and_returns_to_the_menu(self, tmp_path):
|
async def test_every_sub_screen_is_reachable_and_returns_to_the_menu(self, tmp_path):
|
||||||
flow = _options_flow(tmp_path)
|
flow = _options_flow(tmp_path)
|
||||||
|
|
||||||
for row in ("connection", "receiver", "apps", "scripts"):
|
for row in ("connection", "receiver", "apps", "scripts"): # sub-screens
|
||||||
opened = await _options_open(flow, row)
|
opened = await _options_open(flow, row)
|
||||||
assert opened["type"] == "form"
|
assert opened["type"] == "form"
|
||||||
# a submission that changes nothing still returns to the menu
|
# a submission that changes nothing still returns to the menu
|
||||||
@@ -2112,9 +2118,10 @@ class TestAppsTable:
|
|||||||
assert form["step_id"] == "apps"
|
assert form["step_id"] == "apps"
|
||||||
assert form["errors"] == {}
|
assert form["errors"] == {}
|
||||||
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
|
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
|
||||||
# no discovery data yet, so nothing is suggested — an empty text field,
|
# no discovery data yet (there is no manager in this harness), so the
|
||||||
# which is exactly the documented "blank uses the built-in list"
|
# field is empty — which is exactly the documented "blank uses the
|
||||||
assert "app_name_map" not in form["data_schema"].get("__suggested__", {})
|
# built-in list", and nothing to crash on
|
||||||
|
assert form["data_schema"].get("__suggested__", {}).get("app_name_map", "") == ""
|
||||||
|
|
||||||
async def test_a_cleared_table_is_an_empty_map_and_not_an_error(self, tmp_path):
|
async def test_a_cleared_table_is_an_empty_map_and_not_an_error(self, tmp_path):
|
||||||
flow = _options_flow(tmp_path, {"app_name_map": {"com.plex.hdx": "Plex"}})
|
flow = _options_flow(tmp_path, {"app_name_map": {"com.plex.hdx": "Plex"}})
|
||||||
@@ -2124,6 +2131,148 @@ class TestAppsTable:
|
|||||||
assert result["data"]["app_name_map"] == {}
|
assert result["data"]["app_name_map"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoveryFeedsTheAppsTable:
|
||||||
|
"""t57: the Apps table opens on the REAL app list, not four curated rows."""
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
"""A stand-in connection manager with a canned discovered table."""
|
||||||
|
|
||||||
|
def __init__(self, rows=None, connected=True):
|
||||||
|
self.rows = list(rows or [])
|
||||||
|
self.connected = connected
|
||||||
|
self.refresh_calls: list[bool] = []
|
||||||
|
|
||||||
|
def installed_apps(self):
|
||||||
|
return [dict(row) for row in self.rows]
|
||||||
|
|
||||||
|
async def async_refresh_installed_apps(self, force=False):
|
||||||
|
self.refresh_calls.append(force)
|
||||||
|
return self.installed_apps()
|
||||||
|
|
||||||
|
def _flow_with(self, tmp_path, options, rows, connected=True):
|
||||||
|
flow = _options_flow(tmp_path, options)
|
||||||
|
manager = self._Manager(rows, connected)
|
||||||
|
flow.config_entry.runtime_data = manager
|
||||||
|
return flow, manager
|
||||||
|
|
||||||
|
async def test_the_table_lists_every_discovered_app(self, tmp_path):
|
||||||
|
flow, _m = self._flow_with(
|
||||||
|
tmp_path,
|
||||||
|
{"app_name_map": {"ar.tvplayer.tv": "Telly"}},
|
||||||
|
[
|
||||||
|
{"package": "ar.tvplayer.tv", "name": "TiviMate", "icon": "mdi:television-classic"},
|
||||||
|
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
||||||
|
{"package": "com.example.handmade", "name": "com.example.handmade",
|
||||||
|
"icon": "mdi:television"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
form = await _options_open(flow, "apps")
|
||||||
|
text = form["data_schema"]["__suggested__"]["app_name_map"]
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
# the user's own row keeps its position, its name and its icon slot
|
||||||
|
assert lines[0] == "ar.tvplayer.tv=Telly"
|
||||||
|
# every discovered app is a row too — this is the live bug
|
||||||
|
assert "org.xbmc.kodi=Kodi" in lines
|
||||||
|
assert "com.example.handmade=com.example.handmade" in lines
|
||||||
|
# the user's name wins over the device label for the same package
|
||||||
|
assert not [line for line in lines if line == "ar.tvplayer.tv=TiviMate"]
|
||||||
|
# ...and the discovered icon is NEVER written into the icon column
|
||||||
|
assert not [line for line in lines if "mdi:" in line]
|
||||||
|
|
||||||
|
async def test_a_discovered_app_the_map_never_mentions_is_appended(self, tmp_path):
|
||||||
|
flow, _m = self._flow_with(
|
||||||
|
tmp_path,
|
||||||
|
{},
|
||||||
|
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
form = await _options_open(flow, "apps")
|
||||||
|
|
||||||
|
assert form["data_schema"]["__suggested__"]["app_name_map"] == (
|
||||||
|
"com.netflix.ninja=Netflix"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_no_discovery_leaves_the_users_own_rows_alone(self, tmp_path):
|
||||||
|
flow, _m = self._flow_with(
|
||||||
|
tmp_path, {"app_name_map": {"com.a": "A"}}, [], connected=False
|
||||||
|
)
|
||||||
|
|
||||||
|
form = await _options_open(flow, "apps")
|
||||||
|
|
||||||
|
assert form["data_schema"]["__suggested__"]["app_name_map"] == "com.a=A"
|
||||||
|
|
||||||
|
async def test_rendering_the_table_issues_no_shell(self, tmp_path):
|
||||||
|
"""A form render must never sweep — discovery is an explicit action."""
|
||||||
|
flow, manager = self._flow_with(tmp_path, {}, [])
|
||||||
|
|
||||||
|
await _options_open(flow, "apps")
|
||||||
|
await flow.async_step_init(None)
|
||||||
|
|
||||||
|
assert manager.refresh_calls == []
|
||||||
|
|
||||||
|
async def test_the_discover_row_runs_the_sweep_and_returns_to_the_menu(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
flow, manager = self._flow_with(
|
||||||
|
tmp_path,
|
||||||
|
{},
|
||||||
|
[{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
menu = await flow.async_step_init(None)
|
||||||
|
assert "discover" in menu["menu_options"]
|
||||||
|
|
||||||
|
result = await flow.async_step_discover(None)
|
||||||
|
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
assert manager.refresh_calls == [True] # explicit force, exactly once
|
||||||
|
|
||||||
|
async def test_the_menu_says_how_many_apps_were_found(self, tmp_path):
|
||||||
|
"""The menu line is what tells the user discovery worked.
|
||||||
|
|
||||||
|
Asserted on the flow's own placeholder builder rather than the stub's
|
||||||
|
menu result: ``description_placeholders`` is passed to
|
||||||
|
``async_show_menu`` for the real frontend to substitute into the step
|
||||||
|
description, and a stub that echoed it would be testing itself.
|
||||||
|
"""
|
||||||
|
flow, _m = self._flow_with(
|
||||||
|
tmp_path,
|
||||||
|
{},
|
||||||
|
[{"package": "com.a", "name": "A", "icon": "mdi:television"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "1 apps found" in flow._menu_placeholders()["discovery"]
|
||||||
|
|
||||||
|
async def test_a_failed_sweep_still_renders_the_menu(self, tmp_path):
|
||||||
|
class _Boom(TestDiscoveryFeedsTheAppsTable._Manager):
|
||||||
|
async def async_refresh_installed_apps(self, force=False):
|
||||||
|
raise RuntimeError("adb went away")
|
||||||
|
|
||||||
|
flow = _options_flow(tmp_path, {})
|
||||||
|
flow.config_entry.runtime_data = _Boom([], connected=False)
|
||||||
|
|
||||||
|
result = await flow.async_step_discover(None)
|
||||||
|
|
||||||
|
assert result["type"] == "menu" # no traceback, no dead end
|
||||||
|
|
||||||
|
async def test_the_table_override_survives_a_submission(self, tmp_path):
|
||||||
|
"""Saving the merged table stores the user's rows, not the discovered ones."""
|
||||||
|
flow, _m = self._flow_with(
|
||||||
|
tmp_path,
|
||||||
|
{},
|
||||||
|
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
||||||
|
)
|
||||||
|
form = await _options_open(flow, "apps")
|
||||||
|
submitted = form["data_schema"]["__suggested__"]["app_name_map"]
|
||||||
|
|
||||||
|
await flow.async_step_apps({CONF_APP_NAME_MAP: submitted})
|
||||||
|
result = await flow.async_step_finish(None)
|
||||||
|
|
||||||
|
assert result["data"][CONF_APP_NAME_MAP] == {"com.netflix.ninja": "Netflix"}
|
||||||
|
|
||||||
|
|
||||||
class TestReceiverAcceptsASwitch:
|
class TestReceiverAcceptsASwitch:
|
||||||
"""t56: a soundbar that only knows on/off is a valid receiver."""
|
"""t56: a soundbar that only knows on/off is a valid receiver."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""t57: installed-app discovery — parsers, the bounded sweep, and the surfaces.
|
||||||
|
|
||||||
|
No real Shield was available, so the command OUTPUT here is the part that is
|
||||||
|
fixture-based, not observed: the shapes are the documented Android ones
|
||||||
|
(``pm list packages`` prints ``package:<id>``; ``resolve-activity --brief``
|
||||||
|
prints the verbose intent then ``package/activity``; ``pm dump`` prints
|
||||||
|
``application-label: '...'``). The parsers are deliberately tolerant because
|
||||||
|
that is the property that matters when a real device's output differs in
|
||||||
|
whitespace or ordering, and every tolerance is pinned below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import entity_stublib # noqa: E402
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from entity_stublib import load_entity_modules # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_entity_stubs():
|
||||||
|
"""Container for the HA stubs + loaded entity modules (t7 discipline).
|
||||||
|
|
||||||
|
Without the teardown the stubs this file installs leak into whichever test
|
||||||
|
file runs next — tests/test_harness_scoping.py exists because that mistake
|
||||||
|
is invisible until an unrelated file fails.
|
||||||
|
"""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
entity_stublib.install()
|
||||||
|
yield load_entity_modules()
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
from nsh.connection import ShieldConnectionManager # noqa: E402
|
||||||
|
from nsh.const import ( # noqa: E402
|
||||||
|
APP_ICON_FALLBACKS,
|
||||||
|
APP_LIST_TTL,
|
||||||
|
CONF_APP_NAME_MAP,
|
||||||
|
)
|
||||||
|
from nsh.ha_adb_common import ( # noqa: E402
|
||||||
|
icon_for_package,
|
||||||
|
installed_packages,
|
||||||
|
is_launchable,
|
||||||
|
parse_active_launcher,
|
||||||
|
parse_package_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── realistic device output ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#: `pm list packages -3` on a Shield with a normal app set. Wrapped the way a
|
||||||
|
#: real adb shell answers (a bare list, no header).
|
||||||
|
USER_PACKAGES = (
|
||||||
|
"package:ar.tvplayer.tv\n"
|
||||||
|
"package:com.github.damontecres.wholphin\n"
|
||||||
|
"package:com.netflix.ninja\n"
|
||||||
|
"package:com.google.android.youtube.tv\n"
|
||||||
|
"package:org.xbmc.kodi\n"
|
||||||
|
"package:com.plexapp.android\n"
|
||||||
|
"package:com.spotify.tv.android\n"
|
||||||
|
"package:com.example.handmade\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: `cmd package resolve-activity --brief -c android.intent.category.HOME` —
|
||||||
|
#: the verbose header then the component, exactly as Android prints it.
|
||||||
|
LAUNCHER_RESOLVED = (
|
||||||
|
"priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false\n"
|
||||||
|
"com.google.android.tvlauncher/com.google.android.apps.tv.launcherx.MainActivity\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: `pm dump` for an app whose manifest and locale variants both exist. The
|
||||||
|
#: parser takes the first default-locale label and ignores the localized ones.
|
||||||
|
DUMP_TIVIMATE = (
|
||||||
|
"Package [ar.tvplayer.tv] (7f1a2b3c):\n"
|
||||||
|
" application-label:'TiviMate'\n"
|
||||||
|
" application-label-de:'TiviMate DE'\n"
|
||||||
|
" application-label-fr:'TiviMate FR'\n"
|
||||||
|
" versionName=4.7.0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: A dump that RAN and carries no label at all — several system/OEM packages
|
||||||
|
#: look like this. It is a definitive negative, not a failed fetch.
|
||||||
|
DUMP_LABEL_LESS = (
|
||||||
|
"Package [com.example.handmade] (aa11bb22):\n"
|
||||||
|
" versionName=1.0\n"
|
||||||
|
" codePath=/data/app/com.example.handmade\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DiscoveryPoller:
|
||||||
|
"""A poller whose shell answers the discovery commands from fixtures."""
|
||||||
|
|
||||||
|
def __init__(self, *, packages=USER_PACKAGES, launcher=LAUNCHER_RESOLVED,
|
||||||
|
dumps=None, failure=False):
|
||||||
|
self.connected = True
|
||||||
|
self.connected_override = True
|
||||||
|
self.packages = packages
|
||||||
|
self.launcher = launcher
|
||||||
|
self.dumps = dict(dumps or {})
|
||||||
|
self.failure = failure
|
||||||
|
self.shell_calls: list[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self):
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
async def shell(self, cmd: str):
|
||||||
|
self.shell_calls.append(cmd)
|
||||||
|
if self.failure:
|
||||||
|
return None
|
||||||
|
if "pm list packages" in cmd:
|
||||||
|
return self.packages
|
||||||
|
if "resolve-activity" in cmd:
|
||||||
|
return self.launcher
|
||||||
|
if "pm dump" in cmd:
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
for package, dump in self.dumps.items():
|
||||||
|
if shlex.quote(package) in cmd or package in cmd:
|
||||||
|
return dump
|
||||||
|
return "__NSH_EXIT:0\n" # ran, no label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(poller):
|
||||||
|
return ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackageListParsing:
|
||||||
|
"""`pm list packages -3`."""
|
||||||
|
|
||||||
|
def test_real_output_parses_one_package_per_line(self):
|
||||||
|
assert parse_package_list(USER_PACKAGES) == [
|
||||||
|
"ar.tvplayer.tv",
|
||||||
|
"com.github.damontecres.wholphin",
|
||||||
|
"com.netflix.ninja",
|
||||||
|
"com.google.android.youtube.tv",
|
||||||
|
"org.xbmc.kodi",
|
||||||
|
"com.plexapp.android",
|
||||||
|
"com.spotify.tv.android",
|
||||||
|
"com.example.handmade",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_noise_and_duplicates_are_tolerated(self):
|
||||||
|
noisy = (
|
||||||
|
"adb: connecting to 10.0.0.9:5555\n"
|
||||||
|
"package:com.a\n"
|
||||||
|
"\n"
|
||||||
|
"package:com.a\n"
|
||||||
|
"package:com.b \n"
|
||||||
|
"some warning line\n"
|
||||||
|
)
|
||||||
|
assert parse_package_list(noisy) == ["com.a", "com.b"]
|
||||||
|
|
||||||
|
def test_empty_and_none_are_an_empty_list(self):
|
||||||
|
assert parse_package_list(None) == []
|
||||||
|
assert parse_package_list("") == []
|
||||||
|
|
||||||
|
def test_a_package_id_is_sanity_checked(self):
|
||||||
|
assert is_launchable("com.example.app")
|
||||||
|
assert is_launchable("ar.tvplayer.tv")
|
||||||
|
assert not is_launchable("")
|
||||||
|
assert not is_launchable("notapackage")
|
||||||
|
assert not is_launchable("com..b")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLauncherParsing:
|
||||||
|
"""`cmd package resolve-activity --brief -c ...HOME`."""
|
||||||
|
|
||||||
|
def test_the_component_after_the_verbose_header_is_the_launcher(self):
|
||||||
|
assert parse_active_launcher(LAUNCHER_RESOLVED) == "com.google.android.tvlauncher"
|
||||||
|
|
||||||
|
def test_activity_only_output_still_parses(self):
|
||||||
|
assert parse_active_launcher("com.google.android.tvlauncher/.MainActivity") == (
|
||||||
|
"com.google.android.tvlauncher"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nothing_resolved_is_none(self):
|
||||||
|
assert parse_active_launcher("No activity found") is None
|
||||||
|
assert parse_active_launcher(None) is None
|
||||||
|
|
||||||
|
def test_the_tv_settings_entry_point_is_not_a_launcher(self):
|
||||||
|
# a HOME resolution can land on the settings app's entry point; that is
|
||||||
|
# not the app a user opens
|
||||||
|
output = "com.android.tv.settings/.MainActivity"
|
||||||
|
assert parse_active_launcher(output) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledPackagesMerge:
|
||||||
|
"""User apps plus the active launcher, de-duplicated."""
|
||||||
|
|
||||||
|
def test_launcher_is_added_last(self):
|
||||||
|
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
|
||||||
|
assert packages[-1] == "com.google.android.tvlauncher"
|
||||||
|
assert packages[0] == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
def test_a_third_party_launcher_is_not_duplicated(self):
|
||||||
|
user = "package:com.example.launcher\npackage:com.a\n"
|
||||||
|
launcher = "com.example.launcher/.MainActivity"
|
||||||
|
assert installed_packages(user, launcher) == ["com.example.launcher", "com.a"]
|
||||||
|
|
||||||
|
def test_no_launcher_still_yields_the_user_apps(self):
|
||||||
|
assert installed_packages(USER_PACKAGES, None) == parse_package_list(USER_PACKAGES)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIconHeuristic:
|
||||||
|
"""Icons are a curated map plus a generic fallback — and always a string."""
|
||||||
|
|
||||||
|
def test_a_known_package_gets_its_curated_icon(self):
|
||||||
|
assert icon_for_package("com.netflix.ninja", APP_ICON_FALLBACKS) == "mdi:netflix"
|
||||||
|
|
||||||
|
def test_an_unknown_package_gets_the_generic_icon(self):
|
||||||
|
assert icon_for_package("com.example.handmade", APP_ICON_FALLBACKS) == "mdi:television"
|
||||||
|
|
||||||
|
def test_the_result_is_always_a_string(self):
|
||||||
|
for package in ("", None, "com.example.handmade", "ar.tvplayer.tv"):
|
||||||
|
icon = icon_for_package(package, APP_ICON_FALLBACKS)
|
||||||
|
assert isinstance(icon, str) and icon
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverySweep:
|
||||||
|
"""The bounded sweep on the connection manager."""
|
||||||
|
|
||||||
|
async def test_a_sweep_lists_labels_and_icons_every_app(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={
|
||||||
|
"ar.tvplayer.tv": DUMP_TIVIMATE,
|
||||||
|
"com.example.handmade": DUMP_LABEL_LESS,
|
||||||
|
})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
packages = [row["package"] for row in rows]
|
||||||
|
assert "ar.tvplayer.tv" in packages
|
||||||
|
assert "com.netflix.ninja" in packages
|
||||||
|
assert "com.google.android.tvlauncher" in packages # the launcher is there
|
||||||
|
by_pkg = {row["package"]: row for row in rows}
|
||||||
|
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate" # from the dump
|
||||||
|
assert by_pkg["ar.tvplayer.tv"]["icon"] == "mdi:television-classic"
|
||||||
|
# a label-less app keeps its discovered name (the package id) and is
|
||||||
|
# still a row — it must not vanish from the table
|
||||||
|
assert by_pkg["com.example.handmade"]["name"] == "com.example.handmade"
|
||||||
|
# and every row's name/icon is a string
|
||||||
|
for row in rows:
|
||||||
|
assert isinstance(row["name"], str) and row["name"]
|
||||||
|
assert isinstance(row["icon"], str) and row["icon"]
|
||||||
|
|
||||||
|
async def test_the_sweep_issues_the_documented_commands(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert "pm list packages -3" in poller.shell_calls
|
||||||
|
assert any("resolve-activity" in cmd for cmd in poller.shell_calls)
|
||||||
|
assert "android.intent.category.HOME" in " ".join(poller.shell_calls)
|
||||||
|
|
||||||
|
async def test_labels_are_fetched_in_a_bounded_burst(self):
|
||||||
|
"""A full sweep must not serialise one shell at a time, and must not flood."""
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
concurrent = {"now": 0, "peak": 0, "total": 0}
|
||||||
|
|
||||||
|
real_shell = poller.shell
|
||||||
|
|
||||||
|
async def _counted(cmd):
|
||||||
|
if "pm dump" in cmd:
|
||||||
|
concurrent["now"] += 1
|
||||||
|
concurrent["total"] += 1
|
||||||
|
concurrent["peak"] = max(concurrent["peak"], concurrent["now"])
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0) # yield so overlap is observable
|
||||||
|
return await real_shell(cmd)
|
||||||
|
finally:
|
||||||
|
concurrent["now"] -= 1
|
||||||
|
return await real_shell(cmd)
|
||||||
|
|
||||||
|
poller.shell = _counted
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert len(rows) >= 5
|
||||||
|
assert concurrent["total"] >= 5
|
||||||
|
# bounded: never more than the configured workers in flight at once
|
||||||
|
from nsh.const import APP_SWEEP_CONCURRENCY
|
||||||
|
|
||||||
|
assert concurrent["peak"] <= APP_SWEEP_CONCURRENCY
|
||||||
|
assert concurrent["peak"] > 1 # ...and genuinely concurrent, not serial
|
||||||
|
|
||||||
|
async def test_a_cached_table_is_reused_and_costs_no_shell(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
first = await manager.async_refresh_installed_apps()
|
||||||
|
calls = len(poller.shell_calls)
|
||||||
|
second = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert second == first
|
||||||
|
assert len(poller.shell_calls) == calls # cached: zero new shells
|
||||||
|
assert manager.installed_apps_stale is False
|
||||||
|
|
||||||
|
async def test_force_refreshes_even_a_fresh_table(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
calls = len(poller.shell_calls)
|
||||||
|
await manager.async_refresh_installed_apps(force=True)
|
||||||
|
|
||||||
|
assert len(poller.shell_calls) > calls
|
||||||
|
|
||||||
|
async def test_a_stale_table_is_not_served(self):
|
||||||
|
now = [1000.0]
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||||
|
)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
assert manager.installed_apps()
|
||||||
|
|
||||||
|
now[0] += APP_LIST_TTL + 1
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
assert manager.installed_apps_stale is True
|
||||||
|
|
||||||
|
async def test_concurrent_callers_share_one_sweep(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
list_calls = {"n": 0}
|
||||||
|
real_shell = poller.shell
|
||||||
|
|
||||||
|
async def _counted(cmd):
|
||||||
|
if "pm list packages" in cmd:
|
||||||
|
list_calls["n"] += 1
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return await real_shell(cmd)
|
||||||
|
|
||||||
|
poller.shell = _counted
|
||||||
|
results = await asyncio.gather(
|
||||||
|
manager.async_refresh_installed_apps(force=True),
|
||||||
|
manager.async_refresh_installed_apps(force=True),
|
||||||
|
manager.async_refresh_installed_apps(force=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_calls["n"] == 1 # one sweep, shared
|
||||||
|
assert results[0] == results[1] == results[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoveryDegradesGracefully:
|
||||||
|
"""ADB off, down or mute: an empty list and a note, never a crash or a loop."""
|
||||||
|
|
||||||
|
async def test_no_poller_is_an_empty_list(self):
|
||||||
|
manager = ShieldConnectionManager(hass=None, entry=None, poller=None)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows == []
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
|
||||||
|
async def test_a_disconnected_poller_is_an_empty_list(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager._poller = poller
|
||||||
|
poller.connected = False
|
||||||
|
# the manager's own connected flag is what the sweep reads
|
||||||
|
assert manager.connected is False
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows == []
|
||||||
|
assert poller.shell_calls == [] # nothing was even attempted
|
||||||
|
|
||||||
|
async def test_a_mute_transport_reports_empty_without_raising(self):
|
||||||
|
poller = _DiscoveryPoller(failure=True)
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows == []
|
||||||
|
assert poller.shell_calls # it tried
|
||||||
|
# and a second call does NOT re-shell in a loop: the empty answer is
|
||||||
|
# stamped, and only an explicit force/TTL asks again
|
||||||
|
calls = len(poller.shell_calls)
|
||||||
|
assert await manager.async_refresh_installed_apps() == []
|
||||||
|
assert len(poller.shell_calls) == calls
|
||||||
|
|
||||||
|
async def test_the_accessor_never_shells(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
assert poller.shell_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledAppsSensor:
|
||||||
|
"""The dashboard surface: state = count, attribute = the table."""
|
||||||
|
|
||||||
|
def _sensor(self, manager, options=None):
|
||||||
|
sensor_module = load_entity_modules()["sensor"]
|
||||||
|
entry = type("E", (), {})()
|
||||||
|
entry.entry_id = "e1"
|
||||||
|
entry.options = dict(options or {})
|
||||||
|
entry.runtime_data = manager
|
||||||
|
entry.title = "mdarcy"
|
||||||
|
sensor = sensor_module.NvidiaShieldAppsSensor.__new__(
|
||||||
|
sensor_module.NvidiaShieldAppsSensor
|
||||||
|
)
|
||||||
|
sensor.hass = entity_stublib.FakeHass()
|
||||||
|
sensor._entry = entry
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
async def test_state_is_the_count_and_the_attribute_is_the_table(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
sensor = self._sensor(manager)
|
||||||
|
|
||||||
|
assert sensor.native_value == len(manager.installed_apps())
|
||||||
|
attrs = sensor.extra_state_attributes
|
||||||
|
assert attrs["discovery"] == "ok"
|
||||||
|
assert any(
|
||||||
|
app["package"] == "ar.tvplayer.tv" and app["name"] == "TiviMate"
|
||||||
|
for app in attrs["installed_apps"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_discovery_yet_is_an_empty_table_with_a_reason(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
sensor = self._sensor(manager)
|
||||||
|
|
||||||
|
assert sensor.native_value == 0
|
||||||
|
attrs = sensor.extra_state_attributes
|
||||||
|
assert attrs["installed_apps"] == []
|
||||||
|
assert attrs["discovery"] in ("pending", "not_connected", "adb_disabled")
|
||||||
|
|
||||||
|
def test_adb_disabled_is_reported_as_such(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
poller.connected = False
|
||||||
|
sensor = self._sensor(manager, {"adb_enabled": False})
|
||||||
|
|
||||||
|
attrs = sensor.extra_state_attributes
|
||||||
|
|
||||||
|
assert attrs == {"installed_apps": [], "discovery": "adb_disabled"}
|
||||||
|
|
||||||
|
async def test_the_sensor_refresh_is_a_no_op_without_a_manager(self):
|
||||||
|
sensor = self._sensor(None)
|
||||||
|
sensor._entry.runtime_data = None
|
||||||
|
|
||||||
|
assert sensor.native_value == 0
|
||||||
|
assert sensor.extra_state_attributes["installed_apps"] == []
|
||||||
|
await sensor.async_update() # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoveredAppsStillRespectTheUsersMap:
|
||||||
|
"""The user's own map is an OVERRIDE, and discovery fills the rest (t57)."""
|
||||||
|
|
||||||
|
async def test_a_mapped_package_keeps_the_users_name_not_the_device_label(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
# the shared resolver is what the sensor and the player use; discovery
|
||||||
|
# only supplies the device label underneath the user's map
|
||||||
|
from nsh.media_player import resolve_app_name
|
||||||
|
|
||||||
|
options = {CONF_APP_NAME_MAP: {"ar.tvplayer.tv": "Telly"}}
|
||||||
|
assert (
|
||||||
|
resolve_app_name(
|
||||||
|
"ar.tvplayer.tv", options[CONF_APP_NAME_MAP], manager.app_label
|
||||||
|
)
|
||||||
|
== "Telly"
|
||||||
|
)
|
||||||
|
# ...and the device label is what discovery put UNDER it, so dropping
|
||||||
|
# the map entry still resolves to a real name rather than a package id
|
||||||
|
assert (
|
||||||
|
resolve_app_name("ar.tvplayer.tv", {}, manager.app_label) == "TiviMate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_label_exit_marker_never_becomes_a_name():
|
||||||
|
"""The marker is a transport detail; it must not leak into a label."""
|
||||||
|
from nsh.ha_adb_common import parse_application_label
|
||||||
|
|
||||||
|
assert parse_application_label("__NSH_EXIT:0\n") is None
|
||||||
|
assert parse_application_label(DUMP_TIVIMATE + "__NSH_EXIT:0\n") == "TiviMate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_importing_the_discovery_module_needs_no_home_assistant():
|
||||||
|
"""The parsers live in ha_adb_common and stay importable without HA."""
|
||||||
|
import nsh.ha_adb_common.installed_apps as module
|
||||||
|
|
||||||
|
assert module.__doc__ and "pm list packages -3" in module.__doc__
|
||||||
|
assert not [n for n in dir(module) if n.startswith("hass")]
|
||||||
@@ -149,12 +149,16 @@ def _install_stubs() -> None:
|
|||||||
raise DiscoveryDedupAbortError("already_in_progress")
|
raise DiscoveryDedupAbortError("already_in_progress")
|
||||||
|
|
||||||
class OptionsFlow:
|
class OptionsFlow:
|
||||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
def async_show_form(
|
||||||
|
self, *, step_id, data_schema=None, errors=None,
|
||||||
|
description_placeholders=None,
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"type": "form",
|
"type": "form",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"errors": dict(errors or {}),
|
"errors": dict(errors or {}),
|
||||||
"data_schema": data_schema,
|
"data_schema": data_schema,
|
||||||
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
def async_show_menu(self, *, step_id, menu_options, sort=False,
|
||||||
@@ -1306,7 +1310,7 @@ class TestAppsMapOptionsEditor:
|
|||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
fields = getattr(result["data_schema"], "schema", result["data_schema"])
|
fields = getattr(result["data_schema"], "schema", result["data_schema"])
|
||||||
suggested = fields.get("__suggested__", {})
|
suggested = fields.get("__suggested__", {})
|
||||||
assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate"
|
assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate" # t57
|
||||||
# the editor carried the existing map through the defaults path
|
# the editor carried the existing map through the defaults path
|
||||||
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user