fix(config-flow): the setup menu was unreachable under real HA (t57 UNIT 0a)

t61 F1 (BLOCKER) + F2 + F3 + F5, all reproduced against a real FlowManager
before fixing. The OPTIONS flow was verified good and is untouched by this
commit; the SETUP flow was broken in three independent ways.

F1 — `_show_setup_menu` passed `errors=` to `async_show_menu`, which has no
such parameter (HA 2026.10.1: step_id / menu_options / sort /
description_placeholders). Every path that went near the menu therefore
raised `TypeError` the moment the first form was submitted or ADB pairing
was skipped. The path map, driven end to end: FOUR of the five ordinary
paths died before create_entry ("ADB skip", "ADB paired + protocol off",
"ADB skip + protocol skip", "pair ADB"); only "protocol only" survived, and
it never showed the menu — so a new entry could only be created on that one
path and the sub-screens were unreachable.

F2 — the menu's rows broke the rule this repo documents: HA dispatches a row
to `async_step_<row id>` AND dispatches a submitted form by its OWN step_id,
so a row named `adb` with a handler called `async_step_adb_pair` is an
UnknownStep, and a row named `receiver` rendering `step_id="setup_receiver"`
is a form that cannot be submitted. The rows are now their handler names
(`receiver`, `connection`, `adb_pair`, `protocol_pair`, `finish`) and the
two sub-screens render their row id. There is also a `_paired_adb` guard
now, so a completed ADB pairing stops offering that row — the rule the
protocol row already followed.

F3 — both menus keyed their row labels under a bare `menu`, which core does
not read (56 core strings files use `menu_options`), so every row fell back
to its raw id in the browser. Renamed in both strings files.

F5 — the per-command script merge is field-wise now: a `script_<command>`
key that is present and empty removes that command, one with a value sets
it, and a key the submission does not carry is untouched — the same rule as
every other option. The wholesale rebuild depended on the page always being
complete, so a partial submission silently wiped the commands it omitted.

THE SYSTEMIC HALF (F1), closed as asked: `ha_api_drift.HA_CALL_OWNERS` now
covers the flow-display APIs (`async_show_menu`, `async_show_progress`,
`async_show_progress_done`, plus the existing form/abort/entry owners), so
the drift engine resolves their REAL signatures and flags an unknown
keyword. `tests/test_ha_api_drift.py` carries a pre-fix source using
`errors=` to prove the new owner is non-vacuous. Every harness stub now
mirrors the real signature (no `errors` on a menu) and a stub-level test
pins that parameter set, so a stub cannot go back to accepting anything.
On the pre-fix tree the drift engine reports exactly this:

    config_flow.py:898: unknown-kwarg: async_show_menu() has no 'errors'
    parameter; real signature: (self, *, step_id=None, menu_options, sort,
    description_placeholders) -> _FlowResultT

TESTS: tests/test_config_flow_real_ha.py gains a SETUP section that drives
the real flow — the first form, the ADB skip, the pairing guard, EVERY menu
row (dispatch AND re-submittable step_id), an accepted sub-screen
submission, a full connection+receiver+finish walk merging into one entry,
the menu's pairing note, and the five-path map as a parametrised test.
11 of those fail on the pre-fix tree; the drift engine failure above is the
12th. The stub suite additionally pins the menu stub's signature and the
`menu_options` key in both strings files.

Also in this commit (t57 F4): the real-HA file claimed entity-valued pages
could not be driven there, which was false (it has always submitted
`switch.soundbar`). The claim is gone and the scripts page is driven through
SET (two scripts selected, real entity ids) → CLEAR (one selector emptied)
→ SAVE → REOPEN, so the SET half is pinned in the real flow and not only
against the selector objects.
This commit is contained in:
James Mackay committed 2026-10-12 07:39:01 +01:00
1 parent 5eefd0c6d0
commit 3a55519b74
10 files changed
+713 -208

No files matched your search

