fix(options): edit apps one row per step in a multiline field, and report discovery

The live Apps page showed the whole map as ONE long line with nowhere to add a
row. Cause: the field was a bare `vol.Optional(...): str`, and HA renders a
selector-less string as a SINGLE-LINE input (pre-fix wire field:
{"type": "string", "name": "app_name_map"} — no selector key). It is a
multiline TextSelector now: the real-HA test pins
selector.text.multiline on the wire and an N-line save/reopen round trip.

HA has no table selector, so the Apps sub-screen is a menu of row actions
(whole list as text / add / edit / remove) instead of one form:

- the EDIT row lists every app the user can act on: the rows they saved AND the
  apps discovery found, marked `[detected]`, each labelled "name (package id)"
  or "package id (no name yet)" — the user's Shield exposes no device labels at
  all, so the unnamed case is the common one and it must not read as its own id
  twice, and picking it is how a name gets set (saving stores the row)
- add: package + name + optional icon, refused by the SAME `_valid_app_row`
  rule the text field uses, with an error on the page
- edit: PREFILLED from the stored row (suggested values — a voluptuous
  `default=` never reaches the browser, so the form would have opened blank);
  the device's mdi: icon is never prefilled into the icon column
- remove: pick a SAVED row, then an explicit confirmation menu; cancelling
  keeps it (a detected app has no line to delete)
- edit/remove rows are offered only while something is stored
- every mutation writes ONE row through `_write_app_row`/`_drop_app_row`, so
  untouched rows and their icons stay byte-for-byte as they were

The Discover row was a silent no-op ("seems to load then do nothing"): it now
returns a one-line verdict the menu shows — N apps found / the Shield reported
none / the sweep could not reach the Shield / ADB is not set up — while still
never raising and never caching a failed sweep as an answer.

Also fixes `async_step_menu_again`, which the real FlowManager dispatches with
user_input and which raised TypeError for every menu offering that row (only
reachable through a stub before, hence unnoticed).

Docs: README and BOTH strings files (byte-identical) describe the editor, the
one-per-line format, the detected-app dropdown and the four Discover verdicts.
This commit is contained in:
James Mackay committed 2026-10-12 10:02:09 +01:00
1 parent eb62011ded
commit 46738063aa
8 files changed
+1369 -81

No files matched your search

