test(config-flow): drive the menus, pin the tables, probe real HA

The flow tests drove `async_step_init` as if it were the one form, so they
had to learn the menu: a shared helper picks the sub-screen that owns a
payload's keys out of the flow's own `menu_options` (no test re-implements
the routing), and a `save=True` flag walks to "Save and close" so the
assertions read as they did before.

New coverage for this task:

- the menu offers every sub-screen and every row has a handler named
  after it, with the rendered step_id equal to the row id (the invariant
  real HA enforces and the first draft broke);
- edits on one sub-screen are not lost by another, and a page that is
  never saved never writes the entry;
- the apps table: add / edit / remove a row, the icon column round-trips,
  the empty state renders without data, a blank field clears the map, and
  the suggested value is TEXT (the `[object Object]` layer);
- a receiver the selector offers `switch` for, stored and pre-filled;
- the setup menu's rows, their handlers and a connection+receiver merge.

tests/test_config_flow_real_ha.py is new and is the empirical half: it
drives the real `FlowManager`, real probatio validation and the real
frontend serialiser (`to_field_list` + `cv.custom_serializer`) — the call
the HTTP flow view makes — so the menu dispatch, the cleared-selector
shapes and the wire serialisation are established rather than assumed.
It skips when homeassistant is absent; CI runs it with:

    uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py

53 of the stub tests and 11 of the real-HA ones fail on the pre-fix tree.
This commit is contained in:
James Mackay committed 2026-10-12 07:17:41 +01:00
1 parent b0adbd2dfd
commit ca3fdec218
6 files changed
+1145 -73

No files matched your search

