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:
1 parent
b0adbd2dfd
commit
ca3fdec218
6 files changed
+1146
-74
No files matched your search
+11
-2
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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_<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):
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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_<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):
|
||||
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]
|
||||
|
||||
|
||||
@@ -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"
|
||||
+499
-55
@@ -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_<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):
|
||||
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_<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
@@ -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_<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):
|
||||
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_<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):
|
||||
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"}
|
||||
|
||||
|
||||
Reference in new issue
Block a user