+391 -13
View File
@@ -255,6 +255,24 @@ async def open_scripts(hass, entry):
return manager, scripts
async def open_apps_menu(hass, entry):
"""Open the Apps editor (t64: a MENU of row actions) through the real menu."""
manager, menu = await open_options(hass, entry)
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
assert apps["type"] == "menu", apps
return manager, apps
async def open_apps_text(hass, entry):
"""Open the Apps editor's TEXT step — the whole-list multiline field (t64)."""
manager, apps = await open_apps_menu(hass, entry)
text = await manager.async_configure(
apps["flow_id"], {"next_step_id": "apps_text"}
)
assert text["type"] == "form", text
return manager, text
class TestRealMenuFlow:
"""The options page is a real menu with real sub-screens."""
@@ -286,7 +304,11 @@ class TestRealMenuFlow:
opened = await _manager.async_configure(
fresh["flow_id"], {"next_step_id": row}
)
assert opened["type"] == "form", row
# a sub-screen is a FORM — except Apps, which is a MENU of row
# actions: HA has no table selector, so a table is edited one row per
# step (t64), and every row it offers has to be dispatchable too
expected = "menu" if row == "apps" else "form"
assert opened["type"] == expected, (row, opened)
# HA dispatches a submitted form by its OWN step_id, so the rendered
# step_id must be the menu row id (the method name)
assert opened["step_id"] == row
@@ -310,8 +332,7 @@ class TestRealAppsField:
async def test_the_apps_suggestion_is_the_serialized_text(self, ha):
entry = make_entry(ha)
manager, menu = await open_options(ha, entry)
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
manager, apps = await open_apps_text(ha, entry)
suggested_values = suggested(apps["data_schema"])
assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate"
@@ -325,8 +346,7 @@ class TestRealAppsField:
async def test_the_empty_apps_state_renders_without_a_suggestion(self, ha):
entry = make_entry(ha, options={"host": "10.0.0.9"})
manager, menu = await open_options(ha, entry)
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
manager, apps = await open_apps_text(ha, entry)
fields = field_list(apps["data_schema"])
assert [f["name"] for f in fields] == ["app_name_map"]
@@ -334,17 +354,22 @@ class TestRealAppsField:
async def test_a_table_row_round_trips_through_the_real_flow(self, ha):
entry = make_entry(ha, options={"host": "10.0.0.9"})
manager, menu = await open_options(ha, entry)
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
manager, apps = await open_apps_text(ha, entry)
saved = await manager.async_configure(
apps["flow_id"],
{"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"},
)
assert saved["type"] == "menu" # back at the menu, nothing committed yet
assert saved["type"] == "menu" # back at the APPS editor, nothing committed
assert saved["step_id"] == "apps"
back = await manager.async_configure(
saved["flow_id"], {"next_step_id": "menu_again"}
)
assert back["type"] == "menu" and back["step_id"] == "init"
created = await manager.async_configure(
saved["flow_id"], {"next_step_id": "finish"}
back["flow_id"], {"next_step_id": "finish"}
)
assert created["type"] == "create_entry"
assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"}
@@ -506,13 +531,22 @@ class TestRealNoLostEdits:
apps = await manager.async_configure(
after_scripts["flow_id"], {"next_step_id": "apps"}
)
after_apps = await manager.async_configure(
apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
assert apps["type"] == "menu" and apps["step_id"] == "apps"
text = await manager.async_configure(
apps["flow_id"], {"next_step_id": "apps_text"}
)
assert after_apps["type"] == "menu"
after_apps = await manager.async_configure(
text["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
)
assert after_apps["type"] == "menu" and after_apps["step_id"] == "apps"
back = await manager.async_configure(
after_apps["flow_id"], {"next_step_id": "menu_again"}
)
assert back["type"] == "menu" and back["step_id"] == "init"
created = await manager.async_configure(
after_apps["flow_id"], {"next_step_id": "finish"}
back["flow_id"], {"next_step_id": "finish"}
)
assert created["type"] == "create_entry"
data = created["data"]
@@ -815,3 +849,347 @@ class TestRealSetupMenu:
assert menu["type"] == "menu"
note = (menu.get("description_placeholders") or {}).get("pairing", "")
assert "ADB" in note and "remote protocol" in note
# ------------------------------------------- t64: the apps editor (real HA)
#
# Both live reports are about this screen: the map arrived as ONE LONG LINE with
# rows that could not be added, and "Discover installed apps seems to load then do
# nothing". The fixes are pinned here, over the REAL FlowManager, because that is
# where the field list the browser renders and the exact ``user_input`` a
# submission produces are decided. What is NOT provable here: how the field LOOKS.
# There is no browser, so "multiline" is proven through the serialised selector
# (``selector.text.multiline``) — the value the frontend actually reads.
class TestRealAppsMultilineField:
"""t64: the apps field is a MULTILINE text field — the live bug's cause."""
async def test_the_rendered_field_carries_the_multiline_text_selector(self, ha):
entry = make_entry(ha, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}})
manager, apps = await open_apps_text(ha, entry)
fields = {f["name"]: f for f in field_list(apps["data_schema"])}
assert list(fields) == ["app_name_map"]
# A bare `str` field with no selector renders as a SINGLE-LINE input (HA
# docs), which is why the live page showed the map flattened and refused
# new lines. The field must carry a real selector, and it is the text one;
# a selector-less field would have no ``selector`` key at all.
assert fields["app_name_map"]["selector"] == {
"text": {"multiline": True, "multiple": False}
}
async def test_a_saved_page_keeps_one_row_per_line_and_reopens_unchanged(self, ha):
entry = make_entry(ha, options={"host": "10.0.0.9"})
manager, apps = await open_apps_text(ha, entry)
page = "com.a=Alpha\ncom.b=Beta|http://icons.local/b.png\ncom.c=Gamma"
saved = await manager.async_configure(apps["flow_id"], {"app_name_map": page})
back = await manager.async_configure(
saved["flow_id"], {"next_step_id": "menu_again"}
)
created = await manager.async_configure(
back["flow_id"], {"next_step_id": "finish"}
)
assert created["data"]["app_name_map"] == {
"com.a": "Alpha",
"com.b": "Beta",
"com.c": "Gamma",
}
assert created["data"]["app_icon_map"] == {"com.b": "http://icons.local/b.png"}
# reopen over the SAVED options: the same three lines come back, in order
reopened = dict(entry.options)
reopened.update(created["data"])
object.__setattr__(entry, "_options", reopened)
_manager, apps2 = await open_apps_text(ha, entry)
assert suggested(apps2["data_schema"])["app_name_map"] == page
class TestRealAppsRowEditor:
"""t64: add / edit / remove ONE row — HA has no table selector."""
async def test_the_editor_offers_only_the_rows_that_can_do_something(self, ha):
entry = make_entry(ha, options={})
_manager, apps = await open_apps_menu(ha, entry)
# nothing stored AND nothing detected: no edit/remove row could do
# anything, so none is offered (t64)
assert list(apps["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
assert "no apps known yet" in apps["description_placeholders"]["apps"]
entry = make_entry(
ha, options={"app_name_map": {"com.a": "Alpha", "com.b": "Beta"}}
)
_manager, apps = await open_apps_menu(ha, entry)
assert list(apps["menu_options"]) == [
"apps_text",
"apps_add",
"apps_edit",
"apps_remove",
"menu_again",
]
assert apps["description_placeholders"]["apps"] == (
"2 apps known — Alpha (com.a), Beta (com.b)"
)
async def test_the_dropdown_carries_the_detected_apps_and_names_them(self, ha):
"""t64: the picked-from list is the detected list, named where known.
The user's own Shield exposes no device label at all, so the unnamed case
is the common one: it must read as unnamed (not as its own id twice), it
must still be pickable, and saving a name for it must store the row.
"""
entry = make_entry(ha, options={"app_name_map": {"com.a": "Alpha"}})
entry.runtime_data = TestRealDiscoverFeedback._Manager(
[
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
{"package": "com.unnamed.tv", "name": "com.unnamed.tv", "icon": ""},
{"package": "com.a", "name": "Wrong name", "icon": "mdi:x"},
]
)
manager, apps = await open_apps_menu(ha, entry)
note = apps["description_placeholders"]["apps"]
assert note.startswith("3 apps known — ")
assert "Kodi (org.xbmc.kodi) [detected]" in note
assert "com.unnamed.tv (no name yet) [detected]" in note
assert "Alpha (com.a)" in note # a saved row keeps the user's name
choose = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_edit"})
options = field_list(choose["data_schema"])[0]["selector"]["select"]["options"]
assert options == [
{"value": "com.a", "label": "Alpha (com.a)"},
{"value": "org.xbmc.kodi", "label": "Kodi (org.xbmc.kodi)"},
{"value": "com.unnamed.tv", "label": "com.unnamed.tv (no name yet)"},
]
form = await manager.async_configure(
choose["flow_id"], {"app_package": "com.unnamed.tv"}
)
assert form["step_id"] == "apps_edit_form"
assert "com.unnamed.tv (no name yet)" in form["description_placeholders"]["app"]
assert suggested(form["data_schema"])["app_name"] == "" # nothing invented
assert suggested(form["data_schema"])["app_icon"] == "" # never the mdi: icon
edited = await manager.async_configure(
form["flow_id"],
{"app_name": "Unnamed TV", "app_icon": "http://icons.local/u.png"},
)
back = await manager.async_configure(
edited["flow_id"], {"next_step_id": "menu_again"}
)
created = await manager.async_configure(
back["flow_id"], {"next_step_id": "finish"}
)
assert created["data"]["app_name_map"] == {
"com.a": "Alpha",
"com.unnamed.tv": "Unnamed TV",
}
assert created["data"]["app_icon_map"] == {"com.unnamed.tv": "http://icons.local/u.png"}
async def test_add_persists_one_row_and_leaves_the_others_alone(self, ha):
entry = make_entry(
ha, options={"app_name_map": {"com.a": "Alpha"}, "host": "10.0.0.9"}
)
manager, apps = await open_apps_menu(ha, entry)
add = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_add"})
assert add["type"] == "form"
assert add["step_id"] == "apps_add"
assert field_names(add["data_schema"]) == ["app_package", "app_name", "app_icon"]
edited = await manager.async_configure(
add["flow_id"],
{"app_package": "com.b", "app_name": "Beta", "app_icon": ""},
)
assert edited["type"] == "menu"
assert edited["step_id"] == "apps"
back = await manager.async_configure(
edited["flow_id"], {"next_step_id": "menu_again"}
)
created = await manager.async_configure(
back["flow_id"], {"next_step_id": "finish"}
)
assert created["data"]["app_name_map"] == {"com.a": "Alpha", "com.b": "Beta"}
assert created["data"]["host"] == "10.0.0.9" # nothing else moved
async def test_an_invalid_row_is_refused_with_a_message(self, ha):
entry = make_entry(ha, options={})
manager, apps = await open_apps_menu(ha, entry)
add = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_add"})
refused = await manager.async_configure(
add["flow_id"],
{"app_package": "has spaces", "app_name": "Nope", "app_icon": ""},
)
assert refused["type"] == "form"
assert refused["step_id"] == "apps_add"
assert refused["errors"] == {"base": "invalid_app_row"}
async def test_edit_lists_the_stored_rows_and_prefills_the_chosen_one(self, ha):
entry = make_entry(
ha,
options={
"app_name_map": {"com.a": "Alpha", "com.b": "Beta"},
"app_icon_map": {"com.b": "http://icons.local/b.png"},
},
)
manager, apps = await open_apps_menu(ha, entry)
choose = await manager.async_configure(
apps["flow_id"], {"next_step_id": "apps_edit"}
)
assert choose["type"] == "form"
assert choose["step_id"] == "apps_edit"
options = field_list(choose["data_schema"])[0]["selector"]["select"]["options"]
assert options == [
{"value": "com.a", "label": "Alpha (com.a)"},
{"value": "com.b", "label": "Beta (com.b)"},
]
form = await manager.async_configure(choose["flow_id"], {"app_package": "com.b"})
assert form["type"] == "form"
assert form["step_id"] == "apps_edit_form"
assert "Beta (com.b)" in form["description_placeholders"]["app"]
# PREFILLED from the stored row — name and icon both
assert suggested(form["data_schema"])["app_name"] == "Beta"
assert suggested(form["data_schema"])["app_icon"] == "http://icons.local/b.png"
edited = await manager.async_configure(
form["flow_id"],
{"app_name": "Beta (living room)", "app_icon": "http://icons.local/b2.png"},
)
assert edited["type"] == "menu" and edited["step_id"] == "apps"
back = await manager.async_configure(
edited["flow_id"], {"next_step_id": "menu_again"}
)
created = await manager.async_configure(
back["flow_id"], {"next_step_id": "finish"}
)
assert created["data"]["app_name_map"] == {
"com.a": "Alpha",
"com.b": "Beta (living room)",
}
assert created["data"]["app_icon_map"] == {"com.b": "http://icons.local/b2.png"}
async def test_remove_confirms_first_and_a_cancel_keeps_the_row(self, ha):
entry = make_entry(
ha, options={"app_name_map": {"com.a": "Alpha", "com.b": "Beta"}}
)
manager, apps = await open_apps_menu(ha, entry)
choose = await manager.async_configure(
apps["flow_id"], {"next_step_id": "apps_remove"}
)
assert choose["type"] == "form" and choose["step_id"] == "apps_remove"
confirm = await manager.async_configure(choose["flow_id"], {"app_package": "com.a"})
assert confirm["type"] == "menu"
assert confirm["step_id"] == "apps_remove_confirm"
assert set(confirm["menu_options"]) == {"apps_remove_yes", "apps_remove_no"}
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
cancelled = await manager.async_configure(
confirm["flow_id"], {"next_step_id": "apps_remove_no"}
)
assert cancelled["type"] == "menu" and cancelled["step_id"] == "apps"
# ...and the confirmed path drops exactly that row
choose2 = await manager.async_configure(
cancelled["flow_id"], {"next_step_id": "apps_remove"}
)
confirm2 = await manager.async_configure(
choose2["flow_id"], {"app_package": "com.a"}
)
after = await manager.async_configure(
confirm2["flow_id"], {"next_step_id": "apps_remove_yes"}
)
assert after["type"] == "menu" and after["step_id"] == "apps"
back = await manager.async_configure(
after["flow_id"], {"next_step_id": "menu_again"}
)
created = await manager.async_configure(
back["flow_id"], {"next_step_id": "finish"}
)
assert created["data"]["app_name_map"] == {"com.b": "Beta"}
class TestRealDiscoverFeedback:
"""t64: the Discover row reports WHAT IT DID — never a silent no-op."""
class _Manager:
"""A stand-in connection manager with a canned discovered table."""
def __init__(self, rows=None, connected=True, fail=False):
self.rows = list(rows or [])
self.connected = connected
self.fail = fail
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)
if self.fail:
raise RuntimeError("adb went away")
return self.installed_apps()
async def _discover(self, ha, entry):
manager, menu = await open_options(ha, entry)
return manager, await manager.async_configure(
menu["flow_id"], {"next_step_id": "discover"}
)
async def test_an_unreachable_sweep_says_so(self, ha):
entry = make_entry(ha, options={})
entry.runtime_data = self._Manager(connected=False, fail=True)
_manager, after = await self._discover(ha, entry)
assert after["type"] == "menu" and after["step_id"] == "init"
note = after["description_placeholders"]["discovery"]
assert "could not reach" in note and "unchanged" in note
async def test_finding_apps_reports_the_count(self, ha):
entry = make_entry(ha, options={})
manager = self._Manager(
[
{"package": "com.a", "name": "Alpha", "icon": "mdi:alpha"},
{"package": "com.b", "name": "Beta", "icon": "mdi:beta"},
]
)
entry.runtime_data = manager
_manager, after = await self._discover(ha, entry)
note = after["description_placeholders"]["discovery"]
assert note.startswith("2 apps found on the Shield")
assert "updated" in note
assert manager.refresh_calls == [True] # explicit force, exactly once
async def test_finding_nothing_says_the_rows_are_kept(self, ha):
entry = make_entry(ha, options={"app_name_map": {"com.a": "Alpha"}})
entry.runtime_data = self._Manager([])
_manager, after = await self._discover(ha, entry)
note = after["description_placeholders"]["discovery"]
assert "no installed apps" in note and "keeps your" in note
async def test_without_a_manager_it_says_adb_is_not_set_up(self, ha):
entry = make_entry(ha, options={})
_manager, after = await self._discover(ha, entry)
note = after["description_placeholders"]["discovery"]
assert note == "ADB is not set up for this entry — nothing could be discovered."