+11 -2
View File
@@ -67,9 +67,18 @@ def install_selector_faces(selector_module) -> None:
installers in the flow tests), so the stubs cannot drift apart. installers in the flow tests), so the stubs cannot drift apart.
""" """
selector_module.EntitySelector = EntitySelectorStub 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.SelectSelector = lambda *args, **kwargs: None
selector_module.SelectSelectorConfig = lambda **kwargs: "" selector_module.SelectSelectorConfig = _Config
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown") selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
+14
View File
@@ -69,6 +69,17 @@ def _install_import_stubs() -> None:
def async_show_form(self, *, step_id, data_schema=None, errors=None): def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {}) 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_<choice>(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): def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data, return {"type": "create_entry", "title": title, "data": data,
"options": options} "options": options}
@@ -212,6 +223,9 @@ async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
mocked_test_connect.assert_awaited_once_with( mocked_test_connect.assert_awaited_once_with(
"shield.local", 5555, _KEY_PATH "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" assert result["type"] == "create_entry"
+17
View File
@@ -110,6 +110,17 @@ def _install_stubs() -> None:
def async_show_form(self, *, step_id, data_schema=None, errors=None): def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {}) 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_<choice>(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): def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data, return {"type": "create_entry", "title": title, "data": data,
"options": options} "options": options}
@@ -338,6 +349,10 @@ class TestProtocolPairing:
assert client.finished_codes == [FAKE_PAIRING_CODE] assert client.finished_codes == [FAKE_PAIRING_CODE]
assert flow._unique_id == FAKE_MAC assert flow._unique_id == FAKE_MAC
assert client.disconnected == 1 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["type"] == "create_entry"
assert result["options"][CONF_PROTOCOL_ENABLED] is True assert result["options"][CONF_PROTOCOL_ENABLED] is True
assert result["options"][CONF_HOST] == "shield.local" assert result["options"][CONF_HOST] == "shield.local"
@@ -354,6 +369,8 @@ class TestProtocolPairing:
result = await flow.async_step_protocol_pair( result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE} {CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
) )
assert result["type"] == "menu"
result = await flow.async_step_finish(None)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert client.finished_codes == [FAKE_PAIRING_CODE] assert client.finished_codes == [FAKE_PAIRING_CODE]
+480
View File
@@ -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"
+498 -54
View File
@@ -31,11 +31,15 @@ import pytest
import voluptuous_mirror as vol_mirror import voluptuous_mirror as vol_mirror
from conftest import _SHIELD_DIR, _load_module from conftest import _SHIELD_DIR, _load_module
from nsh.const import ( from nsh.const import (
CONF_ADB_COMMAND_ENABLED,
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_CHANNEL_DIGIT_DELAY,
CONF_COMMAND_SCRIPTS, CONF_COMMAND_SCRIPTS,
CONF_HOST, CONF_HOST,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_LOGCAT_EVENTS,
CONF_PAIRING_CODE, CONF_PAIRING_CODE,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
@@ -103,6 +107,19 @@ def _install_stubs() -> None:
"description_placeholders": dict(description_placeholders or {}), "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_<choice>(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): def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data, return {"type": "create_entry", "title": title, "data": data,
"options": options} "options": options}
@@ -182,6 +199,17 @@ def _install_stubs() -> None:
"description_placeholders": dict(description_placeholders or {}), "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: class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested): def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or ( schema_dict = getattr(schema, "schema", None) or (
@@ -864,8 +892,10 @@ class TestNoDeadEndPairing:
) )
assert form["step_id"] == "adb_pair" 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 result["type"] == "create_entry"
assert test_connect.await_count == 0 # nothing was paired assert test_connect.await_count == 0 # nothing was paired
# the intent survives, so the Pair ADB button stays available for later # 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}) 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( async def test_skip_protocol_after_skip_adb_creates_the_entry(
self, tmp_path, monkeypatch self, tmp_path, monkeypatch
@@ -941,15 +975,21 @@ class TestNoDeadEndPairing:
) )
assert form["step_id"] == "protocol_pair" 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} {CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
) )
assert result["type"] == "create_entry" # t56: a finished pairing returns to the setup menu (the just-paired
assert result["options"][CONF_PROTOCOL_ENABLED] is True # 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 client.finished_codes == [FAKE_PAIRING_CODE]
assert flow._unique_id == FAKE_MAC 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): async def test_normal_adb_pair_path_still_creates_the_entry(self, tmp_path, monkeypatch):
module = _form_module() module = _form_module()
test_connect = _patch_adb(monkeypatch, module) test_connect = _patch_adb(monkeypatch, module)
@@ -965,11 +1005,17 @@ class TestNoDeadEndPairing:
) )
assert form["step_id"] == "adb_pair" 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["type"] == "create_entry"
assert result["options"][CONF_ADB_ENABLED] is True 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): 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.""" """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( result = await flow.async_step_adb_pair(
{CONF_HOST: "10.0.0.77", CONF_ADB_PORT: 5555} {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 created["type"] == "create_entry"
assert result["options"][CONF_HOST] == "10.0.0.77" assert created["options"][CONF_HOST] == "10.0.0.77"
assert test_connect.await_args.args[0] == "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): 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" 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["type"] == "form"
assert result["step_id"] == "protocol_pair" assert result["step_id"] == "protocol_pair"
assert result["errors"] == {"base": "protocol_unreachable"} assert result["errors"] == {"base": "protocol_unreachable"}
@@ -1434,19 +1487,43 @@ class TestCopyContract:
assert field in steps[step]["data"], f"{step}.{field} has no label" 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): 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() module = _form_module()
flow = module.NvidiaShieldOptionsFlow() flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield") flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield")
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
form = await flow.async_step_init(None) menu = await flow.async_step_init(None)
fields = vol_mirror.declared_fields(form["data_schema"]) # 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()): for file in (_strings(), _en_strings()):
step = file["options"]["step"]["init"] menu_step = file["options"]["step"]["init"]
assert step["description"] 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: for field in fields:
assert field in step["data"], f"options.{field} has no label" assert field in steps[step]["data"], f"{step}.{field} has no label"
def _options_flow(tmp_path, options=None): def _options_flow(tmp_path, options=None):
@@ -1460,6 +1537,86 @@ def _options_flow(tmp_path, options=None):
return flow 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: class TestCommandScriptOptions:
"""t44: one script selector per command, stored as one dict option. """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): async def test_a_script_selector_exists_for_every_command(self, tmp_path):
flow = self._flow(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"])) fields = set(vol_mirror.declared_fields(form["data_schema"]))
assert {f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS} <= fields 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): async def test_saving_stores_one_dict_option_and_no_flat_keys(self, tmp_path):
flow = self._flow(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}volume_set": "script.shield_volume",
f"{SCRIPT_FIELD_PREFIX}turn_on": "script.shield_wake", f"{SCRIPT_FIELD_PREFIX}turn_on": "script.shield_wake",
} },
save=True,
) )
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
@@ -1506,7 +1665,7 @@ class TestCommandScriptOptions:
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} 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__", {}) suggested = self._submitted(form["data_schema"]).get("__suggested__", {})
assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.shield_wake" 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 assert f"{SCRIPT_FIELD_PREFIX}volume_up" not in suggested
async def test_clearing_every_script_removes_the_option(self, tmp_path): 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( flow = self._flow(
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}} 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] == {} assert result["data"][CONF_COMMAND_SCRIPTS] == {}
@@ -1529,15 +1688,34 @@ class TestCommandScriptOptions:
{CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake", "gone": "script.x"}}, {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"} 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): 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.""" """A string where the dict belongs must not break the form."""
flow = self._flow(tmp_path, {CONF_COMMAND_SCRIPTS: "script.shield_wake"}) 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 form["type"] == "form"
assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields( assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields(
@@ -1582,7 +1760,7 @@ class TestClearingAnEntityField:
flow = _options_flow( flow = _options_flow(
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}} 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): for value in ("", None):
assert self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": value}) == { assert self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": value}) == {
@@ -1616,15 +1794,18 @@ class TestClearingAnEntityField:
flow = _options_flow( flow = _options_flow(
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}} 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 # what HA does: validate the submitted page, then run the step — the
result = await flow.async_step_init( # whole Scripts page, with the one selector cleared
self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": value}) 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" created = await flow.async_step_finish(None)
assert result["data"][CONF_COMMAND_SCRIPTS] == {} assert created["type"] == "create_entry"
assert created["data"][CONF_COMMAND_SCRIPTS] == {}
@pytest.mark.parametrize("value", ["", None]) @pytest.mark.parametrize("value", ["", None])
async def test_an_explicit_empty_value_clears_a_preexisting_entity_field( async def test_an_explicit_empty_value_clears_a_preexisting_entity_field(
@@ -1635,34 +1816,42 @@ class TestClearingAnEntityField:
tmp_path, tmp_path,
{CONF_JELLYFIN_ENTITY: "media_player.jellyfin", CONF_HOST: "10.0.0.9"}, {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}) 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 not result["data"][CONF_JELLYFIN_ENTITY] # cleared, not kept
assert result["data"][CONF_HOST] == "10.0.0.9" # untouched fields survive 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): 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( flow = _options_flow(
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}} 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] == {} assert result["data"][CONF_COMMAND_SCRIPTS] == {}
async def test_an_omitted_entity_field_keeps_its_stored_value(self, tmp_path): 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( flow = _options_flow(
tmp_path, {CONF_JELLYFIN_ENTITY: "media_player.jellyfin"} 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" assert result["data"][CONF_JELLYFIN_ENTITY] == "media_player.jellyfin"
@@ -1674,22 +1863,29 @@ class TestClearingAnEntityField:
CONF_HOST: "10.0.0.9", CONF_HOST: "10.0.0.9",
} }
flow = _options_flow(tmp_path, stored) flow = _options_flow(tmp_path, stored)
form = await flow.async_step_init(None) scripts_form = await _options_open(flow, "scripts")
suggested = form["data_schema"].get("__suggested__", {}) suggested = scripts_form["data_schema"].get("__suggested__", {})
# the page comes back carrying the stored values, and the user submits them
assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.watch" 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( 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_JELLYFIN_ENTITY: "media_player.jellyfin",
CONF_POWER_ENTITY: "switch.plug", CONF_POWER_ENTITY: "switch.plug",
CONF_HOST: "10.0.0.9", 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_COMMAND_SCRIPTS] == {"turn_on": "script.watch"}
assert result["data"][CONF_JELLYFIN_ENTITY] == "media_player.jellyfin" 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): async def test_the_field_is_offered_and_stored_as_a_string(self, tmp_path):
flow = _options_flow(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( assert CONF_REMOTE_COMMAND_SCRIPT in vol_mirror.declared_fields(
form["data_schema"] form["data_schema"]
) )
result = await flow.async_step_init( result = await _options_submit(
{CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"} flow, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"}, save=True
) )
assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote" assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote"
@@ -1718,7 +1914,7 @@ class TestRemoteCommandScriptOption:
flow = _options_flow( flow = _options_flow(
tmp_path, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"} 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] == ( assert form["data_schema"]["__suggested__"][CONF_REMOTE_COMMAND_SCRIPT] == (
"script.shield_remote" "script.shield_remote"
@@ -1727,8 +1923,8 @@ class TestRemoteCommandScriptOption:
async def test_a_padded_value_is_trimmed(self, tmp_path): async def test_a_padded_value_is_trimmed(self, tmp_path):
flow = _options_flow(tmp_path) flow = _options_flow(tmp_path)
result = await flow.async_step_init( result = await _options_submit(
{CONF_REMOTE_COMMAND_SCRIPT: " script.shield_remote "} flow, {CONF_REMOTE_COMMAND_SCRIPT: " script.shield_remote "}, save=True
) )
assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote" assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote"
@@ -1737,12 +1933,13 @@ class TestRemoteCommandScriptOption:
flow = _options_flow( flow = _options_flow(
tmp_path, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"} 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( validated = TestClearingAnEntityField._validate(
form, {CONF_REMOTE_COMMAND_SCRIPT: ""} 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] == "" assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == ""
@@ -1770,3 +1967,250 @@ class TestPassiveSetupNeverDemandsReadiness:
assert "ConfigEntryNotReady" not in body # never demands readiness assert "ConfigEntryNotReady" not in body # never demands readiness
assert "raise " not in body 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_<row id>`` and a
submitted form to ``async_step_<its own step_id>``, 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"
+125 -17
View File
@@ -27,6 +27,8 @@ from nsh.const import (
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_HOST, CONF_HOST,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
SCRIPT_COMMANDS,
SCRIPT_FIELD_PREFIX,
) )
_STUB_NAMES = ( _STUB_NAMES = (
@@ -101,6 +103,17 @@ def _install_stubs() -> None:
"description_placeholders": dict(description_placeholders or {}), "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_<choice>(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): def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data, return {"type": "create_entry", "title": title, "data": data,
"options": options} "options": options}
@@ -144,6 +157,17 @@ def _install_stubs() -> None:
"data_schema": data_schema, "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_<choice>(None)`.
return {
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"errors": dict(errors or {}),
}
def async_create_entry(self, *, title, data): def async_create_entry(self, *, title, data):
return {"type": "create_entry", "title": title, "data": data} return {"type": "create_entry", "title": title, "data": data}
@@ -157,6 +181,9 @@ def _install_stubs() -> None:
return schema return schema
class OptionsFlowWithReload(_SuggestedMixin): 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): def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None) entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None) scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
@@ -476,6 +503,82 @@ class TestMigrationDropsTheEntityKey:
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3 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): def _cmd_player(modules):
"""Standalone media_player entity via the command-routing harness.""" """Standalone media_player entity via the command-routing harness."""
import test_command_routing as routing import test_command_routing as routing
@@ -496,7 +599,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **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_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"} 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_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **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_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {} # blank-safe (no icons) 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_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **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["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"} assert result["errors"] == {"base": "invalid_app_map"}
@@ -562,11 +665,11 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init( result = await _options_submit(
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"} 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 result["errors"] == {"base": "invalid_app_map"}
assert "app_name_map" not in flow._options # nothing half-stored 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_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **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_digit_delay"] == 0.3
assert flow._options["channel_send_enter"] is True 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_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **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["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"} assert result["errors"] == {"base": "invalid_digit_delay"}
@@ -614,7 +717,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **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["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"} assert result["errors"] == {"base": "invalid_digit_delay"}
@@ -1161,12 +1264,14 @@ class TestAppsMapOptionsEditor:
async def test_map_text_stored_as_dict(self, tmp_path): async def test_map_text_stored_as_dict(self, tmp_path):
flow = self._options_flow(tmp_path, options={}) flow = self._options_flow(tmp_path, options={})
result = await flow.async_step_init( result = await _options_submit(
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"} flow,
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"},
save=True,
) )
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert flow._options["app_name_map"] == { assert result["data"]["app_name_map"] == {
"ar.tvplayer.tv": "TiviMate", "ar.tvplayer.tv": "TiviMate",
"com.plex.hdx": "Plex", "com.plex.hdx": "Plex",
} }
@@ -1174,7 +1279,7 @@ class TestAppsMapOptionsEditor:
async def test_invalid_map_shows_form_error(self, tmp_path): async def test_invalid_map_shows_form_error(self, tmp_path):
flow = self._options_flow(tmp_path, options={}) 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["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"} assert result["errors"] == {"base": "invalid_app_map"}
@@ -1184,10 +1289,10 @@ class TestAppsMapOptionsEditor:
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}} 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 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): async def test_existing_map_prefills_the_text_field(self, tmp_path):
flow = self._options_flow( flow = self._options_flow(
@@ -1195,10 +1300,13 @@ class TestAppsMapOptionsEditor:
) )
# first visit: the form carries the serialized current map as the # first visit: the form carries the serialized current map as the
# field default # field default — NEVER the dict (that is the [object Object] bug)
result = await flow.async_step_init(None) result = await _options_open(flow, "apps")
assert result["type"] == "form" 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 # the editor carried the existing map through the defaults path
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"} assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}