"""t56: the config flow against a REAL Home Assistant install. The clearing semantics the live bug turned on ("a configured script cannot be removed") cannot be established from hand-written stubs: what matters is what Home Assistant itself does when a selector is cleared and how it serialises the form it hands the browser. So this file drives the REAL ``homeassistant.data_entry_flow.FlowManager`` with the REAL integration, real ``probatio`` validation and the real frontend serialiser (``probatio.to_field_list`` + ``cv.custom_serializer``) — the exact call the HTTP flow view makes. Skips cleanly when homeassistant is not installed (every other test file runs against stubs). Run it with: uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py -q What this file CANNOT prove: there is no browser here, so nothing about how a form LOOKS. What it proves is the wire contract — the field list the frontend receives and the exact ``user_input`` a submission produces. """ from __future__ import annotations import pytest import pytest_asyncio pytest.importorskip( "homeassistant", reason="the real FlowManager is the point of this file", ) probatio = pytest.importorskip("probatio") import importlib.util # noqa: E402 import sys # noqa: E402 from pathlib import Path # noqa: E402 from homeassistant.config_entries import ( # noqa: E402 SOURCE_USER, ConfigEntries, ConfigEntry, OptionsFlowManager, ) from homeassistant.core import HomeAssistant # noqa: E402 from homeassistant.data_entry_flow import FlowManager # noqa: E402 from homeassistant.helpers import config_validation as cv # noqa: E402 from homeassistant.helpers import device_registry as device_registry # noqa: E402 from homeassistant.helpers import entity_registry as er # noqa: E402 from homeassistant.helpers.entity_registry import ( # noqa: E402 EntityRegistryItems, EntityRegistrySettings, ) # Load the integration by PATH under its real package name: the repo is not an # installed distribution, and the dotted import would need custom_components/ on # sys.path plus every one of its siblings present. _ROOT = Path(__file__).resolve().parent.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) cf = importlib.import_module("custom_components.nvidia_shield.config_flow") SCRIPTS = {"turn_on": "script.shield_wake", "volume_set": "script.shield_volume"} OPTIONS = { "app_name_map": {"ar.tvplayer.tv": "TiviMate"}, "command_scripts": dict(SCRIPTS), "host": "10.0.0.9", "adb_enabled": True, } SCRIPT_COMMANDS = ( "turn_on", "turn_off", "volume_up", "volume_down", "volume_set", "volume_mute", "select_source", ) SCRIPT_FIELD_PREFIX = "script_" def field_list(schema): """EXACTLY what the HTTP flow view serialises for the browser.""" if schema is None: return [] return probatio.to_field_list(schema, custom_serializer=cv.custom_serializer) def field_names(schema): return [field["name"] for field in field_list(schema)] def suggested(schema): return { field["name"]: (field.get("description") or {}).get("suggested_value") for field in field_list(schema) } #: Entities the probe refers to. The real EntitySelector validates an id #: against the entity registry, so these have to exist for a set (as opposed to #: a clear) to be a legal submission. ENTITIES = ( "script.shield_wake", "script.shield_volume", "script.kept", "script.wake", "media_player.jellyfin", "switch.soundbar", "switch.plug", ) @pytest_asyncio.fixture(name="ha") async def ha_fixture(tmp_path): """A real HomeAssistant with just enough machinery to submit real forms. HomeAssistant binds the running loop in __init__, so this has to be an async fixture — which is also why the tests below are coroutines. """ hass = HomeAssistant(str(tmp_path)) # the real manager, exactly as core builds it — minus the reload SCHEDULE: # core would immediately set the domain up, which needs the integration # loader and the whole component bootstrap this probe deliberately skips. hass.config_entries = ConfigEntries(hass, {}) hass.config_entries.async_schedule_reload = lambda entry_id: None # the real registry, populated with the entities the probe uses: a real # EntitySelector refuses an id the registry does not know device_registry.async_setup(hass) await device_registry.async_load(hass) # EntityRegistry's own loader reaches deep into core (device migration, # restore, store), so the two containers it fills are set up directly — # same objects, no core bootstrap. registry = er.async_get(hass) registry.entities = EntityRegistryItems(hass) registry.deleted_entities = {} registry.settings = EntityRegistrySettings() registry._entities_data = registry.entities.data hass.data[er.DATA_REGISTRY] = registry for entity_id in ENTITIES: domain, object_id = entity_id.split(".", 1) registry.async_get_or_create( domain, "test", entity_id, suggested_object_id=object_id ) assert registry.async_get("script.kept") is not None return hass def make_entry(hass, options=None): entry = ConfigEntry( version=1, minor_version=1, domain="nvidia_shield", title=f"Shield {len(hass.config_entries._entries) + 1}", data={}, options=dict(options if options is not None else OPTIONS), source=SOURCE_USER, unique_id=None, subentries_data=(), discovery_keys={}, ) # the probe "saves" by writing back what create_entry returned, exactly as # core's async_update_entry would; ConfigEntry exposes options through a # read-only property, so the private store is written instead object.__setattr__(entry, "_options", dict(entry.options)) hass.config_entries._entries[entry.entry_id] = entry return entry class SetupManager(FlowManager): """A real FlowManager that builds the integration's real config flow. Overrides only the handler construction, and does there what core does at config_entries.py:1959 — the flow's ``init_step`` is the SOURCE, which is why a real config flow starts at ``async_step_user`` and not at ``init``. """ async def async_create_flow(self, handler_key, *, context=None, data=None): flow = cf.NvidiaShieldConfigFlow() flow.init_step = (context or {}).get("source", SOURCE_USER) return flow async def async_finish_flow(self, flow, result): return result def setup_manager(hass): return SetupManager(hass) async def open_setup(hass): """Start a real user-initiated setup flow and return (manager, result).""" manager = setup_manager(hass) result = await manager.async_init( "nvidia_shield", context={"source": SOURCE_USER} ) return manager, result #: The first setup form, as the real frontend submits it: EVERY rendered field. def first_form(**overrides): payload = { "name": "Shield", "jellyfin_entity": "", "receiver_entity": "", "receiver_input": "", "power_entity": "", "adb_enabled": False, "adb_store_app_info": True, "host": "10.0.0.9", "adb_port": 5555, "protocol_enabled": False, } payload.update(overrides) return payload class OptionsManager(OptionsFlowManager): """OptionsFlowManager that builds the integration's real options flow. Overrides ONLY the handler lookup: real HA resolves it through ``async_get_options_flow`` from the registered integration, which needs a fully loaded core. Everything else — menu dispatch, schema validation, ``next_step_id`` routing — is the real implementation. """ async def async_create_flow(self, handler_key, *, context=None, data=None): return cf.NvidiaShieldOptionsFlow() def options_manager(hass): return OptionsManager(hass) async def open_options(hass, entry): manager = options_manager(hass) result = await manager.async_init( entry.entry_id, context={"source": SOURCE_USER, "entry_id": entry.entry_id}, ) return manager, result def full_script_page(value=""): """A real submission carries EVERY rendered field — this is that page.""" return {f"{SCRIPT_FIELD_PREFIX}{c}": value for c in SCRIPT_COMMANDS} async def open_scripts(hass, entry): """Open the Scripts sub-screen through the real menu.""" manager, menu = await open_options(hass, entry) scripts = await manager.async_configure(menu["flow_id"], {"next_step_id": "scripts"}) assert scripts["type"] == "form", scripts return manager, scripts class TestRealMenuFlow: """The options page is a real menu with real sub-screens.""" async def test_the_page_is_a_menu_and_every_row_opens(self, ha): entry = make_entry(ha) manager, menu = await open_options(ha, entry) assert menu["type"] == "menu" assert menu["step_id"] == "init" assert set(menu["menu_options"]) == { "connection", "receiver", "apps", "discover", "scripts", "finish", } # HA renders the rows from the flow result, and validates the choice # against the menu's own schema assert field_names(menu["data_schema"]) == ["next_step_id"] # each row is opened on its own flow, because a real flow keeps ONE # step at a time — reopening from a fresh menu is what the browser does for row in menu["menu_options"]: if row in ("finish", "discover"): # actions: `finish` commits and `discover` sweeps, neither renders continue _manager, fresh = await open_options(ha, entry) opened = await _manager.async_configure( fresh["flow_id"], {"next_step_id": row} ) assert opened["type"] == "form", row # HA dispatches a submitted form by its OWN step_id, so the rendered # step_id must be the menu row id (the method name) assert opened["step_id"] == row async def test_an_unknown_menu_row_is_rejected_by_the_real_schema(self, ha): entry = make_entry(ha) manager, menu = await open_options(ha, entry) from homeassistant.data_entry_flow import InvalidData # real HA validates the choice against the menu schema (probatio.In on # the offered ids) before the flow is even dispatched with pytest.raises(InvalidData): await manager.async_configure( menu["flow_id"], {"next_step_id": "not_a_step"} ) class TestRealAppsField: """[object Object]: the field list must carry TEXT, never a dict.""" async def test_the_apps_suggestion_is_the_serialized_text(self, ha): entry = make_entry(ha) manager, menu = await open_options(ha, entry) apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"}) suggested_values = suggested(apps["data_schema"]) assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate" assert isinstance(suggested_values["app_name_map"], str) # and nothing in the whole field list carries a dict for a text field for field in field_list(apps["data_schema"]): value = (field.get("description") or {}).get("suggested_value") if field.get("type") == "string": assert not isinstance(value, (dict, list)), field["name"] async def test_the_empty_apps_state_renders_without_a_suggestion(self, ha): entry = make_entry(ha, options={"host": "10.0.0.9"}) manager, menu = await open_options(ha, entry) apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"}) fields = field_list(apps["data_schema"]) assert [f["name"] for f in fields] == ["app_name_map"] assert (fields[0].get("description") or {}).get("suggested_value") in (None, "") async def test_a_table_row_round_trips_through_the_real_flow(self, ha): entry = make_entry(ha, options={"host": "10.0.0.9"}) manager, menu = await open_options(ha, entry) apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"}) saved = await manager.async_configure( apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"}, ) assert saved["type"] == "menu" # back at the menu, nothing committed yet created = await manager.async_configure( saved["flow_id"], {"next_step_id": "finish"} ) assert created["type"] == "create_entry" assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"} assert created["data"]["app_icon_map"] == { "com.plex.hdx": "http://icons.local/plex.png" } class TestRealScriptClearing: """The live bug: what the REAL flow stores when a selector is cleared.""" async def test_set_then_cleared_persists_through_save_and_reopen(self, ha): """t56 unit 0 / t57 F4: SET, CLEAR, SAVE, REOPEN — the live symptom. The whole page goes through the real flow manager: first with a script SELECTED (an entity id, which the seeded registry resolves), then with that one selector cleared while the other keeps its value. Both halves are driven here — the earlier version of this file claimed entity ids could not be driven, which was simply false (the receiver page in this file has always submitted ``switch.soundbar``). """ entry = make_entry(ha) manager, scripts = await open_scripts(ha, entry) # 1. SET: two commands selected through the real flow, from real ids page = full_script_page("") page["script_turn_on"] = "script.kept" page["script_volume_set"] = SCRIPTS["volume_set"] after_set = await manager.async_configure(scripts["flow_id"], page) assert after_set["type"] == "menu", after_set created = await manager.async_configure( after_set["flow_id"], {"next_step_id": "finish"} ) assert created["type"] == "create_entry" assert created["data"]["command_scripts"] == { "turn_on": "script.kept", "volume_set": SCRIPTS["volume_set"], } # 2. CLEAR: the same page with ONE selector emptied (a cleared selector # arrives as ''), the other left set saved = dict(entry.options) saved.update(created["data"]) object.__setattr__(entry, "_options", saved) manager2, scripts2 = await open_scripts(ha, entry) assert suggested(scripts2["data_schema"])["script_turn_on"] == "script.kept" cleared = full_script_page("") cleared["script_turn_on"] = "" cleared["script_volume_set"] = SCRIPTS["volume_set"] after_clear = await manager2.async_configure(scripts2["flow_id"], cleared) assert after_clear["type"] == "menu", after_clear created2 = await manager2.async_configure( after_clear["flow_id"], {"next_step_id": "finish"} ) assert created2["data"]["command_scripts"] == { "volume_set": SCRIPTS["volume_set"], } # 3. PERSIST: re-open over the SAVED options — the cleared command must # not come back, and no flat script_* key was ever stored saved2 = dict(entry.options) saved2.update(created2["data"]) object.__setattr__(entry, "_options", saved2) assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)] manager3, scripts3 = await open_scripts(ha, entry) values = suggested(scripts3["data_schema"]) assert values["script_volume_set"] == SCRIPTS["volume_set"] assert values.get("script_turn_on") in (None, "") @pytest.mark.parametrize("shape", ["empty_string", "null"]) async def test_a_cleared_selector_is_accepted_by_the_real_schema(self, ha, shape): """Real HA rejects '' / None for a plain EntitySelector; ours tolerates it.""" entry = make_entry(ha) manager, scripts = await open_scripts(ha, entry) page = full_script_page("") page["script_turn_on"] = "" if shape == "empty_string" else None saved = await manager.async_configure(scripts["flow_id"], page) assert saved["type"] == "menu", saved created = await manager.async_configure( saved["flow_id"], {"next_step_id": "finish"} ) assert created["data"]["command_scripts"] == {} async def test_a_never_set_field_stays_out_of_the_dict(self, ha): entry = make_entry(ha, options={"host": "10.0.0.9", "adb_enabled": True}) manager, scripts = await open_scripts(ha, entry) # a real submission carries every rendered field, all empty here saved = await manager.async_configure(scripts["flow_id"], full_script_page("")) created = await manager.async_configure( saved["flow_id"], {"next_step_id": "finish"} ) assert created["data"]["command_scripts"] == {} class TestRealSelectorValidation: """The selector distinction, pinned on the selector classes themselves. ``TestRealScriptClearing`` drives a cleared page end to end; this class pins the underlying difference in isolation, so a regression says WHICH rule broke: the built-in EntitySelector rejects a cleared field, ours accepts it and still validates a real id. """ def test_real_entity_selector_rejects_a_cleared_field(self): from homeassistant.helpers.selector import ( EntitySelector, EntitySelectorConfig, ) strict = EntitySelector(EntitySelectorConfig(domain="script")) for cleared in ("", None): with pytest.raises(probatio.Invalid): strict(cleared) def test_the_clearing_selector_accepts_cleared_and_real_values(self): from custom_components.nvidia_shield.config_flow import _build_selectors selector = _build_selectors()["script"] assert selector("") == "" assert selector(None) == "" assert selector(" ") == "" assert selector("script.whatever") == "script.whatever" class TestRealNoLostEdits: """Edits made on one sub-screen survive a submission on another.""" async def test_connection_then_scripts_then_apps_all_survive(self, ha): entry = make_entry(ha, options={}) manager, menu = await open_options(ha, entry) connection = await manager.async_configure( menu["flow_id"], {"next_step_id": "connection"} ) after_connection = await manager.async_configure( connection["flow_id"], { "host": "10.0.0.44", "adb_port": 5555, "adb_enabled": True, "protocol_enabled": False, "jellyfin_entity": "media_player.jellyfin", }, ) assert after_connection["type"] == "menu" scripts = await manager.async_configure( after_connection["flow_id"], {"next_step_id": "scripts"} ) page = full_script_page("") page["script_turn_on"] = "script.wake" after_scripts = await manager.async_configure(scripts["flow_id"], page) assert after_scripts["type"] == "menu" apps = await manager.async_configure( after_scripts["flow_id"], {"next_step_id": "apps"} ) after_apps = await manager.async_configure( apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex"} ) assert after_apps["type"] == "menu" created = await manager.async_configure( after_apps["flow_id"], {"next_step_id": "finish"} ) assert created["type"] == "create_entry" data = created["data"] # every earlier sub-screen's edit is still there assert data["host"] == "10.0.0.44" assert data["jellyfin_entity"] == "media_player.jellyfin" assert data["command_scripts"] == {"turn_on": "script.wake"} assert data["app_name_map"] == {"com.plex.hdx": "Plex"} class TestRealReceiverSelector: """The Receiver field accepts a switch, not only a media_player.""" async def test_the_selector_offers_switch_domains(self, ha): entry = make_entry(ha, options={}) manager, menu = await open_options(ha, entry) receiver = await manager.async_configure( menu["flow_id"], {"next_step_id": "receiver"} ) fields = {f["name"]: f for f in field_list(receiver["data_schema"])} selector = fields["receiver_entity"]["selector"]["entity"] assert "switch" in selector["domain"] assert "media_player" in selector["domain"] async def test_a_switch_is_accepted_and_stored(self, ha): entry = make_entry(ha, options={}) manager, menu = await open_options(ha, entry) receiver = await manager.async_configure( menu["flow_id"], {"next_step_id": "receiver"} ) saved = await manager.async_configure( receiver["flow_id"], {"receiver_entity": "switch.soundbar", "receiver_input": "HDMI 2"}, ) created = await manager.async_configure( saved["flow_id"], {"next_step_id": "finish"} ) assert created["data"]["receiver_entity"] == "switch.soundbar" assert created["data"]["receiver_input"] == "HDMI 2" class TestRealSetupMenu: """t57 UNIT 0a / F2: the SETUP flow under a REAL FlowManager. The stub suite drove `async_step_menu` directly, which is exactly how a broken menu stays green: it never asked Home Assistant to dispatch a row. These tests do, for every row, and they are the red proof for the TypeError the first form used to hit on the way to the menu. """ @staticmethod def _no_connect(result=True): async def _connect(*args, **kwargs): return result return staticmethod(_connect) async def test_the_first_form_leads_into_pairing_then_the_menu(self, ha, monkeypatch): """The whole ordinary path: first form → ADB pairing → the menu.""" monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(True) ) manager, form = await open_setup(ha) assert form["type"] == "form" assert form["step_id"] == "user" pairing = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False) ) assert pairing["type"] == "form" assert pairing["step_id"] == "adb_pair" result = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555}) assert result["type"] == "menu", result # ADB is paired, the protocol is off: only the two sub-screens + finish assert list(result["menu_options"]) == ["receiver", "connection", "finish"] async def test_the_adb_skip_reaches_the_menu(self, ha, monkeypatch): """The path that used to die: skip ADB pairing, then render the menu.""" monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(False) ) manager, form = await open_setup(ha) pairing = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False) ) assert pairing["step_id"] == "adb_pair" result = await manager.async_configure( pairing["flow_id"], {"skip_pairing": True} ) assert result["type"] == "menu", result assert "adb_pair" in result["menu_options"] # not paired: still offered async def test_adb_pairing_success_stops_offering_that_row(self, ha, monkeypatch): monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(True) ) manager, form = await open_setup(ha) pairing = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False) ) paired = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555}) assert paired["type"] == "menu" assert "adb_pair" not in paired["menu_options"] # F2: the guard async def test_every_setup_menu_row_dispatches_and_renders(self, ha, monkeypatch): """Every row HA offers must have a handler AND render a form whose step_id is that same row, or the page dead-ends on submit.""" monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(False) ) # the protocol row builds the androidtvremote2 client; this test is about # DISPATCH, and the pairing internals have their own stub coverage. The # UNREACHABLE branch is a real code path needing no library, so the row # renders its real form. async def _unreachable(self): return None, "protocol_unreachable" monkeypatch.setattr( cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable ) manager, form = await open_setup(ha) menu_result = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=True), ) assert menu_result["type"] == "form" # the ADB pairing form comes first # hand the ADB form a skip so the MENU renders with every row menu_result = await manager.async_configure( menu_result["flow_id"], {"skip_pairing": True} ) assert menu_result["type"] == "menu", menu_result rows = list(menu_result["menu_options"]) assert rows == ["receiver", "connection", "adb_pair", "protocol_pair", "finish"] for row in rows: # each row on its own flow (a flow is at one step at a time) AND on # the manager that owns it: a flow lives in its manager's progress row_manager, fresh = await open_setup(ha) pairing = await row_manager.async_configure( fresh["flow_id"], first_form(adb_enabled=True, protocol_enabled=True) ) menu = await row_manager.async_configure( pairing["flow_id"], {"skip_pairing": True} ) opened = await row_manager.async_configure( menu["flow_id"], {"next_step_id": row} ) assert opened["type"] in ("form", "menu", "create_entry"), (row, opened) if opened["type"] == "form": assert opened["step_id"] == row, (row, opened["step_id"]) async def test_a_setup_sub_screen_submission_is_accepted(self, ha, monkeypatch): """A rendered sub-screen must be re-submittable (row id == step_id).""" monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(True) ) manager, form = await open_setup(ha) pairing = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False) ) menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555}) assert menu["type"] == "menu" opened = await manager.async_configure( menu["flow_id"], {"next_step_id": "receiver"} ) assert opened["type"] == "form" assert opened["step_id"] == "receiver" back = await manager.async_configure( opened["flow_id"], {"receiver_entity": "switch.soundbar"} ) assert back["type"] == "menu", back created = await manager.async_configure( back["flow_id"], {"next_step_id": "finish"} ) assert created["type"] == "create_entry" assert created["options"]["receiver_entity"] == "switch.soundbar" async def test_the_full_menu_walk_merges_and_creates_one_entry(self, ha, monkeypatch): """connection + receiver + finish → ONE entry carrying both edits.""" monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(True) ) manager, form = await open_setup(ha) pairing = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False) ) menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555}) connection = await manager.async_configure( menu["flow_id"], {"next_step_id": "connection"} ) assert connection["step_id"] == "connection" menu2 = await manager.async_configure( connection["flow_id"], {"host": "10.0.0.55", "adb_port": 5555, "adb_enabled": True, "protocol_enabled": False} ) assert menu2["type"] == "menu" receiver = await manager.async_configure( menu2["flow_id"], {"next_step_id": "receiver"} ) menu3 = await manager.async_configure( receiver["flow_id"], {"receiver_entity": "switch.soundbar"} ) created = await manager.async_configure( menu3["flow_id"], {"next_step_id": "finish"} ) assert created["type"] == "create_entry" assert created["options"]["host"] == "10.0.0.55" assert created["options"]["receiver_entity"] == "switch.soundbar" @pytest.mark.parametrize( ("adb_enabled", "protocol_enabled", "connect", "pairs_protocol"), [ (True, False, False, False), # ADB skipped (True, False, True, False), # ADB paired, protocol off (True, True, False, False), # ADB skipped, protocol skipped (True, True, True, False), # ADB paired, protocol skipped (False, True, False, False), # protocol only ], ids=["adb-skip", "adb-paired", "adb-skip+protocol-skip", "pair-adb", "protocol-only"], ) async def test_every_ordinary_path_reaches_create_entry( self, ha, monkeypatch, adb_enabled, protocol_enabled, connect, pairs_protocol ): """t57 UNIT 0a: the path map. Under the pre-fix tree FOUR of these five died with `TypeError: async_show_menu() got an unexpected keyword argument 'errors'` the moment the ADB pairing form was submitted or skipped — so a new entry could not be created on any path that went near the menu, and the sub-screens were unreachable. Each path is driven here to `create_entry`. """ monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(connect) ) async def _unreachable(self): return None, "protocol_unreachable" monkeypatch.setattr( cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable ) manager, form = await open_setup(ha) result = await manager.async_configure( form["flow_id"], first_form(adb_enabled=adb_enabled, protocol_enabled=protocol_enabled), ) assert result["type"] == "form", result for _ in range(12): if result["type"] == "create_entry": break step = result.get("step_id") if step == "adb_pair": result = await manager.async_configure( result["flow_id"], {"skip_pairing": True} ) elif step == "protocol_pair": result = await manager.async_configure( result["flow_id"], {"pairing_code": "1234", "host": "10.0.0.9", "skip_pairing": True}, ) elif result["type"] == "menu": # the menu must always offer a way out assert "finish" in result["menu_options"], result["menu_options"] result = await manager.async_configure( result["flow_id"], {"next_step_id": "finish"} ) else: # pragma: no cover - the assertion below reports it break assert result["type"] == "create_entry", result async def test_the_menu_description_reports_what_is_unpaired(self, ha, monkeypatch): monkeypatch.setattr( cf.ShieldConnectionManager, "test_connect", self._no_connect(False) ) manager, form = await open_setup(ha) pairing = await manager.async_configure( form["flow_id"], first_form(adb_enabled=True, protocol_enabled=True) ) menu = await manager.async_configure(pairing["flow_id"], {"skip_pairing": True}) assert menu["type"] == "menu" note = (menu.get("description_placeholders") or {}).get("pairing", "") assert "ADB" in note and "remote protocol" in note