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.
252 lines
8.4 KiB
Python
252 lines
8.4 KiB
Python
"""F1 regression: the adb_pair submit path calls
|
|
ShieldConnectionManager.test_connect with its declared signature
|
|
(host, port, key_path) — nothing extra, arity exact.
|
|
|
|
config_flow.py imports Home Assistant; it is exercised here against small
|
|
module stubs (import-only; no HA behaviour needed on this path) with the
|
|
actual ADB handshake mocked on the manager.
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
from unittest.mock import AsyncMock
|
|
|
|
import entity_stublib
|
|
import pytest
|
|
from conftest import _SHIELD_DIR, _load_module
|
|
from nsh.const import (
|
|
CONF_ADB_ENABLED,
|
|
CONF_ADB_PORT,
|
|
CONF_ADB_STORE_APP_INFO,
|
|
CONF_HOST,
|
|
CONF_JELLYFIN_ENTITY,
|
|
CONF_POWER_ENTITY,
|
|
CONF_RECEIVER_ENTITY,
|
|
CONF_RECEIVER_INPUT,
|
|
)
|
|
|
|
_KEY_PATH = "/tmp/nsh-test-adbkey"
|
|
|
|
|
|
_STUB_NAMES = (
|
|
"voluptuous",
|
|
"homeassistant",
|
|
"homeassistant.config_entries",
|
|
"homeassistant.core",
|
|
"homeassistant.helpers",
|
|
"homeassistant.helpers.entity_registry",
|
|
"homeassistant.helpers.device_registry",
|
|
"homeassistant.helpers.selector",
|
|
# Loaded execution runs beneath the stubs; never escape this file either.
|
|
"nsh.helpers",
|
|
"nsh.config_flow",
|
|
)
|
|
|
|
|
|
def _install_import_stubs() -> None:
|
|
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
|
|
if sys.modules.get("voluptuous") is not None:
|
|
return
|
|
|
|
vol = types.ModuleType("voluptuous")
|
|
vol.Required = lambda key, default=None: key
|
|
vol.Optional = lambda key, default=None: key
|
|
vol.Schema = lambda schema: schema
|
|
sys.modules["voluptuous"] = vol
|
|
|
|
ha = types.ModuleType("homeassistant")
|
|
sys.modules["homeassistant"] = ha
|
|
|
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
|
|
|
class ConfigFlow:
|
|
VERSION = 1
|
|
|
|
def __init_subclass__(cls, **kwargs):
|
|
kwargs.pop("domain", None) # class statement passes domain=DOMAIN
|
|
super().__init_subclass__(**kwargs)
|
|
|
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
|
return _form(step_id, errors or {})
|
|
|
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
|
description_placeholders=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),
|
|
"description_placeholders": dict(
|
|
description_placeholders or {}
|
|
),
|
|
}
|
|
|
|
def async_create_entry(self, *, title, data, options=None):
|
|
return {"type": "create_entry", "title": title, "data": data,
|
|
"options": options}
|
|
|
|
def _form(step_id, errors):
|
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
|
|
|
config_entries.ConfigFlow = ConfigFlow
|
|
class _BaseOptionsFlow:
|
|
pass
|
|
|
|
# t29 G3b: the current options-flow API surface on the local stub
|
|
class _SuggestedMixin:
|
|
def add_suggested_values_to_schema(self, schema, suggested):
|
|
schema_dict = getattr(schema, "schema", None) or (
|
|
schema if isinstance(schema, dict) else {}
|
|
)
|
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
|
return schema
|
|
|
|
class OptionsFlowWithReload(_SuggestedMixin):
|
|
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)
|
|
if entry_id is not None and scheduler is not None and hasattr(
|
|
scheduler, "async_schedule_reload"
|
|
):
|
|
scheduler.async_schedule_reload(entry_id)
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
config_entries.OptionsFlow = _BaseOptionsFlow
|
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
|
sys.modules["homeassistant.config_entries"] = config_entries
|
|
|
|
core = types.ModuleType("homeassistant.core")
|
|
core.HomeAssistant = object
|
|
core.callback = lambda fn: fn
|
|
sys.modules["homeassistant.core"] = core
|
|
|
|
helpers = types.ModuleType("homeassistant.helpers")
|
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
|
|
|
class _Registry:
|
|
entities: dict = {}
|
|
|
|
def async_get(self, entity_id):
|
|
return None
|
|
|
|
entity_registry.async_get = lambda hass: _Registry()
|
|
helpers.entity_registry = entity_registry
|
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
|
sys.modules["homeassistant.helpers"] = helpers
|
|
|
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
|
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
|
|
helpers.device_registry = device_registry
|
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
|
entity_stublib.install_selector_faces(selector)
|
|
sys.modules["homeassistant.helpers.selector"] = selector
|
|
|
|
|
|
class _Config:
|
|
config_dir = "/tmp/nsh-test-config"
|
|
|
|
|
|
class _FakeHass:
|
|
config = _Config
|
|
|
|
|
|
@staticmethod
|
|
async def async_add_executor_job(fn, *args):
|
|
return fn(*args)
|
|
|
|
|
|
def _config_flow_module():
|
|
_install_import_stubs()
|
|
if "nsh.helpers" not in sys.modules:
|
|
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
|
if "nsh.config_flow" not in sys.modules:
|
|
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
|
return sys.modules["nsh.config_flow"]
|
|
|
|
|
|
def _flow(user_input_host: str, monkeypatch=None):
|
|
module = _config_flow_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = _FakeHass
|
|
flow._user_data = {
|
|
"name": "Nvidia Shield",
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: user_input_host,
|
|
CONF_ADB_PORT: 5555,
|
|
CONF_JELLYFIN_ENTITY: "",
|
|
CONF_RECEIVER_ENTITY: "",
|
|
CONF_RECEIVER_INPUT: "",
|
|
CONF_POWER_ENTITY: "",
|
|
CONF_ADB_STORE_APP_INFO: True,
|
|
}
|
|
if monkeypatch is not None:
|
|
# Patch module-level resolution — the call site is a plain global.
|
|
monkeypatch.setattr(
|
|
module, "ensure_adb_key", lambda _config_dir: _KEY_PATH
|
|
)
|
|
return flow
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def scoped_stubs():
|
|
"""Contain the import stubs to this module's tests (t7 INFO).
|
|
|
|
Snapshots every injected sys.modules entry and restores or removes it on
|
|
teardown, so later tests never observe the fake homeassistant/voluptuous
|
|
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
|
|
"""
|
|
snapshot = entity_stublib.snapshot_modules()
|
|
_install_import_stubs()
|
|
yield
|
|
entity_stublib.restore_modules(snapshot)
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_test_connect(monkeypatch):
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
mock = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(ShieldConnectionManager, "test_connect", mock)
|
|
# Preload + patch the lazy-loaded config-flow module's ensure_adb_key
|
|
# (no key material — and no adb_shell keygen — during tests).
|
|
module = _config_flow_module()
|
|
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: _KEY_PATH)
|
|
return mock
|
|
|
|
|
|
async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
|
|
"""F1 (HIGH): exactly (host, port, key_path) — no inherited hass arg."""
|
|
flow = _flow("shield.local")
|
|
|
|
result = await flow.async_step_adb_pair({"submit": True})
|
|
|
|
mocked_test_connect.assert_awaited_once_with(
|
|
"shield.local", 5555, _KEY_PATH
|
|
)
|
|
# t56: the pairing hands over to the setup menu; Finish creates the entry
|
|
assert result["type"] == "menu"
|
|
result = await flow.async_step_finish(None)
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
|
async def test_pairing_submit_reports_adb_failed_on_false(mocked_test_connect):
|
|
flow = _flow("shield.local")
|
|
mocked_test_connect.return_value = False
|
|
|
|
result = await flow.async_step_adb_pair({"submit": True})
|
|
|
|
mocked_test_connect.assert_awaited_once()
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "adb_failed"}
|
|
|
|
|
|
async def test_pairing_submit_without_host_reports_error(mocked_test_connect):
|
|
flow = _flow("") # no host resolvable
|
|
|
|
result = await flow.async_step_adb_pair({"submit": True})
|
|
|
|
mocked_test_connect.assert_not_awaited()
|
|
assert result["errors"] == {"base": "adb_no_host"}
|