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:
1 parent
5eefd0c6d0
commit
3a55519b74
10 files changed
+713
-208
No files matched your search
+102
-14
@@ -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
|
||||
|
||||
Reference in new issue
Block a user