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:
1 parent
b0adbd2dfd
commit
ca3fdec218
6 files changed
+1146
-74
No files matched your search
+125
-17
@@ -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"}
|
||||
|
||||
|
||||
Reference in new issue
Block a user