test(config-flow): drive the menus, pin the tables, probe real HA

The flow tests drove `async_step_init` as if it were the one form, so they
had to learn the menu: a shared helper picks the sub-screen that owns a
payload's keys out of the flow's own `menu_options` (no test re-implements
the routing), and a `save=True` flag walks to "Save and close" so the
assertions read as they did before.

New coverage for this task:

- the menu offers every sub-screen and every row has a handler named
  after it, with the rendered step_id equal to the row id (the invariant
  real HA enforces and the first draft broke);
- edits on one sub-screen are not lost by another, and a page that is
  never saved never writes the entry;
- the apps table: add / edit / remove a row, the icon column round-trips,
  the empty state renders without data, a blank field clears the map, and
  the suggested value is TEXT (the `[object Object]` layer);
- a receiver the selector offers `switch` for, stored and pre-filled;
- the setup menu's rows, their handlers and a connection+receiver merge.

tests/test_config_flow_real_ha.py is new and is the empirical half: it
drives the real `FlowManager`, real probatio validation and the real
frontend serialiser (`to_field_list` + `cv.custom_serializer`) — the call
the HTTP flow view makes — so the menu dispatch, the cleared-selector
shapes and the wire serialisation are established rather than assumed.
It skips when homeassistant is absent; CI runs it with:

    uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py

53 of the stub tests and 11 of the real-HA ones fail on the pre-fix tree.
This commit is contained in:
James Mackay committed 2026-10-12 07:17:41 +01:00
1 parent b0adbd2dfd
commit ca3fdec218
6 files changed
+1146 -74

No files matched your search

