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
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user