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:
James Mackay committed 2026-10-12 07:29:01 +01:00
1 parent a56ec38170
commit 5eefd0c6d0
6 files changed
+669 -16

No files matched your search

+155 -6
View File
@@ -35,6 +35,7 @@ from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_COMMAND_SCRIPTS,
CONF_HOST,
@@ -1506,7 +1507,7 @@ class TestCopyContract:
(await getattr(flow, f"async_step_{step}")(None))["data_schema"]
)
for step in menu["menu_options"]
if step != "finish"
if step not in ("finish", "discover")
}
for file in (_strings(), _en_strings()):
@@ -1983,6 +1984,7 @@ class TestOptionsMenu:
"connection",
"receiver",
"apps",
"discover",
"scripts",
"finish",
]
@@ -1998,9 +2000,13 @@ class TestOptionsMenu:
flow = _options_flow(tmp_path)
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"]:
assert hasattr(flow, f"async_step_{row}"), row
if row == "finish":
if row in action_rows:
continue
form = await getattr(flow, f"async_step_{row}")(None)
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):
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)
assert opened["type"] == "form"
# a submission that changes nothing still returns to the menu
@@ -2112,9 +2118,10 @@ class TestAppsTable:
assert form["step_id"] == "apps"
assert form["errors"] == {}
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
# no discovery data yet, so nothing is suggested — an empty text field,
# which is exactly the documented "blank uses the built-in list"
assert "app_name_map" not in form["data_schema"].get("__suggested__", {})
# no discovery data yet (there is no manager in this harness), so the
# field is empty — which is exactly the documented "blank uses the
# 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):
flow = _options_flow(tmp_path, {"app_name_map": {"com.plex.hdx": "Plex"}})
@@ -2124,6 +2131,148 @@ class TestAppsTable:
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:
"""t56: a soundbar that only knows on/off is a valid receiver."""