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

+24
View File
@@ -80,6 +80,30 @@ def install_selector_faces(selector_module) -> None:
selector_module.SelectSelector = lambda *args, **kwargs: None
selector_module.SelectSelectorConfig = _Config
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
# t64: the apps table is a MULTILINE text field, and the row editor picks an
# app out of a select whose options are label/value pairs. Both are real HA
# shapes; ``SelectOptionDict`` is a TypedDict there (a plain dict at runtime).
selector_module.SelectOptionDict = dict
selector_module.TextSelector = TextSelectorStub
selector_module.TextSelectorConfig = _Config
class TextSelectorStub:
"""Stand-in for HA's TextSelector: accepts a string, rejects anything else.
Real HA validates with ``probatio.Schema(str)`` (so ``None`` and numbers are
Invalid) and carries ``multiline`` as a CONFIG flag — the flag is what makes
the browser render a textarea, which is the t64 fix, so it is inspectable
here the same way the entity selectors' domains are.
"""
def __init__(self, config=None):
self.config = config
def __call__(self, data):
if not isinstance(data, str):
raise ValueError("Value is not a string")
return data
class MediaPlayerState(enum.StrEnum):
+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."
+339 -25
View File
@@ -36,6 +36,7 @@ from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_COMMAND_SCRIPTS,
@@ -1526,15 +1527,27 @@ class TestCopyContract:
flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield")
flow.hass = FakeHass(str(tmp_path))
async def walk(step: str) -> dict:
"""The rendered screens from ``step`` down, keyed by the i18n step id."""
opened = await getattr(flow, f"async_step_{step}")(None)
if opened["type"] == "menu":
nested: dict = {step: ("menu", opened)}
for row in opened["menu_options"]:
if row in OPTIONS_ACTION_ROWS:
continue
nested.update(await walk(row))
return nested
return {step: ("form", opened)}
menu = await flow.async_step_init(None)
# the rendered sub-screens, keyed by the i18n step id the menu row maps to
rendered = {
step: vol_mirror.declared_fields(
(await getattr(flow, f"async_step_{step}")(None))["data_schema"]
)
for step in menu["menu_options"]
if step not in ("finish", "discover")
}
# t64: the Apps sub-screen is itself a MENU of row actions, so the walk
# goes as deep as the screen does — a nested step is checked exactly like
# a top-level one, and every row a nested menu offers must have a label.
rendered: dict = {}
for step in menu["menu_options"]:
if step in OPTIONS_ACTION_ROWS:
continue
rendered.update(await walk(step))
for file in (_strings(), _en_strings()):
menu_step = file["options"]["step"]["init"]
@@ -1546,12 +1559,18 @@ class TestCopyContract:
assert option in menu_step.get("menu_options", {}), f"menu row {option}"
steps = file["options"]["step"]
for step, fields in rendered.items():
for step, (kind, opened) in rendered.items():
# a sub-screen's i18n step key is its menu row id (HA dispatches
# a submitted form by its own step_id, so the two must match)
assert steps[step]["title"], step
assert steps[step]["description"], step
for field in fields:
if kind == "menu":
for row in opened["menu_options"]:
assert row in steps[step].get("menu_options", {}), (
f"{step} row {row} has no label"
)
continue
for field in vol_mirror.declared_fields(opened["data_schema"]):
assert field in steps[step]["data"], f"{step}.{field} has no label"
@@ -1586,7 +1605,9 @@ OPTIONS_SUBSTEP_FIELDS = {
CONF_ADB_COMMAND_ENABLED,
},
"receiver": {"receiver_entity", "receiver_input"},
"apps": {"app_name_map", "app_icon_map"},
# t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu
# of row actions), so the field that owns the map is `apps_text`.
"apps_text": {"app_name_map", "app_icon_map"},
"scripts": {
CONF_REMOTE_COMMAND_SCRIPT,
CONF_CHANNEL_DIGIT_DELAY,
@@ -1595,6 +1616,24 @@ OPTIONS_SUBSTEP_FIELDS = {
},
}
#: t64: the Apps editor's rows live one level deeper — the Apps sub-screen is a
#: MENU of row actions, exactly as the browser navigates it: open the parent row,
#: then the row the user picked.
OPTIONS_NESTED_STEPS = {
"apps_text": "apps",
"apps_add": "apps",
"apps_edit": "apps",
"apps_edit_form": "apps",
"apps_remove": "apps",
"apps_remove_confirm": "apps",
}
#: Menu rows that ACT rather than render a form: HA dispatches them with no input,
#: so there is no data_schema and no field labels to check.
OPTIONS_ACTION_ROWS = frozenset(
{"discover", "finish", "menu_again", "apps_remove_yes", "apps_remove_no"}
)
def _options_substep_of(key: str) -> str:
for step, fields in OPTIONS_SUBSTEP_FIELDS.items():
@@ -1619,11 +1658,20 @@ async def _options_submit(flow, payload: dict, *, save=False):
grouped.setdefault(_options_substep_of(key), {})[key] = value
result = await flow.async_step_init(None)
for step, sub_payload in grouped.items():
parent = OPTIONS_NESTED_STEPS.get(step)
if parent is not None:
assert parent in result["menu_options"], (
f"{parent} is not offered by the menu: {result['menu_options']}"
)
result = await getattr(flow, f"async_step_{parent}")(None)
assert step in result["menu_options"], (
f"{step} is not offered by the menu: {result['menu_options']}"
)
result = await getattr(flow, f"async_step_{step}")(sub_payload)
if save:
# t64: a nested row (the apps table) returns to the APPS menu, so walk
# back the way the "Back to the main menu" row does before committing.
result = await flow.async_step_menu_again()
assert "finish" in result["menu_options"], result["menu_options"]
result = await flow.async_step_finish(None)
return result
@@ -1642,6 +1690,10 @@ def _all_script_fields(value):
async def _options_open(flow, step: str):
"""Choose a sub-screen from the menu and open its form."""
result = await flow.async_step_init(None)
parent = OPTIONS_NESTED_STEPS.get(step)
if parent is not None:
assert parent in result["menu_options"], result["menu_options"]
result = await getattr(flow, f"async_step_{parent}")(None)
assert step in result["menu_options"], result["menu_options"]
return await getattr(flow, f"async_step_{step}")(None)
@@ -2036,16 +2088,22 @@ class TestOptionsMenu:
assert hasattr(flow, f"async_step_{row}"), row
if row in action_rows:
continue
form = await getattr(flow, f"async_step_{row}")(None)
assert form["type"] == "form"
assert form["step_id"] == row, form["step_id"]
opened = await getattr(flow, f"async_step_{row}")(None)
# a sub-screen is a FORM — or, for Apps (t64), a MENU of row actions
assert opened["type"] in ("form", "menu"), (row, opened)
assert opened["step_id"] == row, opened["step_id"]
if opened["type"] == "menu":
# a nested menu's rows are dispatched by name as well
for nested in opened["menu_options"]:
assert hasattr(flow, f"async_step_{nested}"), (row, nested)
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"): # sub-screens
opened = await _options_open(flow, row)
assert opened["type"] == "form"
# a sub-screen is a form, except Apps: a MENU of row actions (t64)
assert opened["type"] in ("form", "menu"), (row, opened)
# a submission that changes nothing still returns to the menu
back = await getattr(flow, f"async_step_{row}")({})
assert back["type"] == "menu", (row, back)
@@ -2090,7 +2148,7 @@ class TestAppsTable:
{"app_name_map": {"ar.tvplayer.tv": "TiviMate", "com.plex.hdx": "Plex"}},
)
form = await _options_open(flow, "apps")
form = await _options_open(flow, "apps_text")
suggested = form["data_schema"]["__suggested__"]
assert isinstance(suggested["app_name_map"], str)
@@ -2111,7 +2169,7 @@ class TestAppsTable:
# re-open: the row (icon included) comes back in the text field
reopened = _options_flow(tmp_path, result["data"])
form = await _options_open(reopened, "apps")
form = await _options_open(reopened, "apps_text")
assert (
form["data_schema"]["__suggested__"]["app_name_map"]
== "com.plex.hdx=Plex|http://icons.local/plex.png"
@@ -2140,10 +2198,10 @@ class TestAppsTable:
async def test_the_empty_state_renders_without_data_or_a_crash(self, tmp_path):
flow = _options_flow(tmp_path, options={})
form = await _options_open(flow, "apps")
form = await _options_open(flow, "apps_text")
assert form["type"] == "form"
assert form["step_id"] == "apps"
assert form["step_id"] == "apps_text"
assert form["errors"] == {}
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
# no discovery data yet (there is no manager in this harness), so the
@@ -2195,7 +2253,7 @@ class TestDiscoveryFeedsTheAppsTable:
],
)
form = await _options_open(flow, "apps")
form = await _options_open(flow, "apps_text")
text = form["data_schema"]["__suggested__"]["app_name_map"]
lines = text.split("\n")
@@ -2216,7 +2274,7 @@ class TestDiscoveryFeedsTheAppsTable:
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
)
form = await _options_open(flow, "apps")
form = await _options_open(flow, "apps_text")
assert form["data_schema"]["__suggested__"]["app_name_map"] == (
"com.netflix.ninja=Netflix"
@@ -2227,7 +2285,7 @@ class TestDiscoveryFeedsTheAppsTable:
tmp_path, {"app_name_map": {"com.a": "A"}}, [], connected=False
)
form = await _options_open(flow, "apps")
form = await _options_open(flow, "apps_text")
assert form["data_schema"]["__suggested__"]["app_name_map"] == "com.a=A"
@@ -2235,7 +2293,7 @@ class TestDiscoveryFeedsTheAppsTable:
"""A form render must never sweep — discovery is an explicit action."""
flow, manager = self._flow_with(tmp_path, {}, [])
await _options_open(flow, "apps")
await _options_open(flow, "apps_text")
await flow.async_step_init(None)
assert manager.refresh_calls == []
@@ -2292,15 +2350,271 @@ class TestDiscoveryFeedsTheAppsTable:
{},
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
)
form = await _options_open(flow, "apps")
form = await _options_open(flow, "apps_text")
submitted = form["data_schema"]["__suggested__"]["app_name_map"]
await flow.async_step_apps({CONF_APP_NAME_MAP: submitted})
await flow.async_step_apps_text({CONF_APP_NAME_MAP: submitted})
result = await flow.async_step_finish(None)
assert result["data"][CONF_APP_NAME_MAP] == {"com.netflix.ninja": "Netflix"}
#: t64: the apps ROW EDITOR's field names. They are UI only — a projection of one
#: ``package=name[|icon]`` row — so they are asserted against the flow module's own
#: constants (below) instead of being trusted to match.
APP_PACKAGE = "app_package"
APP_NAME = "app_name"
APP_ICON = "app_icon"
class TestAppsRowEditor:
"""t64: HA has no table selector, so a row is edited one step at a time."""
def _flow(self, tmp_path, options=None):
return _options_flow(tmp_path, options or {})
async def test_the_field_is_the_multiline_text_selector(self, tmp_path):
"""The mechanical cause of the live report, pinned without HA.
A selector-less ``str`` field renders as a SINGLE-LINE input, so the map
arrived flattened with nowhere to add a row. The field must carry the text
selector, and it must be the MULTILINE one — the flag the frontend reads
to render a textarea instead of a one-line box.
"""
flow = self._flow(tmp_path)
form = await _options_open(flow, "apps_text")
module = _form_module()
assert (
module.APP_FIELD_PACKAGE,
module.APP_FIELD_NAME,
module.APP_FIELD_ICON,
) == (APP_PACKAGE, APP_NAME, APP_ICON)
field = form["data_schema"][CONF_APP_NAME_MAP]
assert isinstance(field, entity_stublib.TextSelectorStub), field
assert field.config.multiline is True
async def test_the_dropdown_lists_detected_apps_too_and_labels_them(self, tmp_path):
"""t64: the entry point is the DETECTED list, named where a name is known.
The dropdown is what the user picks from, so it carries the saved rows AND
the apps discovery found; an app whose device label is missing is labelled
"(no name yet)" rather than with its own id twice, and the icon column is
never prefilled from the device icon (that is an ``mdi:`` name, which the
column does not accept).
"""
flow = _options_flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
flow.config_entry.runtime_data = TestDiscoverSaysWhatHappened._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"},
]
)
placeholder = flow._apps_placeholders()["apps"]
assert placeholder.startswith("3 apps known — ")
assert "Kodi (org.xbmc.kodi) [detected]" in placeholder
assert "com.unnamed.tv (no name yet) [detected]" in placeholder
assert "Alpha (com.a)" in placeholder # the saved row keeps the user's name
choose = await flow.async_step_apps_edit(None)
assert choose["step_id"] == "apps_edit"
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.unnamed.tv"})
suggested_values = form["data_schema"]["__suggested__"]
assert suggested_values[APP_NAME] == "" # no device label: nothing invented
assert suggested_values[APP_ICON] == "" # never the mdi: device icon
await flow.async_step_apps_edit_form(
{APP_NAME: "Unnamed TV", APP_ICON: "http://icons.local/u.png"}
)
assert flow._options_dict[CONF_APP_NAME_MAP] == {
"com.a": "Alpha",
"com.unnamed.tv": "Unnamed TV",
}
async def test_only_the_rows_that_can_act_are_offered(self, tmp_path):
empty = self._flow(tmp_path)
menu = await empty.async_step_apps(None)
assert list(menu["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
assert empty._apps_placeholders()["apps"].startswith("no apps known yet")
flow = self._flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
menu = await flow.async_step_apps(None)
assert list(menu["menu_options"]) == [
"apps_text",
"apps_add",
"apps_edit",
"apps_remove",
"menu_again",
]
assert flow._apps_placeholders()["apps"] == "1 app known — Alpha (com.a)"
async def test_add_writes_one_row_and_leaves_the_others_alone(self, tmp_path):
flow = self._flow(
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_HOST: "10.0.0.9"}
)
result = await flow.async_step_apps_add(
{
APP_PACKAGE: "com.b",
APP_NAME: "Beta",
APP_ICON: "http://icons.local/b.png",
}
)
assert result["type"] == "menu" # back at the editor, nothing committed yet
assert flow._options_dict[CONF_APP_NAME_MAP] == {
"com.a": "Alpha",
"com.b": "Beta",
}
assert flow._options_dict[CONF_APP_ICON_MAP] == {
"com.b": "http://icons.local/b.png"
}
assert flow._options_dict[CONF_HOST] == "10.0.0.9" # another screen's field
async def test_an_invalid_row_is_refused_and_stores_nothing(self, tmp_path):
flow = self._flow(tmp_path)
form = await flow.async_step_apps_add(
{APP_PACKAGE: "has spaces", APP_NAME: "Nope", APP_ICON: ""}
)
assert form["type"] == "form"
assert form["step_id"] == "apps_add"
assert form["errors"] == {"base": "invalid_app_row"}
assert CONF_APP_NAME_MAP not in flow._options_dict
async def test_edit_prefills_the_stored_row_and_changes_only_it(self, tmp_path):
flow = self._flow(
tmp_path,
{
CONF_APP_NAME_MAP: {"com.a": "Alpha", "com.b": "Beta"},
CONF_APP_ICON_MAP: {"com.b": "http://icons.local/b.png"},
},
)
choose = await flow.async_step_apps_edit(None)
assert choose["step_id"] == "apps_edit"
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.b"})
suggested_values = form["data_schema"]["__suggested__"]
assert suggested_values[APP_NAME] == "Beta"
assert suggested_values[APP_ICON] == "http://icons.local/b.png"
result = await flow.async_step_apps_edit_form(
{APP_NAME: "Beta (living room)", APP_ICON: "http://icons.local/b2.png"}
)
assert result["type"] == "menu"
assert flow._options_dict[CONF_APP_NAME_MAP] == {
"com.a": "Alpha",
"com.b": "Beta (living room)",
}
assert flow._options_dict[CONF_APP_ICON_MAP] == {
"com.b": "http://icons.local/b2.png"
}
async def test_remove_needs_the_confirmation_row(self, tmp_path):
flow = self._flow(
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_APP_ICON_MAP: {}}
)
choose = await flow.async_step_apps_remove(None)
assert choose["step_id"] == "apps_remove"
confirm = await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
assert confirm["type"] == "menu"
assert confirm["step_id"] == "apps_remove_confirm"
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
kept = await flow.async_step_apps_remove_no(None)
assert kept["type"] == "menu"
assert flow._options_dict[CONF_APP_NAME_MAP] == {"com.a": "Alpha"}
await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
dropped = await flow.async_step_apps_remove_yes(None)
assert dropped["type"] == "menu"
assert flow._options_dict[CONF_APP_NAME_MAP] == {}
assert flow._options_dict[CONF_APP_ICON_MAP] == {}
class TestDiscoverSaysWhatHappened:
"""t64: "Discover ... seems to load then do nothing" must be impossible."""
class _Manager:
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()
def _flow_with(self, tmp_path, manager):
flow = _options_flow(tmp_path, {})
flow.config_entry.runtime_data = manager
return flow
async def test_no_manager_says_adb_is_not_set_up(self, tmp_path):
flow = _options_flow(tmp_path, {})
await flow.async_step_discover(None)
assert flow._menu_placeholders()["discovery"] == (
"ADB is not set up for this entry — nothing could be discovered."
)
async def test_finding_apps_reports_the_count_and_updates_the_table(
self, tmp_path
):
manager = self._Manager(
[{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"}]
)
flow = self._flow_with(tmp_path, manager)
result = await flow.async_step_discover(None)
assert result["type"] == "menu"
assert manager.refresh_calls == [True]
note = flow._menu_placeholders()["discovery"]
assert note.startswith("1 apps found on the Shield") # the t57 wording
assert "updated" in note
form = await _options_open(flow, "apps_text")
assert "org.xbmc.kodi=Kodi" in form["data_schema"]["__suggested__"][
CONF_APP_NAME_MAP
]
async def test_finding_nothing_says_the_saved_rows_stay(self, tmp_path):
flow = self._flow_with(tmp_path, self._Manager([]))
await flow.async_step_discover(None)
note = flow._menu_placeholders()["discovery"]
assert "no installed apps" in note and "keeps your" in note
async def test_a_failed_sweep_says_so_instead_of_nothing(self, tmp_path):
flow = self._flow_with(tmp_path, self._Manager([], connected=False, fail=True))
result = await flow.async_step_discover(None)
assert result["type"] == "menu" # no traceback, no dead end
note = flow._menu_placeholders()["discovery"]
assert "could not reach" in note and "unchanged" in note
class TestReceiverAcceptsASwitch:
"""t56: a soundbar that only knows on/off is a valid receiver."""
+25 -2
View File
@@ -524,7 +524,9 @@ _OPTIONS_SUBSTEP_FIELDS = {
"power_entity",
},
"receiver": {"receiver_entity", "receiver_input"},
"apps": {"app_name_map", "app_icon_map"},
# t64: the apps table lives in its OWN step now — the Apps sub-screen is a
# menu of row actions, so the field that owns the map is `apps_text`.
"apps_text": {"app_name_map", "app_icon_map"},
"scripts": {
"remote_command_script",
"channel_digit_delay",
@@ -534,6 +536,16 @@ _OPTIONS_SUBSTEP_FIELDS = {
},
}
#: t64: the Apps editor's rows live one level deeper (the Apps sub-screen is a
#: MENU of row actions), exactly as the browser navigates them.
_OPTIONS_NESTED_STEPS = {
"apps_text": "apps",
"apps_add": "apps",
"apps_edit": "apps",
"apps_edit_form": "apps",
"apps_remove": "apps",
"apps_remove_confirm": "apps",
}
def _options_step_of(key: str) -> str:
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
@@ -556,9 +568,16 @@ async def _options_submit(flow, payload: dict, *, save=False):
grouped.setdefault(_options_step_of(key), {})[key] = value
result = await flow.async_step_init(None)
for step, sub in grouped.items():
parent = _OPTIONS_NESTED_STEPS.get(step)
if parent is not None:
assert parent in result["menu_options"], result["menu_options"]
result = await getattr(flow, f"async_step_{parent}")(None)
assert step in result["menu_options"], result["menu_options"]
result = await getattr(flow, f"async_step_{step}")(sub)
if save:
# t64: a nested row returns to the APPS menu, so walk back to the main menu
# the way the "Back to the main menu" row does before committing.
result = await flow.async_step_menu_again()
assert "finish" in result["menu_options"], result["menu_options"]
result = await flow.async_step_finish(None)
return result
@@ -567,6 +586,10 @@ async def _options_submit(flow, payload: dict, *, save=False):
async def _options_open(flow, step: str):
"""Choose an options sub-screen from the menu and open its form."""
result = await flow.async_step_init(None)
parent = _OPTIONS_NESTED_STEPS.get(step)
if parent is not None:
assert parent in result["menu_options"], result["menu_options"]
result = await getattr(flow, f"async_step_{parent}")(None)
assert step in result["menu_options"], result["menu_options"]
return await getattr(flow, f"async_step_{step}")(None)
@@ -1309,7 +1332,7 @@ class TestAppsMapOptionsEditor:
# first visit: the form carries the serialized current map as the
# field default — NEVER the dict (that is the [object Object] bug)
result = await _options_open(flow, "apps")
result = await _options_open(flow, "apps_text")
assert result["type"] == "form"
fields = getattr(result["data_schema"], "schema", result["data_schema"])