+102 -14
View File
@@ -18,6 +18,7 @@ stayed green while the form rendered blank.
"""
import asyncio
import inspect
import json
import re
import sys
@@ -108,8 +109,8 @@ 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):
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
description_placeholders=None):
# t56: the real result shape. The flow manager reads
# `next_step_id` from the MENU result and calls
# `async_step_<choice>(None)`, so the stub reports the menu and the
@@ -118,7 +119,9 @@ def _install_stubs() -> None:
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"errors": dict(errors or {}),
"description_placeholders": dict(
description_placeholders or {}
),
}
def async_create_entry(self, *, title, data, options=None):
@@ -200,15 +203,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):
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
description_placeholders=None):
# t56: the real result shape (data_entry_flow sets type "menu" from
# FlowResultType.MENU, which is a StrEnum — "menu" on the wire).
return {
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"errors": dict(errors or {}),
"description_placeholders": dict(
description_placeholders or {}
),
}
class _SuggestedMixin:
@@ -933,7 +938,7 @@ class TestNoDeadEndPairing:
# t56: the skip hands over to the setup MENU, and the unpaired protocol
# is still one row away — the escape hatch is intact, not a dead end
assert result["type"] == "menu"
assert "protocol" in result["menu_options"]
assert "protocol_pair" in result["menu_options"]
assert (await flow.async_step_protocol_pair(None))["step_id"] == "protocol_pair"
async def test_skip_protocol_after_skip_adb_creates_the_entry(
@@ -983,7 +988,7 @@ class TestNoDeadEndPairing:
# t56: a finished pairing returns to the setup menu (the just-paired
# transport is no longer offered), and the menu's Finish creates the entry
assert paired["type"] == "menu"
assert "protocol" not in paired["menu_options"]
assert "protocol_pair" not in paired["menu_options"]
assert client.finished_codes == [FAKE_PAIRING_CODE]
assert flow._unique_id == FAKE_MAC
@@ -1337,7 +1342,7 @@ class TestUnreachableShieldDuringPairing:
skipped = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
assert skipped["type"] == "menu"
assert "protocol" in skipped["menu_options"]
assert "protocol_pair" in skipped["menu_options"]
# choosing it re-renders the reachable-again pairing form with the
# unreachable error and both escapes, exactly as before
@@ -1463,14 +1468,30 @@ class TestCopyContract:
assert aborted <= set(file["config"]["abort"])
async def test_every_rendered_setup_field_has_a_label(self, tmp_path, monkeypatch):
"""t57: the setup flow's steps AND its menu rows carry labels.
The menu rows are read from ``menu_options`` — the key core uses — so a
label under a bare ``menu`` key (which never reaches the browser) fails
here rather than surfacing as a row called "connection" in the UI.
"""
module = _form_module()
_patch_protocol(monkeypatch, module)
flow, _hass = _new_flow(module, tmp_path)
await flow.async_step_user(
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True})
)
menu = await flow.async_step_menu(None)
rendered = {
"user": vol_mirror.declared_fields(
(await flow.async_step_user(None))["data_schema"]
),
"receiver": vol_mirror.declared_fields(
(await flow.async_step_receiver(None))["data_schema"]
),
"connection": vol_mirror.declared_fields(
(await flow.async_step_connection(None))["data_schema"]
),
"adb_pair": vol_mirror.declared_fields(
(await flow.async_step_adb_pair(None))["data_schema"]
),
@@ -1481,6 +1502,11 @@ class TestCopyContract:
for file in (_strings(), _en_strings()):
steps = file["config"]["step"]
menu_step = steps["menu"]
assert menu_step["description"]
assert "menu" not in menu_step, "setup menu uses a bare 'menu' key"
for row in menu["menu_options"]:
assert row in menu_step["menu_options"], f"setup menu row {row}"
for step, fields in rendered.items():
assert steps[step]["title"], step
assert steps[step]["description"], step
@@ -1513,9 +1539,11 @@ class TestCopyContract:
for file in (_strings(), _en_strings()):
menu_step = file["options"]["step"]["init"]
assert menu_step["description"]
# every row the menu offers has a label (HA renders these as names)
# every row the menu offers has a label. HA reads these from
# `menu_options` (the key 56 core integrations use) — under a bare
# `menu` key the rows fall back to raw ids in the browser (t57 F3).
for option in menu["menu_options"]:
assert option in menu_step.get("menu", {}), f"menu row {option}"
assert option in menu_step.get("menu_options", {}), f"menu row {option}"
steps = file["options"]["step"]
for step, fields in rendered.items():
@@ -2323,19 +2351,79 @@ class TestSetupMenu:
async def test_every_setup_row_has_a_handler_named_after_it(
self, tmp_path, monkeypatch
):
"""t57 F2: the rule HA enforces, checked for EVERY row.
Two halves, because a menu can break in two ways: a row with no handler
(UnknownStep the moment it is clicked) and a sub-screen whose rendered
``step_id`` is not its row id (the form cannot be submitted, because HA
dispatches a submission by its OWN step_id). The pre-fix tree failed
both — rows named ``adb``/``protocol`` with handlers called
``adb_pair``/``protocol_pair``, and forms rendering ``setup_receiver``.
"""
module = _form_module()
_patch_adb(monkeypatch, module)
flow, _hass = _new_flow(module, tmp_path)
# both transports on, so the MENU offers the pairing rows too
await flow.async_step_user(
_user_input(**{CONF_ADB_ENABLED: False, CONF_PROTOCOL_ENABLED: False})
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True})
)
menu = await flow.async_step_menu(None)
for row in menu["menu_options"]:
assert hasattr(flow, f"async_step_{row}"), row
assert list(menu["menu_options"]) == [
"receiver",
"connection",
"adb_pair",
"protocol_pair",
"finish",
]
# ...and the row ids never include the menu itself, which would loop
assert "menu" not in menu["menu_options"]
for row in menu["menu_options"]:
assert hasattr(flow, f"async_step_{row}"), row
if row == "finish":
continue
form = await getattr(flow, f"async_step_{row}")(None)
assert form["type"] == "form"
assert form["step_id"] == row, (row, form["step_id"])
async def test_the_menu_stub_mirrors_the_real_signature(self):
"""t57 UNIT 0a systemic half: the stub must not accept anything.
The production bug was ``async_show_menu(..., errors=...)`` — a TypeError
under real HA — and the stub suite could not see it because every stub
took an ``errors`` keyword the real method does not have. The stub's
parameter set is pinned here, and the real-HA half is in
``ha_api_drift.HA_CALL_OWNERS`` (with a pre-fix source in
``tests/test_ha_api_drift.py`` proving it is non-vacuous).
"""
module = _form_module()
flow = module.NvidiaShieldConfigFlow.__new__(module.NvidiaShieldConfigFlow)
params = set(
inspect.signature(type(flow).async_show_menu).parameters
)
assert params == {
"self",
"step_id",
"menu_options",
"sort",
"description_placeholders",
}, "the menu stub drifted from HA's real signature"
options_flow = module.NvidiaShieldOptionsFlow.__new__(
module.NvidiaShieldOptionsFlow
)
assert "errors" not in inspect.signature(
type(options_flow).async_show_menu
).parameters
async def test_both_menus_key_their_row_labels_under_menu_options(self, tmp_path):
"""t57 F3: a label under the wrong key never reaches the browser."""
for file in (_strings(), _en_strings()):
for section, step in (("config", "menu"), ("options", "init")):
block = file[section]["step"][step]
assert "menu" not in block, f"{section}.step.{step} uses a bare 'menu' key"
assert block["menu_options"], f"{section}.step.{step} has no row labels"
async def test_connection_and_receiver_edits_both_land_in_the_entry(
self, tmp_path, monkeypatch