diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 807a2d6..879e507 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -67,9 +67,18 @@ def install_selector_faces(selector_module) -> None: installers in the flow tests), so the stubs cannot drift apart. """ selector_module.EntitySelector = EntitySelectorStub - selector_module.EntitySelectorConfig = lambda *args, **kwargs: "" + + class _Config(types.SimpleNamespace): + """Selector configs keep their kwargs (real HA stores them as fields). + + The stub needs them inspectable: which domains a selector offers is a + user-visible contract (t56 — the Receiver field accepts a switch), and a + lambda throwing the value away cannot pin it. + """ + + selector_module.EntitySelectorConfig = _Config selector_module.SelectSelector = lambda *args, **kwargs: None - selector_module.SelectSelectorConfig = lambda **kwargs: "" + selector_module.SelectSelectorConfig = _Config selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown") diff --git a/tests/test_config_flow_pairing.py b/tests/test_config_flow_pairing.py index 81a1c0b..bce9c84 100644 --- a/tests/test_config_flow_pairing.py +++ b/tests/test_config_flow_pairing.py @@ -69,6 +69,17 @@ def _install_import_stubs() -> None: 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, menu_options, sort=False, + description_placeholders=None, errors=None): + # t56: the real MENU result shape; the flow manager dispatches the + # chosen `next_step_id` to `async_step_(None)`. + return { + "type": "menu", + "step_id": step_id, + "menu_options": list(menu_options), + "errors": dict(errors or {}), + } + def async_create_entry(self, *, title, data, options=None): return {"type": "create_entry", "title": title, "data": data, "options": options} @@ -212,6 +223,9 @@ async def test_pairing_submit_uses_declared_signature(mocked_test_connect): 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" diff --git a/tests/test_config_flow_protocol.py b/tests/test_config_flow_protocol.py index 5d07911..94ef729 100644 --- a/tests/test_config_flow_protocol.py +++ b/tests/test_config_flow_protocol.py @@ -110,6 +110,17 @@ def _install_stubs() -> None: 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, menu_options, sort=False, + description_placeholders=None, errors=None): + # t56: the real MENU result shape; the flow manager dispatches the + # chosen `next_step_id` to `async_step_(None)`. + return { + "type": "menu", + "step_id": step_id, + "menu_options": list(menu_options), + "errors": dict(errors or {}), + } + def async_create_entry(self, *, title, data, options=None): return {"type": "create_entry", "title": title, "data": data, "options": options} @@ -338,6 +349,10 @@ class TestProtocolPairing: assert client.finished_codes == [FAKE_PAIRING_CODE] assert flow._unique_id == FAKE_MAC assert client.disconnected == 1 + # t56: a finished pairing hands over to the setup menu; its Finish row + # creates the entry + assert result["type"] == "menu" + result = await flow.async_step_finish(None) assert result["type"] == "create_entry" assert result["options"][CONF_PROTOCOL_ENABLED] is True assert result["options"][CONF_HOST] == "shield.local" @@ -354,6 +369,8 @@ class TestProtocolPairing: result = await flow.async_step_protocol_pair( {CONF_PAIRING_CODE: FAKE_PAIRING_CODE} ) + assert result["type"] == "menu" + result = await flow.async_step_finish(None) assert result["type"] == "create_entry" assert client.finished_codes == [FAKE_PAIRING_CODE] diff --git a/tests/test_config_flow_real_ha.py b/tests/test_config_flow_real_ha.py new file mode 100644 index 0000000..3a97939 --- /dev/null +++ b/tests/test_config_flow_real_ha.py @@ -0,0 +1,480 @@ +"""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.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 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", + "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 == "finish": + 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: SET, CLEAR, SAVE, REOPEN — the live symptom, end to end. + + Scoped deliberately: the whole Scripts page goes through the real flow + manager with every selector cleared (which is the shape the live bug was + about), and the SET half is proven separately against the real selector + objects in TestRealSelectorValidation below. A page carrying ENTITY IDS + cannot be driven here — real HA validates an entity selector against the + loaded entity/device registries and a stubbed core refuses the ids; the + selector-object test is the honest place for that half. + """ + entry = make_entry(ha) + manager, scripts = await open_scripts(ha, entry) + + # 1. CLEAR: the frontend submits the whole page, all selectors empty + cleared = full_script_page("") + after_clear = await manager.async_configure(scripts["flow_id"], cleared) + assert after_clear["type"] == "menu", after_clear + + created = await manager.async_configure( + after_clear["flow_id"], {"next_step_id": "finish"} + ) + assert created["type"] == "create_entry" + assert created["data"]["command_scripts"] == {} + + # 2. PERSIST: re-open the page over the SAVED options — the cleared + # commands must not come back, and no flat script_* key was stored + saved = dict(entry.options) + saved.update(created["data"]) + object.__setattr__(entry, "_options", saved) + assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)] + manager2, scripts2 = await open_scripts(ha, entry) + values = suggested(scripts2["data_schema"]) + assert not [v for k, v in values.items() if k.startswith(SCRIPT_FIELD_PREFIX) and v] + + @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 ONE place the real selectors are exercised with real entity ids. + + Driving an entity-valued page through a stubbed FlowManager is not possible + (see TestRealScriptClearing), so the distinction this task turns on is pinned + here directly against the real selector classes: 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" diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index 9a8fecc..38f6814 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -31,11 +31,15 @@ import pytest import voluptuous_mirror as vol_mirror from conftest import _SHIELD_DIR, _load_module from nsh.const import ( + CONF_ADB_COMMAND_ENABLED, CONF_ADB_ENABLED, CONF_ADB_PORT, + CONF_ADB_STORE_APP_INFO, + CONF_CHANNEL_DIGIT_DELAY, CONF_COMMAND_SCRIPTS, CONF_HOST, CONF_JELLYFIN_ENTITY, + CONF_LOGCAT_EVENTS, CONF_PAIRING_CODE, CONF_POWER_ENTITY, CONF_PROTOCOL_ENABLED, @@ -103,6 +107,19 @@ 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): + # t56: the real result shape. The flow manager reads + # `next_step_id` from the MENU result and calls + # `async_step_(None)`, so the stub reports the menu and the + # test driver makes the choice. + return { + "type": "menu", + "step_id": step_id, + "menu_options": list(menu_options), + "errors": dict(errors or {}), + } + def async_create_entry(self, *, title, data, options=None): return {"type": "create_entry", "title": title, "data": data, "options": options} @@ -182,6 +199,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): + # 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 {}), + } + class _SuggestedMixin: def add_suggested_values_to_schema(self, schema, suggested): schema_dict = getattr(schema, "schema", None) or ( @@ -864,8 +892,10 @@ class TestNoDeadEndPairing: ) assert form["step_id"] == "adb_pair" - result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True}) + skipped = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True}) + assert skipped["type"] == "menu" # t56: the setup menu takes over + result = await flow.async_step_finish(None) assert result["type"] == "create_entry" assert test_connect.await_count == 0 # nothing was paired # the intent survives, so the Pair ADB button stays available for later @@ -899,7 +929,11 @@ class TestNoDeadEndPairing: result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True}) - assert result["step_id"] == "protocol_pair" # not a dead end either + # 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 (await flow.async_step_protocol_pair(None))["step_id"] == "protocol_pair" async def test_skip_protocol_after_skip_adb_creates_the_entry( self, tmp_path, monkeypatch @@ -941,15 +975,21 @@ class TestNoDeadEndPairing: ) assert form["step_id"] == "protocol_pair" - result = await flow.async_step_protocol_pair( + paired = await flow.async_step_protocol_pair( {CONF_PAIRING_CODE: FAKE_PAIRING_CODE} ) - assert result["type"] == "create_entry" - assert result["options"][CONF_PROTOCOL_ENABLED] is True + # 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 client.finished_codes == [FAKE_PAIRING_CODE] assert flow._unique_id == FAKE_MAC + result = await flow.async_step_finish(None) + assert result["type"] == "create_entry" + assert result["options"][CONF_PROTOCOL_ENABLED] is True + async def test_normal_adb_pair_path_still_creates_the_entry(self, tmp_path, monkeypatch): module = _form_module() test_connect = _patch_adb(monkeypatch, module) @@ -965,11 +1005,17 @@ class TestNoDeadEndPairing: ) assert form["step_id"] == "adb_pair" - result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555}) + paired = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555}) + # t56: the ADB pairing is done, so the setup menu takes over; Finish + # (its own row, HA dispatches it) creates the entry + assert paired["type"] == "menu" + assert "finish" in paired["menu_options"] + result = await flow.async_step_finish(None) + + test_connect.assert_awaited_once_with(DISCOVERED_HOST, 5555, KEY_PATH) assert result["type"] == "create_entry" assert result["options"][CONF_ADB_ENABLED] is True - test_connect.assert_awaited_once_with(DISCOVERED_HOST, 5555, KEY_PATH) async def test_adb_form_host_correction_is_accepted_in_place(self, tmp_path, monkeypatch): """The user step is gone — a typed host must be usable where it is typed.""" @@ -983,9 +1029,11 @@ class TestNoDeadEndPairing: result = await flow.async_step_adb_pair( {CONF_HOST: "10.0.0.77", CONF_ADB_PORT: 5555} ) + assert result["type"] == "menu" + created = await flow.async_step_finish(None) - assert result["type"] == "create_entry" - assert result["options"][CONF_HOST] == "10.0.0.77" + assert created["type"] == "create_entry" + assert created["options"][CONF_HOST] == "10.0.0.77" assert test_connect.await_args.args[0] == "10.0.0.77" async def test_protocol_form_host_correction_restarts_pairing(self, tmp_path, monkeypatch): @@ -1286,8 +1334,13 @@ class TestUnreachableShieldDuringPairing: ) assert adb_form["step_id"] == "adb_pair" - result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True}) + skipped = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True}) + assert skipped["type"] == "menu" + assert "protocol" in skipped["menu_options"] + # choosing it re-renders the reachable-again pairing form with the + # unreachable error and both escapes, exactly as before + result = await flow.async_step_protocol_pair(None) assert result["type"] == "form" assert result["step_id"] == "protocol_pair" assert result["errors"] == {"base": "protocol_unreachable"} @@ -1434,19 +1487,43 @@ class TestCopyContract: assert field in steps[step]["data"], f"{step}.{field} has no label" async def test_every_rendered_options_field_has_a_label(self, tmp_path): + """t56: EVERY sub-screen's fields carry a label, in both string files. + + The page is a menu now, so the check walks the real menu: each rendered + sub-screen must have a step with a title, a description and a label for + every field it renders — and the menu itself must label every row it + offers. + """ module = _form_module() flow = module.NvidiaShieldOptionsFlow() flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield") flow.hass = FakeHass(str(tmp_path)) - form = await flow.async_step_init(None) - fields = vol_mirror.declared_fields(form["data_schema"]) + menu = await flow.async_step_init(None) + # the rendered sub-screens, keyed by the i18n step id the menu row maps to + rendered = { + step: vol_mirror.declared_fields( + (await getattr(flow, f"async_step_{step}")(None))["data_schema"] + ) + for step in menu["menu_options"] + if step != "finish" + } for file in (_strings(), _en_strings()): - step = file["options"]["step"]["init"] - assert step["description"] - for field in fields: - assert field in step["data"], f"options.{field} has no label" + menu_step = file["options"]["step"]["init"] + assert menu_step["description"] + # every row the menu offers has a label (HA renders these as names) + for option in menu["menu_options"]: + assert option in menu_step.get("menu", {}), f"menu row {option}" + + steps = file["options"]["step"] + for step, fields in rendered.items(): + # a sub-screen's i18n step key is its menu row id (HA dispatches + # a submitted form by its own step_id, so the two must match) + assert steps[step]["title"], step + assert steps[step]["description"], step + for field in fields: + assert field in steps[step]["data"], f"{step}.{field} has no label" def _options_flow(tmp_path, options=None): @@ -1460,6 +1537,86 @@ def _options_flow(tmp_path, options=None): return flow +# ── t56: driving the options MENU from tests ── +# +# The page is a menu now, so a test navigates two levels: choose a sub-screen +# (the menu result's own `menu_options` are the source of truth, the same list +# Home Assistant renders), then submit that sub-screen's form. Nothing here +# re-implements the flow's routing — a wrong step id fails loudly. + +OPTIONS_SUBSTEP_FIELDS = { + "connection": { + CONF_HOST, + CONF_ADB_PORT, + CONF_ADB_ENABLED, + CONF_PROTOCOL_ENABLED, + CONF_ADB_STORE_APP_INFO, + CONF_JELLYFIN_ENTITY, + CONF_POWER_ENTITY, + CONF_LOGCAT_EVENTS, + CONF_ADB_COMMAND_ENABLED, + }, + "receiver": {"receiver_entity", "receiver_input"}, + "apps": {"app_name_map", "app_icon_map"}, + "scripts": { + CONF_REMOTE_COMMAND_SCRIPT, + CONF_CHANNEL_DIGIT_DELAY, + CONF_COMMAND_SCRIPTS, + *{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS}, + }, +} + + +def _options_substep_of(key: str) -> str: + for step, fields in OPTIONS_SUBSTEP_FIELDS.items(): + if key in fields: + return step + if key.startswith(SCRIPT_FIELD_PREFIX): + return "scripts" + raise AssertionError(f"no options sub-screen owns the field {key!r}") + + +async def _options_submit(flow, payload: dict, *, save=False): + """Submit `payload` through the MENU into the sub-screens that own its keys. + + Returns the result of the LAST submitted sub-screen (the menu again after a + successful save), so a test can assert on form errors the way it did when the + page was one form. With ``save=True`` the menu's "Save and close" row is + chosen afterwards and the create_entry result comes back — that is what + actually commits the merged options. + """ + grouped: dict[str, dict] = {} + for key, value in payload.items(): + grouped.setdefault(_options_substep_of(key), {})[key] = value + result = await flow.async_step_init(None) + for step, sub_payload in grouped.items(): + assert step in result["menu_options"], ( + f"{step} is not offered by the menu: {result['menu_options']}" + ) + result = await getattr(flow, f"async_step_{step}")(sub_payload) + if save: + assert "finish" in result["menu_options"], result["menu_options"] + result = await flow.async_step_finish(None) + return result + + +def _all_script_fields(value): + """Every per-command script field set to `value` — a FULL page submission. + + The frontend submits every field the step rendered, so this is what a real + submit of the Scripts screen looks like; a partial dict is the artificial + shape only tests produce. + """ + return {f"{SCRIPT_FIELD_PREFIX}{command}": value for command in SCRIPT_COMMANDS} + + +async def _options_open(flow, step: str): + """Choose a sub-screen from the menu and open its form.""" + result = await flow.async_step_init(None) + assert step in result["menu_options"], result["menu_options"] + return await getattr(flow, f"async_step_{step}")(None) + + class TestCommandScriptOptions: """t44: one script selector per command, stored as one dict option. @@ -1478,7 +1635,7 @@ class TestCommandScriptOptions: async def test_a_script_selector_exists_for_every_command(self, tmp_path): flow = self._flow(tmp_path) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") fields = set(vol_mirror.declared_fields(form["data_schema"])) assert {f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS} <= fields @@ -1486,11 +1643,13 @@ class TestCommandScriptOptions: async def test_saving_stores_one_dict_option_and_no_flat_keys(self, tmp_path): flow = self._flow(tmp_path) - result = await flow.async_step_init( + result = await _options_submit( + flow, { f"{SCRIPT_FIELD_PREFIX}volume_set": "script.shield_volume", f"{SCRIPT_FIELD_PREFIX}turn_on": "script.shield_wake", - } + }, + save=True, ) assert result["type"] == "create_entry" @@ -1506,7 +1665,7 @@ class TestCommandScriptOptions: tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") suggested = self._submitted(form["data_schema"]).get("__suggested__", {}) assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.shield_wake" @@ -1514,12 +1673,12 @@ class TestCommandScriptOptions: assert f"{SCRIPT_FIELD_PREFIX}volume_up" not in suggested async def test_clearing_every_script_removes_the_option(self, tmp_path): - """The editor is the truth: a save without scripts clears them.""" + """The editor is the truth: clearing every selector removes them all.""" flow = self._flow( tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} ) - result = await flow.async_step_init({}) + result = await _options_submit(flow, _all_script_fields(""), save=True) assert result["data"][CONF_COMMAND_SCRIPTS] == {} @@ -1529,15 +1688,34 @@ class TestCommandScriptOptions: {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake", "gone": "script.x"}}, ) - result = await flow.async_step_init({f"{SCRIPT_FIELD_PREFIX}turn_on": "script.a"}) + payload = _all_script_fields("") + payload[f"{SCRIPT_FIELD_PREFIX}turn_on"] = "script.a" + result = await _options_submit(flow, payload, save=True) assert result["data"][CONF_COMMAND_SCRIPTS] == {"turn_on": "script.a"} + async def test_a_full_clear_of_a_freshly_typed_script_persists(self, tmp_path): + """t56 live bug: a configured script must be removable from the UI. + + The frontend submits the whole Scripts page, so "typed then cleared" + arrives as an empty value for that command — and the save must drop it + rather than keep a stale key. + """ + flow = self._flow( + tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} + ) + + payload = _all_script_fields("") + payload[f"{SCRIPT_FIELD_PREFIX}turn_on"] = "" + result = await _options_submit(flow, payload, save=True) + + assert result["data"][CONF_COMMAND_SCRIPTS] == {} + async def test_the_page_still_renders_with_a_hand_edited_option(self, tmp_path): """A string where the dict belongs must not break the form.""" flow = self._flow(tmp_path, {CONF_COMMAND_SCRIPTS: "script.shield_wake"}) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") assert form["type"] == "form" assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields( @@ -1582,7 +1760,7 @@ class TestClearingAnEntityField: flow = _options_flow( tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") for value in ("", None): assert self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": value}) == { @@ -1616,15 +1794,18 @@ class TestClearingAnEntityField: flow = _options_flow( tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") - # what HA does: validate the submitted page, then run the step - result = await flow.async_step_init( - self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": value}) - ) + # what HA does: validate the submitted page, then run the step — the + # whole Scripts page, with the one selector cleared + page = _all_script_fields("") + page[f"{SCRIPT_FIELD_PREFIX}turn_on"] = value + result = await flow.async_step_scripts(self._validate(form, page)) + assert result["type"] == "menu" # back to the menu, ready to save - assert result["type"] == "create_entry" - assert result["data"][CONF_COMMAND_SCRIPTS] == {} + created = await flow.async_step_finish(None) + assert created["type"] == "create_entry" + assert created["data"][CONF_COMMAND_SCRIPTS] == {} @pytest.mark.parametrize("value", ["", None]) async def test_an_explicit_empty_value_clears_a_preexisting_entity_field( @@ -1635,34 +1816,42 @@ class TestClearingAnEntityField: tmp_path, {CONF_JELLYFIN_ENTITY: "media_player.jellyfin", CONF_HOST: "10.0.0.9"}, ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "connection") - result = await flow.async_step_init( + await flow.async_step_connection( self._validate(form, {CONF_JELLYFIN_ENTITY: value}) ) + result = await flow.async_step_finish(None) assert not result["data"][CONF_JELLYFIN_ENTITY] # cleared, not kept assert result["data"][CONF_HOST] == "10.0.0.9" # untouched fields survive async def test_an_omitted_script_field_is_cleared_as_well(self, tmp_path): - """The script dict is rebuilt from the page, so omitting a command clears it.""" + """The script dict is rebuilt from the page, so an empty page clears it.""" flow = _options_flow( tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") - result = await flow.async_step_init(self._validate(form, {})) + # a full page submission with every selector empty (the frontend's shape + # when the user clears them all) + page = self._validate(form, _all_script_fields("")) + await flow.async_step_scripts(page) + result = await flow.async_step_finish(None) assert result["data"][CONF_COMMAND_SCRIPTS] == {} async def test_an_omitted_entity_field_keeps_its_stored_value(self, tmp_path): - """An omitted key is "not touched" for the patched options, not a clear.""" + """A key the submission does NOT carry is untouched, not a clear (t56).""" flow = _options_flow( tmp_path, {CONF_JELLYFIN_ENTITY: "media_player.jellyfin"} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "connection") - result = await flow.async_step_init(self._validate(form, {CONF_HOST: "10.0.0.9"})) + await flow.async_step_connection( + self._validate(form, {CONF_HOST: "10.0.0.9"}) + ) + result = await flow.async_step_finish(None) assert result["data"][CONF_JELLYFIN_ENTITY] == "media_player.jellyfin" @@ -1674,22 +1863,29 @@ class TestClearingAnEntityField: CONF_HOST: "10.0.0.9", } flow = _options_flow(tmp_path, stored) - form = await flow.async_step_init(None) - suggested = form["data_schema"].get("__suggested__", {}) - - # the page comes back carrying the stored values, and the user submits them + scripts_form = await _options_open(flow, "scripts") + suggested = scripts_form["data_schema"].get("__suggested__", {}) assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.watch" - result = await flow.async_step_init( + + connection_form = await _options_open(flow, "connection") + # the two sub-screens come back carrying the stored values, and the user + # submits both pages unchanged + await flow.async_step_scripts( self._validate( - form, + scripts_form, {f"{SCRIPT_FIELD_PREFIX}turn_on": "script.watch"} + ) + ) + await flow.async_step_connection( + self._validate( + connection_form, { - f"{SCRIPT_FIELD_PREFIX}turn_on": "script.watch", CONF_JELLYFIN_ENTITY: "media_player.jellyfin", CONF_POWER_ENTITY: "switch.plug", CONF_HOST: "10.0.0.9", }, ) ) + result = await flow.async_step_finish(None) assert result["data"][CONF_COMMAND_SCRIPTS] == {"turn_on": "script.watch"} assert result["data"][CONF_JELLYFIN_ENTITY] == "media_player.jellyfin" @@ -1702,14 +1898,14 @@ class TestRemoteCommandScriptOption: async def test_the_field_is_offered_and_stored_as_a_string(self, tmp_path): flow = _options_flow(tmp_path) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") assert CONF_REMOTE_COMMAND_SCRIPT in vol_mirror.declared_fields( form["data_schema"] ) - result = await flow.async_step_init( - {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"} + result = await _options_submit( + flow, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"}, save=True ) assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote" @@ -1718,7 +1914,7 @@ class TestRemoteCommandScriptOption: flow = _options_flow( tmp_path, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") assert form["data_schema"]["__suggested__"][CONF_REMOTE_COMMAND_SCRIPT] == ( "script.shield_remote" @@ -1727,8 +1923,8 @@ class TestRemoteCommandScriptOption: async def test_a_padded_value_is_trimmed(self, tmp_path): flow = _options_flow(tmp_path) - result = await flow.async_step_init( - {CONF_REMOTE_COMMAND_SCRIPT: " script.shield_remote "} + result = await _options_submit( + flow, {CONF_REMOTE_COMMAND_SCRIPT: " script.shield_remote "}, save=True ) assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote" @@ -1737,12 +1933,13 @@ class TestRemoteCommandScriptOption: flow = _options_flow( tmp_path, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"} ) - form = await flow.async_step_init(None) + form = await _options_open(flow, "scripts") validated = TestClearingAnEntityField._validate( form, {CONF_REMOTE_COMMAND_SCRIPT: ""} ) - result = await flow.async_step_init(validated) + await flow.async_step_scripts(validated) + result = await flow.async_step_finish(None) assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "" @@ -1770,3 +1967,250 @@ class TestPassiveSetupNeverDemandsReadiness: assert "ConfigEntryNotReady" not in body # never demands readiness assert "raise " not in body + + +class TestOptionsMenu: + """t56: the options page is a MENU of sub-screens, not one long form.""" + + async def test_the_menu_offers_every_sub_screen_and_save(self, tmp_path): + flow = _options_flow(tmp_path) + + menu = await flow.async_step_init(None) + + assert menu["type"] == "menu" + assert menu["step_id"] == "init" + assert list(menu["menu_options"]) == [ + "connection", + "receiver", + "apps", + "scripts", + "finish", + ] + + async def test_every_menu_row_has_a_handler_named_after_it(self, tmp_path): + """The invariant that makes a menu work at all (found via real HA). + + Home Assistant dispatches a menu choice to ``async_step_`` and a + submitted form to ``async_step_``, so a row whose id has + no matching method is an UnknownStep, and a sub-screen whose step_id is + not its row id cannot be re-submitted. Both are pinned here. + """ + flow = _options_flow(tmp_path) + menu = await flow.async_step_init(None) + + 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, form["step_id"] + + async def test_every_sub_screen_is_reachable_and_returns_to_the_menu(self, tmp_path): + flow = _options_flow(tmp_path) + + for row in ("connection", "receiver", "apps", "scripts"): + opened = await _options_open(flow, row) + assert opened["type"] == "form" + # a submission that changes nothing still returns to the menu + back = await getattr(flow, f"async_step_{row}")({}) + assert back["type"] == "menu", (row, back) + + async def test_edits_on_one_sub_screen_are_not_lost_by_another(self, tmp_path): + """The merge is key-scoped, so the order of sub-screens cannot matter.""" + flow = _options_flow(tmp_path, options={}) + + await _options_submit( + flow, + {"host": "10.0.0.44", "receiver_entity": "switch.soundbar"}, + ) + await _options_submit( + flow, + {"app_name_map": "com.plex.hdx=Plex"}, + ) + result = await flow.async_step_finish(None) + + assert result["data"]["host"] == "10.0.0.44" + assert result["data"]["receiver_entity"] == "switch.soundbar" + assert result["data"]["app_name_map"] == {"com.plex.hdx": "Plex"} + + async def test_closing_without_finish_keeps_the_entry_untouched(self, tmp_path): + """Only "Save and close" commits; visiting a sub-screen never does.""" + stored = {CONF_HOST: "10.0.0.9"} + flow = _options_flow(tmp_path, stored) + + await _options_submit(flow, {"host": "10.0.0.44"}) + + # the flow's pending buffer changed, the entry's options did not + assert flow._options_dict[CONF_HOST] == "10.0.0.44" + assert flow.config_entry.options == stored + + +class TestAppsTable: + """t56: the Apps sub-screen is an editable one-row-per-app table.""" + + async def test_the_suggestion_is_text_never_a_dict(self, tmp_path): + """The [object Object] root cause, pinned at the layer that caused it.""" + flow = _options_flow( + tmp_path, + {"app_name_map": {"ar.tvplayer.tv": "TiviMate", "com.plex.hdx": "Plex"}}, + ) + + form = await _options_open(flow, "apps") + suggested = form["data_schema"]["__suggested__"] + + assert isinstance(suggested["app_name_map"], str) + assert not isinstance(suggested["app_name_map"], (dict, list)) + assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex" + + async def test_a_row_is_added_with_its_icon_and_prefilled(self, tmp_path): + flow = _options_flow(tmp_path, options={}) + await _options_submit( + flow, {"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"} + ) + result = await flow.async_step_finish(None) + + assert result["data"]["app_name_map"] == {"com.plex.hdx": "Plex"} + assert result["data"]["app_icon_map"] == { + "com.plex.hdx": "http://icons.local/plex.png" + } + + # re-open: the row (icon included) comes back in the text field + reopened = _options_flow(tmp_path, result["data"]) + form = await _options_open(reopened, "apps") + assert ( + form["data_schema"]["__suggested__"]["app_name_map"] + == "com.plex.hdx=Plex|http://icons.local/plex.png" + ) + + async def test_a_row_is_edited_in_place(self, tmp_path): + flow = _options_flow( + tmp_path, {"app_name_map": {"com.plex.hdx": "Plex"}, "host": "10.0.0.9"} + ) + await _options_submit(flow, {"app_name_map": "com.plex.hdx=Plex (living room)"}) + result = await flow.async_step_finish(None) + + assert result["data"]["app_name_map"] == {"com.plex.hdx": "Plex (living room)"} + assert result["data"]["host"] == "10.0.0.9" # another screen's field + + async def test_a_row_is_removed_by_dropping_its_line(self, tmp_path): + flow = _options_flow( + tmp_path, + {"app_name_map": {"com.plex.hdx": "Plex", "ar.tvplayer.tv": "TiviMate"}}, + ) + await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate"}) + result = await flow.async_step_finish(None) + + assert result["data"]["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"} + + async def test_the_empty_state_renders_without_data_or_a_crash(self, tmp_path): + flow = _options_flow(tmp_path, options={}) + + form = await _options_open(flow, "apps") + + assert form["type"] == "form" + assert form["step_id"] == "apps" + assert form["errors"] == {} + assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"] + # no discovery data yet, so nothing is suggested — an empty text field, + # which is exactly the documented "blank uses the built-in list" + assert "app_name_map" not in form["data_schema"].get("__suggested__", {}) + + async def test_a_cleared_table_is_an_empty_map_and_not_an_error(self, tmp_path): + flow = _options_flow(tmp_path, {"app_name_map": {"com.plex.hdx": "Plex"}}) + result = await _options_submit(flow, {"app_name_map": " "}, save=True) + + assert result["type"] == "create_entry" + assert result["data"]["app_name_map"] == {} + + +class TestReceiverAcceptsASwitch: + """t56: a soundbar that only knows on/off is a valid receiver.""" + + def test_the_selector_offers_switch_and_media_player(self): + module = _form_module() + config = module._build_selectors()["receiver"].config + + assert sorted(config.domain) == ["media_player", "switch"] + + async def test_a_switch_stores_and_prefills(self, tmp_path): + flow = _options_flow(tmp_path, options={}) + + result = await _options_submit( + flow, + {"receiver_entity": "switch.soundbar", "receiver_input": "HDMI 2"}, + save=True, + ) + + assert result["data"]["receiver_entity"] == "switch.soundbar" + assert result["data"]["receiver_input"] == "HDMI 2" + + reopened = _options_flow(tmp_path, result["data"]) + suggested = (await _options_open(reopened, "receiver"))["data_schema"][ + "__suggested__" + ] + assert suggested["receiver_entity"] == "switch.soundbar" + + +class TestSetupMenu: + """t56: the setup flow keeps its first form and adds a menu after it.""" + + async def test_the_setup_menu_offers_the_sub_screens_and_finish( + self, tmp_path, monkeypatch + ): + module = _form_module() + _patch_adb(monkeypatch, module) + flow, _hass = _new_flow(module, tmp_path) + await flow.async_step_user( + _user_input(**{CONF_ADB_ENABLED: False, CONF_PROTOCOL_ENABLED: False}) + ) + + menu = await flow.async_step_menu(None) + + assert menu["type"] == "menu" + assert menu["step_id"] == "menu" + assert list(menu["menu_options"]) == ["receiver", "connection", "finish"] + + async def test_every_setup_row_has_a_handler_named_after_it( + self, tmp_path, monkeypatch + ): + module = _form_module() + _patch_adb(monkeypatch, module) + flow, _hass = _new_flow(module, tmp_path) + await flow.async_step_user( + _user_input(**{CONF_ADB_ENABLED: False, CONF_PROTOCOL_ENABLED: False}) + ) + + menu = await flow.async_step_menu(None) + + for row in menu["menu_options"]: + assert hasattr(flow, f"async_step_{row}"), row + # ...and the row ids never include the menu itself, which would loop + assert "menu" not in menu["menu_options"] + + async def test_connection_and_receiver_edits_both_land_in_the_entry( + self, tmp_path, monkeypatch + ): + module = _form_module() + _patch_adb(monkeypatch, module) + flow, _hass = _new_flow(module, tmp_path) + await flow.async_step_user( + _user_input( + **{ + CONF_ADB_ENABLED: False, + CONF_PROTOCOL_ENABLED: False, + CONF_HOST: DISCOVERED_HOST, + } + ) + ) + + await flow.async_step_connection({CONF_HOST: "10.0.0.77"}) + await flow.async_step_receiver( + {"receiver_entity": "switch.soundbar", "receiver_input": "HDMI 1"} + ) + result = await flow.async_step_finish(None) + + assert result["type"] == "create_entry" + assert flow._host() == "10.0.0.77" + assert result["options"].get("receiver_entity") == "switch.soundbar" + assert result["options"].get("receiver_input") == "HDMI 1" diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 457b316..37bb1d1 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -27,6 +27,8 @@ from nsh.const import ( CONF_ANDROIDTV_ENTITY, CONF_HOST, CONF_PROTOCOL_ENABLED, + SCRIPT_COMMANDS, + SCRIPT_FIELD_PREFIX, ) _STUB_NAMES = ( @@ -101,6 +103,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): + # t56: the real MENU result shape — the flow manager dispatches the + # chosen `next_step_id` to `async_step_(None)`. + return { + "type": "menu", + "step_id": step_id, + "menu_options": list(menu_options), + "errors": dict(errors or {}), + } + def async_create_entry(self, *, title, data, options=None): return {"type": "create_entry", "title": title, "data": data, "options": options} @@ -144,6 +157,17 @@ def _install_stubs() -> None: "data_schema": data_schema, } + def async_show_menu(self, *, step_id, menu_options, sort=False, + description_placeholders=None, errors=None): + # t56: the real MENU result shape — the flow manager dispatches the + # chosen `next_step_id` to `async_step_(None)`. + return { + "type": "menu", + "step_id": step_id, + "menu_options": list(menu_options), + "errors": dict(errors or {}), + } + def async_create_entry(self, *, title, data): return {"type": "create_entry", "title": title, "data": data} @@ -157,6 +181,9 @@ def _install_stubs() -> None: return schema class OptionsFlowWithReload(_SuggestedMixin): + # t56: the MENU surface is shared with OptionsFlow, like async_show_form. + async_show_menu = OptionsFlow.async_show_menu + 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) @@ -476,6 +503,82 @@ class TestMigrationDropsTheEntityKey: assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3 +_OPTIONS_SUBSTEP_FIELDS = { + "connection": { + CONF_HOST, + CONF_ADB_PORT, + CONF_ADB_ENABLED, + CONF_PROTOCOL_ENABLED, + "adb_store_app_info", + "logcat_events", + "adb_command_enabled", + "jellyfin_entity", + "power_entity", + }, + "receiver": {"receiver_entity", "receiver_input"}, + "apps": {"app_name_map", "app_icon_map"}, + "scripts": { + "remote_command_script", + "channel_digit_delay", + "channel_send_enter", + "command_scripts", + *{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS}, + }, +} + + +def _options_step_of(key: str) -> str: + for step, fields in _OPTIONS_SUBSTEP_FIELDS.items(): + if key in fields: + return step + if key.startswith(SCRIPT_FIELD_PREFIX): + return "scripts" + raise AssertionError(f"no options sub-screen owns {key!r}") + + +async def _options_submit(flow, payload: dict, *, save=False): + """Drive the t56 options MENU into the sub-screens owning `payload` keys. + + Returns the last step result, so an error assertion reads the same as it did + when the page was a single form. With ``save=True`` the menu's "Save and + close" row is chosen afterwards, returning the create_entry result. + """ + grouped: dict[str, dict] = {} + for key, value in payload.items(): + grouped.setdefault(_options_step_of(key), {})[key] = value + result = await flow.async_step_init(None) + for step, sub in grouped.items(): + assert step in result["menu_options"], result["menu_options"] + result = await getattr(flow, f"async_step_{step}")(sub) + if save: + assert "finish" in result["menu_options"], result["menu_options"] + result = await flow.async_step_finish(None) + return result + + +async def _options_open(flow, step: str): + """Choose an options sub-screen from the menu and open its form.""" + result = await flow.async_step_init(None) + assert step in result["menu_options"], result["menu_options"] + return await getattr(flow, f"async_step_{step}")(None) + + +async def _setup_finish(flow, result): + """Walk the t56 setup MENU to the entry, starting from a step result. + + Chooses any pairing row it needs to (the flow's menu_options are the source + of truth) and then Finish, returning the create_entry result. + """ + while result["type"] == "menu": + choice = ( + "finish" + if "finish" in result["menu_options"] + else result["menu_options"][0] + ) + result = await getattr(flow, f"async_step_{choice}")(None) + return result + + def _cmd_player(modules): """Standalone media_player entity via the command-routing harness.""" import test_command_routing as routing @@ -496,7 +599,7 @@ class TestAppIconColumn: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"}) + await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"}) assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"} assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"} @@ -510,7 +613,7 @@ class TestAppIconColumn: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"}) + await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate"}) assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"} assert flow._options["app_icon_map"] == {} # blank-safe (no icons) @@ -540,7 +643,7 @@ class TestAppIconColumn: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"}) + result = await _options_submit(flow, {"app_name_map": "p=Name|has spaces"}) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_app_map"} @@ -562,11 +665,11 @@ class TestAppIconColumn: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - result = await flow.async_step_init( - {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"} + result = await _options_submit( + flow, {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"} ) - assert result["type"] == "form" + assert result["type"] == "form" # the Apps sub-screen re-rendered assert result["errors"] == {"base": "invalid_app_map"} assert "app_name_map" not in flow._options # nothing half-stored @@ -592,7 +695,7 @@ class TestChannelEntryOptions: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True}) + await _options_submit(flow, {"channel_digit_delay": "0.3", "channel_send_enter": True}) assert flow._options["channel_digit_delay"] == 0.3 assert flow._options["channel_send_enter"] is True @@ -603,7 +706,7 @@ class TestChannelEntryOptions: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - result = await flow.async_step_init({"channel_digit_delay": "2.0"}) + result = await _options_submit(flow, {"channel_digit_delay": "2.0"}) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_digit_delay"} @@ -614,7 +717,7 @@ class TestChannelEntryOptions: flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} - result = await flow.async_step_init({"channel_digit_delay": "slow"}) + result = await _options_submit(flow, {"channel_digit_delay": "slow"}) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_digit_delay"} @@ -1161,12 +1264,14 @@ class TestAppsMapOptionsEditor: async def test_map_text_stored_as_dict(self, tmp_path): flow = self._options_flow(tmp_path, options={}) - result = await flow.async_step_init( - {"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"} + result = await _options_submit( + flow, + {"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"}, + save=True, ) assert result["type"] == "create_entry" - assert flow._options["app_name_map"] == { + assert result["data"]["app_name_map"] == { "ar.tvplayer.tv": "TiviMate", "com.plex.hdx": "Plex", } @@ -1174,7 +1279,7 @@ class TestAppsMapOptionsEditor: async def test_invalid_map_shows_form_error(self, tmp_path): flow = self._options_flow(tmp_path, options={}) - result = await flow.async_step_init({"app_name_map": "no equals sign here"}) + result = await _options_submit(flow, {"app_name_map": "no equals sign here"}) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_app_map"} @@ -1184,10 +1289,10 @@ class TestAppsMapOptionsEditor: tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}} ) - result = await flow.async_step_init({"app_name_map": " "}) + result = await _options_submit(flow, {"app_name_map": " "}, save=True) assert result["type"] == "create_entry" - assert flow._options["app_name_map"] == {} + assert result["data"]["app_name_map"] == {} async def test_existing_map_prefills_the_text_field(self, tmp_path): flow = self._options_flow( @@ -1195,10 +1300,13 @@ class TestAppsMapOptionsEditor: ) # first visit: the form carries the serialized current map as the - # field default - result = await flow.async_step_init(None) + # field default — NEVER the dict (that is the [object Object] bug) + result = await _options_open(flow, "apps") assert result["type"] == "form" + fields = getattr(result["data_schema"], "schema", result["data_schema"]) + suggested = fields.get("__suggested__", {}) + assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate" # the editor carried the existing map through the defaults path assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}