+125 -17
View File
@@ -27,6 +27,8 @@ from nsh.const import (
CONF_ANDROIDTV_ENTITY,
CONF_HOST,
CONF_PROTOCOL_ENABLED,
SCRIPT_COMMANDS,
SCRIPT_FIELD_PREFIX,
)
_STUB_NAMES = (
@@ -101,6 +103,17 @@ def _install_stubs() -> None:
"description_placeholders": dict(description_placeholders or {}),
}
def async_show_menu(self, *, step_id, menu_options, sort=False,
description_placeholders=None, errors=None):
# t56: the real MENU result shape — the flow manager dispatches the
# chosen `next_step_id` to `async_step_<choice>(None)`.
return {
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"errors": dict(errors or {}),
}
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data,
"options": options}
@@ -144,6 +157,17 @@ def _install_stubs() -> None:
"data_schema": data_schema,
}
def async_show_menu(self, *, step_id, menu_options, sort=False,
description_placeholders=None, errors=None):
# t56: the real MENU result shape — the flow manager dispatches the
# chosen `next_step_id` to `async_step_<choice>(None)`.
return {
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"errors": dict(errors or {}),
}
def async_create_entry(self, *, title, data):
return {"type": "create_entry", "title": title, "data": data}
@@ -157,6 +181,9 @@ def _install_stubs() -> None:
return schema
class OptionsFlowWithReload(_SuggestedMixin):
# t56: the MENU surface is shared with OptionsFlow, like async_show_form.
async_show_menu = OptionsFlow.async_show_menu
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
@@ -476,6 +503,82 @@ class TestMigrationDropsTheEntityKey:
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
_OPTIONS_SUBSTEP_FIELDS = {
"connection": {
CONF_HOST,
CONF_ADB_PORT,
CONF_ADB_ENABLED,
CONF_PROTOCOL_ENABLED,
"adb_store_app_info",
"logcat_events",
"adb_command_enabled",
"jellyfin_entity",
"power_entity",
},
"receiver": {"receiver_entity", "receiver_input"},
"apps": {"app_name_map", "app_icon_map"},
"scripts": {
"remote_command_script",
"channel_digit_delay",
"channel_send_enter",
"command_scripts",
*{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS},
},
}
def _options_step_of(key: str) -> str:
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
if key in fields:
return step
if key.startswith(SCRIPT_FIELD_PREFIX):
return "scripts"
raise AssertionError(f"no options sub-screen owns {key!r}")
async def _options_submit(flow, payload: dict, *, save=False):
"""Drive the t56 options MENU into the sub-screens owning `payload` keys.
Returns the last step result, so an error assertion reads the same as it did
when the page was a single form. With ``save=True`` the menu's "Save and
close" row is chosen afterwards, returning the create_entry result.
"""
grouped: dict[str, dict] = {}
for key, value in payload.items():
grouped.setdefault(_options_step_of(key), {})[key] = value
result = await flow.async_step_init(None)
for step, sub in grouped.items():
assert step in result["menu_options"], result["menu_options"]
result = await getattr(flow, f"async_step_{step}")(sub)
if save:
assert "finish" in result["menu_options"], result["menu_options"]
result = await flow.async_step_finish(None)
return result
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)
assert step in result["menu_options"], result["menu_options"]
return await getattr(flow, f"async_step_{step}")(None)
async def _setup_finish(flow, result):
"""Walk the t56 setup MENU to the entry, starting from a step result.
Chooses any pairing row it needs to (the flow's menu_options are the source
of truth) and then Finish, returning the create_entry result.
"""
while result["type"] == "menu":
choice = (
"finish"
if "finish" in result["menu_options"]
else result["menu_options"][0]
)
result = await getattr(flow, f"async_step_{choice}")(None)
return result
def _cmd_player(modules):
"""Standalone media_player entity via the command-routing harness."""
import test_command_routing as routing
@@ -496,7 +599,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
@@ -510,7 +613,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"})
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate"})
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
@@ -540,7 +643,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"})
result = await _options_submit(flow, {"app_name_map": "p=Name|has spaces"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"}
@@ -562,11 +665,11 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init(
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
result = await _options_submit(
flow, {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
)
assert result["type"] == "form"
assert result["type"] == "form" # the Apps sub-screen re-rendered
assert result["errors"] == {"base": "invalid_app_map"}
assert "app_name_map" not in flow._options # nothing half-stored
@@ -592,7 +695,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True})
await _options_submit(flow, {"channel_digit_delay": "0.3", "channel_send_enter": True})
assert flow._options["channel_digit_delay"] == 0.3
assert flow._options["channel_send_enter"] is True
@@ -603,7 +706,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"channel_digit_delay": "2.0"})
result = await _options_submit(flow, {"channel_digit_delay": "2.0"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"}
@@ -614,7 +717,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"channel_digit_delay": "slow"})
result = await _options_submit(flow, {"channel_digit_delay": "slow"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"}
@@ -1161,12 +1264,14 @@ class TestAppsMapOptionsEditor:
async def test_map_text_stored_as_dict(self, tmp_path):
flow = self._options_flow(tmp_path, options={})
result = await flow.async_step_init(
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"}
result = await _options_submit(
flow,
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"},
save=True,
)
assert result["type"] == "create_entry"
assert flow._options["app_name_map"] == {
assert result["data"]["app_name_map"] == {
"ar.tvplayer.tv": "TiviMate",
"com.plex.hdx": "Plex",
}
@@ -1174,7 +1279,7 @@ class TestAppsMapOptionsEditor:
async def test_invalid_map_shows_form_error(self, tmp_path):
flow = self._options_flow(tmp_path, options={})
result = await flow.async_step_init({"app_name_map": "no equals sign here"})
result = await _options_submit(flow, {"app_name_map": "no equals sign here"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"}
@@ -1184,10 +1289,10 @@ class TestAppsMapOptionsEditor:
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
)
result = await flow.async_step_init({"app_name_map": " "})
result = await _options_submit(flow, {"app_name_map": " "}, save=True)
assert result["type"] == "create_entry"
assert flow._options["app_name_map"] == {}
assert result["data"]["app_name_map"] == {}
async def test_existing_map_prefills_the_text_field(self, tmp_path):
flow = self._options_flow(
@@ -1195,10 +1300,13 @@ class TestAppsMapOptionsEditor:
)
# first visit: the form carries the serialized current map as the
# field default
result = await flow.async_step_init(None)
# field default — NEVER the dict (that is the [object Object] bug)
result = await _options_open(flow, "apps")
assert result["type"] == "form"
fields = getattr(result["data_schema"], "schema", result["data_schema"])
suggested = fields.get("__suggested__", {})
assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate"
# the editor carried the existing map through the defaults path
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}