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
+1145
-73
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.
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user