fix(config_flow): F1 — a discoverable later pairing path (Reconfigure) and truthful skip copy (t35 F1/F1b)
"Continue without protocol pairing" switched the transport off for good: the options flow carries no protocol fields, so nothing in the UI could ever turn it back on, and the skip copy promised nothing about it (t34 F1). The entry now answers Home Assistant's Reconfigure action: * async_step_reconfigure (audit G5) seeds the flow from the entry (title, adb host/port/enabled, androidtv entity for the host fallback) and re-runs the pairing step — the address is editable, the Shield shows a PIN, and _finish_reconfigure persists protocol_enabled=True plus the address that just paired (so the transport no longer depends on the ADB fallback), adopts the paired MAC when it differs, and updates + reloads the entry. HA derives the "reconfigure_successful" abort from the flow source. A Reconfigure visit that clicks skip aborts honestly with "reconfigure_skipped" (entry untouched), and an entry that is gone aborts with "reconfigure_entry_missing". * the skip data_description now states the truth — the transport stays off until you pair it — and names the later path (the integration's entry menu > Reconfigure). * a host corrected on the pairing form now DROPS the session started against the old address and restarts pairing there, so the typed address is authoritative instead of being paired against the stale one. * the success path no longer runs _abort_if_unique_id_configured for a flow that already owns an entry: core looks the domain up by unique_id, so re-pairing an entry aborted on itself as "already_configured" (this also unblocked reauth). The fresh-setup path is unchanged. * adjacent fix while wiring the same call: _finish_reauth passed `options_updates`, which core's async_update_reload_and_abort does not accept (the kwarg is `options`, REPLACE) — the reauth path would have raised TypeError in production. Both paths now pass `options` and the test stubs mirror the real signature. Tests: tests/test_config_flow_ux.py::TestProtocolReconfigure drives reconfigure from a protocol-disabled entry through to updated options (transport on, host persisted, other options kept, MAC adopted, reload recorded), a corrected host reopening the PIN step against the new address, the skip abort, and the missing-entry abort; TestCopyContract gained the F1b copy pin. Against HEAD's config_flow.py all four reconfigure tests fail (no async_step_reconfigure). Verified: uvx --with pytest-asyncio pytest tests -q → 281 passed, 1 skipped; uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 282 passed; uvx ruff check custom_components tests → clean.
This commit is contained in:
1 parent
7a0152e6b5
commit
e11dccf2c8
6 files changed
+255
-10
No files matched your search
@@ -120,11 +120,42 @@ def _install_stubs() -> None:
|
||||
def _get_reauth_entry(self, entry=None):
|
||||
return entry
|
||||
|
||||
def _get_reconfigure_entry(self, entry=None):
|
||||
"""Real HA reads the entry from the flow context (SOURCE_RECONFIGURE)."""
|
||||
if entry is not None:
|
||||
return entry
|
||||
return self.hass.config_entries.async_get_entry(
|
||||
(getattr(self, "context", None) or {}).get("entry_id")
|
||||
)
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Real HA exposes the flow's source through the context."""
|
||||
return (getattr(self, "context", None) or {}).get("source")
|
||||
|
||||
def async_update_reload_and_abort(self, entry, **kwargs):
|
||||
"""Real-HA shape: update, schedule the reload, then abort.
|
||||
|
||||
``options`` REPLACES the entry's options (core has no
|
||||
``options_updates``); the merge form is still accepted so the older
|
||||
reauth call and this one can share the stub. HA picks the abort
|
||||
reason from the flow source unless one is passed.
|
||||
"""
|
||||
for key, value in (kwargs.get("options") or {}).items():
|
||||
entry.options[key] = value
|
||||
for key, value in (kwargs.get("options_updates") or {}).items():
|
||||
entry.options[key] = value
|
||||
if kwargs.get("unique_id"):
|
||||
entry.unique_id = kwargs["unique_id"]
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
reason = kwargs.get("reason")
|
||||
if reason is None:
|
||||
reason = (
|
||||
"reconfigure_successful"
|
||||
if self.source == "reconfigure"
|
||||
else "reauth_successful"
|
||||
)
|
||||
return self.async_abort(reason=reason)
|
||||
|
||||
class OptionsFlow:
|
||||
def async_show_form(
|
||||
@@ -423,7 +454,6 @@ class TestVoluptuousMirrorContract:
|
||||
with pytest.raises(AssertionError):
|
||||
vol_mirror.declared_fields({CONF_ADB_HOST: str})
|
||||
|
||||
|
||||
def test_marker_equality_is_field_only_like_voluptuous(self):
|
||||
"""t35 F2: real ``Marker.__eq__`` is ``self.schema == other``.
|
||||
|
||||
@@ -874,6 +904,123 @@ class TestNoDeadEndPairing:
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
|
||||
|
||||
|
||||
class TestProtocolReconfigure:
|
||||
"""F1: an entry whose protocol transport is off can pair LATER (audit G5)."""
|
||||
|
||||
def _entry(self, **overrides):
|
||||
options = {
|
||||
CONF_PROTOCOL_ENABLED: False, # the "continue without pairing" outcome
|
||||
CONF_PROTOCOL_HOST: "",
|
||||
CONF_ADB_ENABLED: True,
|
||||
CONF_ADB_HOST: "10.0.0.9",
|
||||
CONF_ADB_PORT: 5555,
|
||||
}
|
||||
options.update(overrides)
|
||||
return SimpleNamespace(
|
||||
entry_id="e1", title="mdarcy", data={}, options=options,
|
||||
unique_id=None,
|
||||
)
|
||||
|
||||
def _reconfigure_flow(self, module, tmp_path, entry=None):
|
||||
flow, hass = _new_flow(module, tmp_path)
|
||||
if entry is not None:
|
||||
hass.config_entries.by_id["e1"] = entry
|
||||
flow.context = {"entry_id": "e1", "source": "reconfigure"}
|
||||
return flow, hass
|
||||
|
||||
async def test_reconfigure_pairs_a_protocol_disabled_entry(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Host editable → PIN step → options updated → entry reloaded."""
|
||||
module = _form_module()
|
||||
client = _patch_protocol(monkeypatch, module)
|
||||
entry = self._entry()
|
||||
flow, hass = self._reconfigure_flow(module, tmp_path, entry)
|
||||
|
||||
form = await flow.async_step_reconfigure()
|
||||
|
||||
assert form["step_id"] == "protocol_pair" # the PIN step
|
||||
assert client.pairing_started is True
|
||||
fields = vol_mirror.declared_fields(form["data_schema"])
|
||||
assert CONF_PROTOCOL_HOST in fields # the address is editable here
|
||||
assert CONF_PAIRING_CODE in fields
|
||||
|
||||
result = await flow.async_step_protocol_pair(
|
||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||
)
|
||||
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
# the transport is ON and carries the address that just paired
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is True
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == "10.0.0.9"
|
||||
# the rest of the entry's options survive the update
|
||||
assert entry.options[CONF_ADB_ENABLED] is True
|
||||
assert entry.options[CONF_ADB_HOST] == "10.0.0.9"
|
||||
assert entry.unique_id == FAKE_MAC
|
||||
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||
|
||||
async def test_reconfigure_host_correction_reopens_the_pin_step(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""The editable host wins: a corrected address starts a NEW session."""
|
||||
module = _form_module()
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
|
||||
first = FakeProtocolClient()
|
||||
_patch_protocol(monkeypatch, module, first)
|
||||
entry = self._entry()
|
||||
flow, _hass = self._reconfigure_flow(module, tmp_path, entry)
|
||||
await flow.async_step_reconfigure()
|
||||
assert first.pairing_started is True
|
||||
|
||||
second = FakeProtocolClient()
|
||||
monkeypatch.setattr(
|
||||
ShieldConnectionManager, "build_protocol_client", lambda *a, **k: second
|
||||
)
|
||||
form = await flow.async_step_protocol_pair(
|
||||
{CONF_PROTOCOL_HOST: "10.0.0.77", CONF_PAIRING_CODE: "0000"}
|
||||
)
|
||||
|
||||
assert form["step_id"] == "protocol_pair" # asks for the NEW code
|
||||
assert first.disconnects == 1 # the stale session was dropped
|
||||
assert second.pairing_started is True # pairing followed the new address
|
||||
assert first.finished_codes == [] # the stale code was never used
|
||||
|
||||
result = await flow.async_step_protocol_pair(
|
||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||
)
|
||||
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == "10.0.0.77"
|
||||
|
||||
async def test_reconfigure_skip_leaves_the_entry_untouched(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
module = _form_module()
|
||||
_patch_protocol(monkeypatch, module)
|
||||
entry = self._entry()
|
||||
flow, hass = self._reconfigure_flow(module, tmp_path, entry)
|
||||
await flow.async_step_reconfigure()
|
||||
|
||||
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
|
||||
|
||||
assert result == {"type": "abort", "reason": "reconfigure_skipped"}
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is False # unchanged
|
||||
assert hass.config_entries.reloaded_entry_ids == []
|
||||
|
||||
async def test_reconfigure_without_a_linked_entry_aborts(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
module = _form_module()
|
||||
_patch_protocol(monkeypatch, module)
|
||||
flow, _hass = self._reconfigure_flow(module, tmp_path, entry=None)
|
||||
|
||||
result = await flow.async_step_reconfigure()
|
||||
|
||||
assert result == {"type": "abort", "reason": "reconfigure_entry_missing"}
|
||||
|
||||
|
||||
class TestCopyContract:
|
||||
"""F-C: plain, self-consistent copy with no unreachable-step pointers."""
|
||||
|
||||
@@ -889,6 +1036,15 @@ class TestCopyContract:
|
||||
assert "host" in text, key
|
||||
assert "discovered" in text, key
|
||||
|
||||
def test_protocol_skip_copy_names_the_later_pairing_path(self):
|
||||
"""F1b: the transport stays OFF until paired, and the way back is named."""
|
||||
for file in (_strings(), _en_strings()):
|
||||
text = file["config"]["step"]["protocol_pair"]["data_description"][
|
||||
"skip_pairing"
|
||||
]
|
||||
assert "stays off until you pair it" in text
|
||||
assert "Reconfigure" in text
|
||||
|
||||
def test_every_error_and_abort_key_the_flow_raises_exists_in_both_files(self):
|
||||
source = (BASE / "config_flow.py").read_text(encoding="utf-8")
|
||||
raised = set(re.findall(r'errors\["base"\] = "([a-z_]+)"', source))
|
||||
|
||||
Reference in new issue
Block a user