Completes the intents feature: part 1 shipped the engine, the buttons and the
action; this is the surface a user actually touches, plus the evidence.
THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the
same editor shape as the Apps table, because HA has no table selector: a menu of
row actions, one row per step. Add and edit share ONE form, reached through a
select of the registered intents whose last option is an explicit "Add a new
intent" sentinel — so adding is a choice the user can see, not an empty-string
convention. The per-intent form is name, package, component OR action,
categories, a data template and the declared variables as text
(`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for
confirmation and takes nothing until the user confirms. "Scan an app's intents"
asks for ONE package (a dropdown of discovered apps with free text), runs the
device-side scan, and offers the candidates; each candidate is marked when it
looks like an internal hook, and a candidate whose path is a PATTERN prefills no
URL with a note saying why, because a pattern is not a link. A scan that cannot
run reports t16's status vocabulary in words a user can act on — ADB off, ADB
down, or the app really advertising nothing are three different sentences.
Nothing half-stored: a submission that fails writes NOTHING and the form comes
back naming each problem (a component without its slash, a package with a space,
a duplicate name, a data template using an undeclared placeholder, a fields line
that does not parse), and a successful edit REPLACES the row where it already is
instead of moving it to the end of the list under the user's eyes.
The screen's reader of `registered_intents` is a second implementation of the
runtime's one — deliberately, because config_flow must stay importable without
Home Assistant's media_player platform. A test drives BOTH over the same battery
of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and
requires identical results, so the two cannot drift into meaning different
things.
THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME
validator the form uses, so a seeded intent is identical to a typed one; a row it
refuses is dropped with a warning NAMING the row and the reason (a seed is
configuration.yaml: there is no form to show an error on, and a silent skip would
be indistinguishable from a typo). It is honoured only while the UI holds nothing
— at creation, because the Intents screen never writes a seed back and the import
step aborts for a known host first.
THE STRINGS (both files, byte-identical) carry every new step, field, menu row
and the action's own description, plus the exceptions the action raises. The two
shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate
`{service}`, because `send_intent` reuses them verbatim and "for this action" was
`adb_command`-specific copy.
THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in
test_command_routing.py). The command: the exact `am start` line per declared
type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted
argument (asserted through shlex, not by eyeballing), a value that looks like a
placeholder being data rather than a second substitution, `{id.__class__}` left
alone because there is no format()/eval() path at all, and each refusal naming
the field. The scan: the status sentences, the candidate prefill, the pattern
case, an internal-looking candidate still being offered. The seed: warned drops,
and the seeded row being byte-identical to the form's. The buttons: one entity
per registration, its identity keyed by the intent's id so a rename does not
orphan it, defaults sent, and a press that cannot reach the Shield RAISING
instead of doing nothing.
test_command_routing's registry test used to hand-write the dict and set the OLD
attribute name, which is exactly why it never noticed that the add hook registered
`None` for a real entry and that the remove hook dropped nothing. It now drives
the real hooks, and pins both registries plus the ruling that registered intents do
NOT require the adb_command opt-in (a raw shell door and a registered intent are
different consents).
Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new
tests are additive), ruff clean, both strings files byte-identical.
2830 lines
111 KiB
Python
2830 lines
111 KiB
Python
"""t33: config-flow UX — discovery prefill, no dead-end pairing, plain copy.
|
|
|
|
Three defects from the user's live discovery run are pinned here so they cannot
|
|
come back:
|
|
|
|
* the discovered address never reached the form — the host keys were
|
|
re-declared without a default (a no-op), so the user submitted a blank host
|
|
and was told to fix a field on a step they could not return to;
|
|
* both pairing steps were dead ends when the Shield was unreachable (no way to
|
|
finish the entry);
|
|
* the copy pointed at "the previous step" and left the ADB port looking like
|
|
required knowledge.
|
|
|
|
Schemas are rendered through ``tests/voluptuous_mirror.py``, which keeps the
|
|
default on the KEY the way real voluptuous does, so a dropped default FAILS
|
|
here. The pre-t33 tests asserted only that ``_discovered_host`` was stored and
|
|
stayed green while the form rendered blank.
|
|
"""
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
import re
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import entity_stublib
|
|
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_APP_ICON_MAP,
|
|
CONF_APP_NAME_MAP,
|
|
CONF_CHANNEL_DIGIT_DELAY,
|
|
CONF_COMMAND_SCRIPTS,
|
|
CONF_HOST,
|
|
CONF_JELLYFIN_ENTITY,
|
|
CONF_LOGCAT_EVENTS,
|
|
CONF_PAIRING_CODE,
|
|
CONF_POWER_ENTITY,
|
|
CONF_PROTOCOL_ENABLED,
|
|
CONF_REMOTE_COMMAND_SCRIPT,
|
|
CONF_SCREEN_CAPTURE_ENABLED,
|
|
CONF_SKIP_PAIRING,
|
|
DEFAULT_ADB_PORT,
|
|
SCRIPT_COMMANDS,
|
|
SCRIPT_FIELD_PREFIX,
|
|
)
|
|
|
|
BASE = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
|
DISCOVERED_HOST = "192.168.1.50"
|
|
FAKE_PAIRING_CODE = "8421"
|
|
FAKE_MAC = "aa:bb:cc:dd:ee:ff"
|
|
KEY_PATH = "/tmp/nsh-ux-adbkey"
|
|
|
|
_STUB_NAMES = (
|
|
"voluptuous",
|
|
"homeassistant",
|
|
"homeassistant.config_entries",
|
|
"homeassistant.core",
|
|
"homeassistant.const",
|
|
"homeassistant.helpers",
|
|
"homeassistant.helpers.entity_registry",
|
|
"homeassistant.helpers.device_registry",
|
|
"homeassistant.helpers.selector",
|
|
"nsh.helpers",
|
|
"nsh.config_flow",
|
|
# the integration __init__ module binds stub classes at import time, so it
|
|
# is rebuilt per call and must never leak into a later file (t30 G6)
|
|
"nsh.init",
|
|
)
|
|
|
|
|
|
def _install_stubs() -> None:
|
|
"""Import-time stand-ins for HA + the faithful voluptuous mirror."""
|
|
if sys.modules.get("voluptuous") is not None:
|
|
return
|
|
|
|
vol = types.ModuleType("voluptuous")
|
|
vol_mirror.install(vol)
|
|
sys.modules["voluptuous"] = vol
|
|
|
|
ha = types.ModuleType("homeassistant")
|
|
sys.modules["homeassistant"] = ha
|
|
|
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
|
|
|
class ConfigFlow:
|
|
VERSION = 1
|
|
|
|
def __init_subclass__(cls, **kwargs):
|
|
kwargs.pop("domain", None)
|
|
super().__init_subclass__(**kwargs)
|
|
|
|
def async_show_form(
|
|
self, *, step_id, data_schema=None, errors=None,
|
|
description_placeholders=None,
|
|
):
|
|
return {
|
|
"type": "form",
|
|
"step_id": step_id,
|
|
"errors": dict(errors or {}),
|
|
"data_schema": data_schema,
|
|
"description_placeholders": dict(description_placeholders or {}),
|
|
}
|
|
|
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
|
description_placeholders=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),
|
|
"description_placeholders": dict(
|
|
description_placeholders or {}
|
|
),
|
|
}
|
|
|
|
def async_create_entry(self, *, title, data, options=None):
|
|
return {"type": "create_entry", "title": title, "data": data,
|
|
"options": options}
|
|
|
|
def async_abort(self, *, reason):
|
|
return {"type": "abort", "reason": reason}
|
|
|
|
async def async_set_unique_id(self, unique_id):
|
|
self._unique_id = unique_id
|
|
|
|
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
|
|
return None
|
|
|
|
def _async_current_entries(self):
|
|
return []
|
|
|
|
async def _async_handle_discovery_without_unique_id(self):
|
|
"""Real HA helper is a coroutine (t37 D3) — awaiting is mandatory.
|
|
|
|
This file never configures entries for the handler, so the real
|
|
aborts (already_configured / already_in_progress) cannot trigger;
|
|
tests/test_standalone_setup.py exercises those semantics.
|
|
"""
|
|
self._discovery_dedup_calls = getattr(self, "_discovery_dedup_calls", 0) + 1
|
|
return None
|
|
|
|
def _get_reauth_entry(self, entry=None):
|
|
return entry
|
|
|
|
def _get_reconfigure_entry(self, entry=None):
|
|
"""Real HA reads the entry from the flow context (SOURCE_RECONFIGURE)."""
|
|
if entry is not None:
|
|
return entry
|
|
return self.hass.config_entries.async_get_entry(
|
|
(getattr(self, "context", None) or {}).get("entry_id")
|
|
)
|
|
|
|
@property
|
|
def source(self):
|
|
"""Real HA exposes the flow's source through the context."""
|
|
return (getattr(self, "context", None) or {}).get("source")
|
|
|
|
def async_update_reload_and_abort(self, entry, **kwargs):
|
|
"""Real-HA shape: update, schedule the reload, then abort.
|
|
|
|
``options`` REPLACES the entry's options (core has no
|
|
``options_updates``); the merge form is still accepted so the older
|
|
reauth call and this one can share the stub. HA picks the abort
|
|
reason from the flow source unless one is passed.
|
|
"""
|
|
for key, value in (kwargs.get("options") or {}).items():
|
|
entry.options[key] = value
|
|
for key, value in (kwargs.get("options_updates") or {}).items():
|
|
entry.options[key] = value
|
|
if kwargs.get("unique_id"):
|
|
entry.unique_id = kwargs["unique_id"]
|
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
|
reason = kwargs.get("reason")
|
|
if reason is None:
|
|
reason = (
|
|
"reconfigure_successful"
|
|
if self.source == "reconfigure"
|
|
else "reauth_successful"
|
|
)
|
|
return self.async_abort(reason=reason)
|
|
|
|
class OptionsFlow:
|
|
def async_show_form(
|
|
self, *, step_id, data_schema=None, errors=None,
|
|
description_placeholders=None,
|
|
):
|
|
return {
|
|
"type": "form",
|
|
"step_id": step_id,
|
|
"errors": dict(errors or {}),
|
|
"data_schema": data_schema,
|
|
"description_placeholders": dict(description_placeholders or {}),
|
|
}
|
|
|
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
|
description_placeholders=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),
|
|
"description_placeholders": dict(
|
|
description_placeholders or {}
|
|
),
|
|
}
|
|
|
|
class _SuggestedMixin:
|
|
def add_suggested_values_to_schema(self, schema, suggested):
|
|
schema_dict = getattr(schema, "schema", None) or (
|
|
schema if isinstance(schema, dict) else {}
|
|
)
|
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
|
return schema
|
|
|
|
class OptionsFlowWithReload(_SuggestedMixin, OptionsFlow):
|
|
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)
|
|
if entry_id is not None and scheduler is not None:
|
|
scheduler.async_schedule_reload(entry_id)
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
config_entries.ConfigFlow = ConfigFlow
|
|
config_entries.OptionsFlow = OptionsFlow
|
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
|
config_entries.SOURCE_IMPORT = "import"
|
|
sys.modules["homeassistant.config_entries"] = config_entries
|
|
|
|
core = types.ModuleType("homeassistant.core")
|
|
core.HomeAssistant = object
|
|
core.callback = lambda fn: fn
|
|
sys.modules["homeassistant.core"] = core
|
|
|
|
ha_const = types.ModuleType("homeassistant.const")
|
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
|
ha_const.Platform = types.SimpleNamespace(
|
|
MEDIA_PLAYER="media_player", SENSOR="sensor", REMOTE="remote",
|
|
BUTTON="button", IMAGE="image",
|
|
)
|
|
sys.modules["homeassistant.const"] = ha_const
|
|
|
|
helpers = types.ModuleType("homeassistant.helpers")
|
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
|
entity_registry.async_get = lambda hass: SimpleNamespace(entities={})
|
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
|
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
|
helpers.entity_registry = entity_registry
|
|
helpers.device_registry = device_registry
|
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
|
|
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
|
entity_stublib.install_selector_faces(selector)
|
|
helpers.selector = selector
|
|
sys.modules["homeassistant.helpers.selector"] = selector
|
|
sys.modules["homeassistant.helpers"] = helpers
|
|
|
|
|
|
class FakeConfigEntries:
|
|
def __init__(self):
|
|
self.by_id: dict = {}
|
|
self.forwarded: list = []
|
|
self.updating_entry_calls: list = []
|
|
self.reloaded_entry_ids: list[str] = []
|
|
|
|
def async_get_entry(self, entry_id):
|
|
return self.by_id.get(entry_id)
|
|
|
|
def async_update_entry(self, entry, **kwargs):
|
|
self.updating_entry_calls.append((entry, kwargs))
|
|
|
|
def async_schedule_reload(self, entry_id):
|
|
self.reloaded_entry_ids.append(entry_id)
|
|
|
|
async def async_forward_entry_setups(self, entry, platforms):
|
|
self.forwarded.append((entry, list(platforms)))
|
|
return True
|
|
|
|
async def async_unload_platforms(self, entry, platforms):
|
|
return True
|
|
|
|
|
|
class FakeHass:
|
|
"""Minimal hass: config dir, entries, task creation, event bus."""
|
|
|
|
def __init__(self, tmp_dir: str, states: dict | None = None):
|
|
self.config = SimpleNamespace(config_dir=tmp_dir)
|
|
self.config_entries = FakeConfigEntries()
|
|
self.data: dict = {}
|
|
self.tasks: list = []
|
|
self._states = states or {}
|
|
self.states = SimpleNamespace(get=self._states.get)
|
|
self.bus = SimpleNamespace(async_listen_once=lambda *a, **k: (lambda: None))
|
|
|
|
@staticmethod
|
|
async def async_add_executor_job(fn, *args):
|
|
return fn(*args)
|
|
|
|
def async_create_task(self, coro):
|
|
task = asyncio.ensure_future(coro)
|
|
self.tasks.append(task)
|
|
return task
|
|
|
|
def async_create_background_task(self, coro, name=None):
|
|
return self.async_create_task(coro)
|
|
|
|
|
|
class CannotConnectStubError(ConnectionError):
|
|
"""androidtvremote2``CannotConnect``: the Shield never answered (t44 F1)."""
|
|
|
|
|
|
class FakeProtocolClient:
|
|
"""Deterministic androidtvremote2 stand-in for the PIN pairing path."""
|
|
|
|
def __init__(self, fail_code: bool = False, start_raises: bool = False):
|
|
self.cert_generated = False
|
|
self.pairing_started = False
|
|
self.finished_codes: list[str] = []
|
|
self.disconnects = 0
|
|
self._fail_code = fail_code
|
|
# the Shield is asleep / rebooting / mis-addressed: the real library
|
|
# raises from async_start_pairing, which used to escape the config flow
|
|
self._start_raises = start_raises
|
|
|
|
async def async_generate_cert_if_missing(self):
|
|
if self._start_raises:
|
|
raise CannotConnectStubError("cert generation could not reach the Shield")
|
|
self.cert_generated = True
|
|
return True
|
|
|
|
async def async_start_pairing(self):
|
|
if self._start_raises:
|
|
raise CannotConnectStubError("no answer from the Shield")
|
|
self.pairing_started = True
|
|
return FAKE_PAIRING_CODE
|
|
|
|
async def async_finish_pairing(self, pairing_code: str) -> None:
|
|
if self._fail_code or pairing_code != FAKE_PAIRING_CODE:
|
|
raise RuntimeError("device rejected the pairing code")
|
|
self.finished_codes.append(pairing_code)
|
|
|
|
async def async_get_name_and_mac(self):
|
|
return "mdarcy", FAKE_MAC
|
|
|
|
def disconnect(self):
|
|
self.disconnects += 1
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def scoped_stubs():
|
|
"""Hard-reset the stub set per test, then restore the previous modules.
|
|
|
|
Popping first (instead of trusting an idempotence guard) keeps a stub that
|
|
another file left behind from shadowing the faithful voluptuous mirror
|
|
this file's schema assertions depend on.
|
|
"""
|
|
snapshot = entity_stublib.snapshot_modules()
|
|
for name in _STUB_NAMES:
|
|
sys.modules.pop(name, None)
|
|
_install_stubs()
|
|
yield
|
|
entity_stublib.restore_modules(snapshot)
|
|
|
|
|
|
def _form_module():
|
|
_install_stubs()
|
|
if "nsh.helpers" not in sys.modules:
|
|
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
|
if "nsh.config_flow" not in sys.modules:
|
|
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
|
return sys.modules["nsh.config_flow"]
|
|
|
|
|
|
def _integration_module():
|
|
"""The integration __init__ module, rebuilt against the current stubs."""
|
|
_install_stubs()
|
|
if "nsh.helpers" not in sys.modules:
|
|
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
|
_load_module("nsh.init", _SHIELD_DIR / "__init__.py")
|
|
return sys.modules["nsh.init"]
|
|
|
|
|
|
def _new_flow(module, tmp_path):
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
return flow, flow.hass
|
|
|
|
|
|
def _discovery(host=DISCOVERED_HOST, props=None):
|
|
return SimpleNamespace(ip_address=host, host=host, properties=props or {})
|
|
|
|
|
|
def _patch_protocol(monkeypatch, module, client=None):
|
|
"""Stub the protocol client factory + cert paths; return the fake client."""
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
client = client or FakeProtocolClient()
|
|
monkeypatch.setattr(
|
|
ShieldConnectionManager, "build_protocol_client", lambda *a, **k: client
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "protocol_cert_paths",
|
|
lambda hass: ("/tmp/nsh-ux.pem", "/tmp/nsh-ux.key"),
|
|
)
|
|
return client
|
|
|
|
|
|
def _patch_adb(monkeypatch, module, connected=True):
|
|
"""Stub the key store + the ADB handshake; return the test_connect mock."""
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: KEY_PATH)
|
|
test_connect = AsyncMock(return_value=connected)
|
|
monkeypatch.setattr(ShieldConnectionManager, "test_connect", test_connect)
|
|
return test_connect
|
|
|
|
|
|
def _user_input(**overrides) -> dict:
|
|
base = {
|
|
"name": "mdarcy",
|
|
CONF_ADB_ENABLED: False,
|
|
CONF_PROTOCOL_ENABLED: False,
|
|
CONF_HOST: "", # t41: one address field for both transports
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _entry_from(result):
|
|
"""The config-entry stand-in an entry flow's result describes."""
|
|
return SimpleNamespace(
|
|
entry_id="e1",
|
|
title=result["title"],
|
|
data=dict(result["data"] or {}),
|
|
options=dict(result["options"] or {}),
|
|
unique_id=None,
|
|
runtime_data=None,
|
|
async_on_unload=lambda _cb: None,
|
|
async_start_reauth=lambda _hass: None,
|
|
)
|
|
|
|
|
|
def _strings():
|
|
return json.loads((BASE / "strings.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _en_strings():
|
|
return json.loads((BASE / "translations" / "en.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _key_tree(node, path=()):
|
|
if isinstance(node, dict):
|
|
tree = set()
|
|
for key, value in node.items():
|
|
tree |= _key_tree(value, (*path, key))
|
|
return tree
|
|
return {path}
|
|
|
|
|
|
def _iter_string_leaves(node, path=()):
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
yield from _iter_string_leaves(value, (*path, key))
|
|
elif isinstance(node, str):
|
|
yield path, node
|
|
|
|
|
|
class TestVoluptuousMirrorContract:
|
|
"""The harness must be able to SEE a dropped default (else F-A is blind)."""
|
|
|
|
def test_a_default_on_the_declaration_is_visible(self):
|
|
vol = sys.modules["voluptuous"]
|
|
schema = {vol.Optional(CONF_HOST, default=DISCOVERED_HOST): str}
|
|
|
|
assert vol_mirror.defaults_by_key(schema)[CONF_HOST] == DISCOVERED_HOST
|
|
|
|
def test_a_later_redeclaration_cannot_add_the_default(self):
|
|
vol = sys.modules["voluptuous"]
|
|
# this is the t33 shape: declare bare, then "fix" it by re-declaring.
|
|
# Real voluptuous keeps the FIRST key object, so the default is dropped.
|
|
schema = {vol.Optional(CONF_HOST): str}
|
|
schema[vol.Optional(CONF_HOST, default=DISCOVERED_HOST)] = str
|
|
|
|
assert vol_mirror.defaults_by_key(schema)[CONF_HOST] is vol_mirror.UNDEFINED
|
|
|
|
def test_a_bare_string_key_is_rejected_loudly(self):
|
|
with pytest.raises(AssertionError):
|
|
vol_mirror.declared_fields({CONF_HOST: str})
|
|
|
|
def test_marker_equality_is_field_only_like_voluptuous(self):
|
|
"""t35 F2: real ``Marker.__eq__`` is ``self.schema == other``.
|
|
|
|
No class check: ``Optional("x") == Required("x")`` on voluptuous 0.16.0.
|
|
"""
|
|
vol = sys.modules["voluptuous"]
|
|
|
|
assert vol.Optional(CONF_HOST) == vol.Required(CONF_HOST)
|
|
assert hash(vol.Optional(CONF_HOST)) == hash(
|
|
vol.Required(CONF_HOST)
|
|
)
|
|
# the real implementation compares the field name to the other value
|
|
assert vol.Optional(CONF_HOST) == CONF_HOST
|
|
|
|
def test_a_mixed_marker_redeclaration_drops_the_default(self):
|
|
"""The case a class-comparing mirror masks (t35 F2).
|
|
|
|
``Required(field, default=...)`` after ``Optional(field)`` collides on
|
|
the field name, so the FIRST key object (no default) survives — the
|
|
schema silently loses the default, exactly like the Optional case.
|
|
"""
|
|
vol = sys.modules["voluptuous"]
|
|
schema = {vol.Optional(CONF_HOST): str}
|
|
schema[vol.Required(CONF_HOST, default=DISCOVERED_HOST)] = str
|
|
|
|
assert (
|
|
vol_mirror.defaults_by_key(schema)[CONF_HOST]
|
|
is vol_mirror.UNDEFINED
|
|
)
|
|
|
|
|
|
class TestDiscoveryPrefill:
|
|
"""F-A / F-A2 / F-D: the discovered address reaches the rendered FORM."""
|
|
|
|
async def test_discovered_host_is_the_default_of_the_host_field(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_zeroconf(_discovery())
|
|
|
|
assert result["type"] == "form"
|
|
assert result["step_id"] == "user"
|
|
# t37 D3: MAC-less discovery needs the AWAITED coroutine helper — the
|
|
# counter only moves when the call is awaited
|
|
assert flow._discovery_dedup_calls == 1
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
# schema-level, not "the attribute was stored": this is precisely what
|
|
# the old assertion missed while the form rendered blank
|
|
assert defaults[CONF_HOST] == DISCOVERED_HOST
|
|
|
|
async def test_the_host_field_carries_the_discovered_default(self, tmp_path):
|
|
"""The key is declared WITH the default — never re-declared bare."""
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_zeroconf(_discovery())
|
|
|
|
keys = [
|
|
key
|
|
for key in vol_mirror.declared_keys(result["data_schema"])
|
|
if key.schema == CONF_HOST
|
|
]
|
|
assert len(keys) == 1, f"the address field is declared {len(keys)} times"
|
|
assert keys[0].default == DISCOVERED_HOST
|
|
|
|
async def test_discovery_step_announces_the_known_address(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_zeroconf(_discovery())
|
|
|
|
note = result["description_placeholders"]["discovery_note"]
|
|
assert f"Discovered Shield at {DISCOVERED_HOST}" in note
|
|
|
|
async def test_manual_setup_claims_no_discovery(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_user(None)
|
|
|
|
assert result["description_placeholders"]["discovery_note"] == ""
|
|
assert (
|
|
vol_mirror.defaults_by_key(result["data_schema"])[CONF_HOST]
|
|
is vol_mirror.UNDEFINED
|
|
)
|
|
|
|
async def test_seed_hint_prefills_without_claiming_a_discovery(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_import({"name": "Living", "adb_host": "10.0.0.31"})
|
|
|
|
assert result["step_id"] == "user"
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_HOST] == "10.0.0.31"
|
|
assert defaults["name"] == "Living"
|
|
assert result["description_placeholders"]["discovery_note"] == ""
|
|
|
|
async def test_standard_adb_port_is_prefilled(self, tmp_path):
|
|
"""F-D: 5555 rides the form as an advanced-setting default."""
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
manual = await flow.async_step_user(None)
|
|
discovered = await flow.async_step_zeroconf(_discovery())
|
|
|
|
for result in (manual, discovered):
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_ADB_PORT] == DEFAULT_ADB_PORT == 5555
|
|
|
|
async def test_error_re_render_keeps_the_discovered_prefill(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
await flow.async_step_zeroconf(_discovery())
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: False})
|
|
)
|
|
|
|
assert result["errors"] == {"base": "no_state_source"}
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_HOST] == DISCOVERED_HOST
|
|
|
|
|
|
class TestJellyfinAutoDetect:
|
|
"""t41: auto-detect searches with the names setup already knows.
|
|
|
|
The removed tier resolved the Android TV Remote entity's ``friendly_name``
|
|
and searched the Jellyfin registry with THAT. What remains is the name the
|
|
user typed and the device name from discovery — and another integration's
|
|
entity is never consulted, not even as a search term.
|
|
"""
|
|
|
|
def _patch_registry(self, monkeypatch, entity_ids):
|
|
er = sys.modules["homeassistant.helpers.entity_registry"]
|
|
entities = {
|
|
eid: SimpleNamespace(
|
|
domain="media_player",
|
|
platform="jellyfin",
|
|
entity_id=eid,
|
|
original_name=eid.split(".", 1)[1].replace("_", " "),
|
|
disabled_by=None,
|
|
)
|
|
for eid in entity_ids
|
|
}
|
|
monkeypatch.setattr(
|
|
er, "async_get", lambda hass: SimpleNamespace(entities=entities)
|
|
)
|
|
|
|
async def test_the_typed_name_finds_the_jellyfin_player(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
self._patch_registry(monkeypatch, ["media_player.jellyfin_living_room"])
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
state = SimpleNamespace(state="idle")
|
|
hass.states = SimpleNamespace(
|
|
get=lambda eid: state if eid == "media_player.jellyfin_living_room" else None
|
|
)
|
|
|
|
await flow.async_step_user(
|
|
_user_input(**{"name": "Jellyfin Living Room", CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
|
|
assert flow._user_data[CONF_JELLYFIN_ENTITY] == "media_player.jellyfin_living_room"
|
|
|
|
async def test_the_discovered_device_name_is_the_second_tier(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
self._patch_registry(monkeypatch, ["media_player.jellyfin_living_room"])
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
state = SimpleNamespace(state="idle")
|
|
hass.states = SimpleNamespace(
|
|
get=lambda eid: state if eid == "media_player.jellyfin_living_room" else None
|
|
)
|
|
flow._discovered_name = "jellyfin living room"
|
|
|
|
await flow.async_step_user(
|
|
_user_input(**{"name": "Living Room TV", CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
|
|
assert flow._user_data[CONF_JELLYFIN_ENTITY] == "media_player.jellyfin_living_room"
|
|
|
|
async def test_another_integrations_entity_is_not_a_search_term(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The regression pin: the old tier searched with the atv friendly_name.
|
|
|
|
A Jellyfin player matches THAT name, and the state machine serves it, so
|
|
the pre-t41 code auto-detected it. Only the typed name may search now.
|
|
"""
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
self._patch_registry(monkeypatch, ["media_player.jellyfin_shield"])
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
atv_state = SimpleNamespace(
|
|
state="idle", attributes={"friendly_name": "jellyfin shield"}
|
|
)
|
|
state = SimpleNamespace(state="idle")
|
|
hass.states = SimpleNamespace(
|
|
get=lambda eid: state
|
|
if eid == "media_player.jellyfin_shield"
|
|
else (atv_state if eid == "media_player.shield" else None)
|
|
)
|
|
|
|
await flow.async_step_user(
|
|
_user_input(**{"name": "Living Room", CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
|
|
assert flow._user_data[CONF_JELLYFIN_ENTITY] == ""
|
|
|
|
|
|
class TestTransportsDefaultOn:
|
|
"""t41: the two native transports are OURS — both start enabled.
|
|
|
|
The ruling's other half: they are still choices. Turning either off (or
|
|
skipping its pairing) stays a first-class path, and unchecking BOTH is the
|
|
only way to reach ``no_state_source``.
|
|
"""
|
|
|
|
async def test_the_fresh_form_renders_both_toggles_checked(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_user(None)
|
|
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_ADB_ENABLED] is True
|
|
assert defaults[CONF_PROTOCOL_ENABLED] is True
|
|
# the ONE address field is offered, empty until discovery or typing
|
|
assert CONF_HOST in vol_mirror.declared_fields(result["data_schema"])
|
|
|
|
async def test_a_discovered_form_prefills_the_host_and_both_toggles(
|
|
self, tmp_path
|
|
):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_zeroconf(_discovery())
|
|
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_HOST] == DISCOVERED_HOST
|
|
assert defaults[CONF_ADB_ENABLED] is True
|
|
assert defaults[CONF_PROTOCOL_ENABLED] is True
|
|
|
|
async def test_a_submission_naming_neither_transport_is_rejected(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Fail closed: the step never assumes a transport the form did not send.
|
|
|
|
The rendered schema carries both defaults, so a real submission always
|
|
names them (t33 F-A) — but a payload without them must not silently turn
|
|
both transports on.
|
|
"""
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_user(
|
|
{"name": "mdarcy", CONF_HOST: DISCOVERED_HOST, CONF_ADB_PORT: 5555}
|
|
)
|
|
|
|
assert result["errors"] == {"base": "no_state_source"}
|
|
|
|
async def test_the_code_fallback_matches_the_rendered_default(self, tmp_path):
|
|
"""`_create_entry` records ON when its data never named the toggles.
|
|
|
|
t33 F-A's rule: the code's fallback must reproduce what the checkbox
|
|
promised, so a partially-filled `_user_data` (a YAML seed) cannot come
|
|
out of setup with both transports off.
|
|
"""
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
flow._user_data = {"name": "mdarcy", CONF_HOST: DISCOVERED_HOST}
|
|
|
|
result = flow._create_entry()
|
|
|
|
assert result["options"][CONF_ADB_ENABLED] is True
|
|
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
|
|
|
async def test_adb_alone_is_still_reachable(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
_patch_adb(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: False})
|
|
)
|
|
|
|
assert result["step_id"] == "adb_pair"
|
|
assert flow._user_data[CONF_PROTOCOL_ENABLED] is False
|
|
assert flow._user_data[CONF_ADB_ENABLED] is True
|
|
|
|
async def test_protocol_alone_is_still_reachable(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: False, CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
|
|
assert result["step_id"] == "protocol_pair"
|
|
assert flow._user_data[CONF_ADB_ENABLED] is False
|
|
|
|
async def test_neither_transport_is_the_only_error_path(self, tmp_path):
|
|
module = _form_module()
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: False, CONF_PROTOCOL_ENABLED: False})
|
|
)
|
|
|
|
assert result["errors"] == {"base": "no_state_source"}
|
|
|
|
|
|
class TestNoDeadEndPairing:
|
|
"""F-B: both pairing steps offer 'continue without pairing'."""
|
|
|
|
async def test_pairing_forms_offer_the_skip_and_the_field_errors_name(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
adb_form = await flow.async_step_adb_pair(None)
|
|
protocol_form = await flow.async_step_protocol_pair(None)
|
|
|
|
for form in (adb_form, protocol_form):
|
|
assert CONF_SKIP_PAIRING in vol_mirror.declared_fields(form["data_schema"])
|
|
|
|
adb_fields = vol_mirror.declared_fields(adb_form["data_schema"])
|
|
assert CONF_HOST in adb_fields # the field adb_no_host names
|
|
protocol_fields = vol_mirror.declared_fields(protocol_form["data_schema"])
|
|
assert CONF_HOST in protocol_fields # protocol_no_host names this
|
|
|
|
async def test_the_skip_option_is_off_by_default(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
adb_form = await flow.async_step_adb_pair(None)
|
|
protocol_form = await flow.async_step_protocol_pair(None)
|
|
|
|
for form in (adb_form, protocol_form):
|
|
defaults = vol_mirror.defaults_by_key(form["data_schema"])
|
|
assert defaults[CONF_SKIP_PAIRING] is False
|
|
|
|
async def test_skip_protocol_pairing_creates_a_passive_entry(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
client = _patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert form["step_id"] == "protocol_pair"
|
|
|
|
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["options"][CONF_PROTOCOL_ENABLED] is False # passive
|
|
assert client.disconnects == 1 # the half-open session is dropped
|
|
|
|
init = _integration_module()
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = _entry_from(result)
|
|
# no protocol transport configured, no ADB → a passive manager
|
|
assert await init._build_protocol_settings(hass, entry) is None
|
|
assert await init._build_poller_factory(hass, entry) is None
|
|
# and setup still accepts the entry (no ConfigEntryNotReady, no crash)
|
|
assert await init.async_setup_entry(hass, entry) is True
|
|
await entry.runtime_data.async_stop()
|
|
|
|
async def test_skip_adb_pairing_creates_a_passive_entry(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
test_connect = _patch_adb(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert form["step_id"] == "adb_pair"
|
|
|
|
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
|
|
assert result["options"][CONF_ADB_ENABLED] is True
|
|
|
|
init = _integration_module()
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = _entry_from(result)
|
|
# no key material yet → no poller factory, i.e. a passive manager
|
|
assert await init._build_poller_factory(hass, entry) is None
|
|
assert await init.async_setup_entry(hass, entry) is True
|
|
await entry.runtime_data.async_stop()
|
|
|
|
async def test_skip_adb_pairing_still_offers_the_protocol_step(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert form["step_id"] == "adb_pair"
|
|
|
|
result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
# 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_pair" 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
|
|
):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["options"][CONF_PROTOCOL_ENABLED] is False
|
|
|
|
async def test_normal_protocol_pair_path_still_creates_the_entry(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
client = _patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert form["step_id"] == "protocol_pair"
|
|
|
|
paired = await flow.async_step_protocol_pair(
|
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
|
)
|
|
|
|
# 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_pair" 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)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert form["step_id"] == "adb_pair"
|
|
|
|
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
|
|
|
|
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."""
|
|
module = _form_module()
|
|
test_connect = _patch_adb(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: True})
|
|
)
|
|
|
|
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 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):
|
|
module = _form_module()
|
|
first = FakeProtocolClient()
|
|
_patch_protocol(monkeypatch, module, first)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert first.pairing_started is True
|
|
|
|
# the host typed HERE replaces the wrong one and voids the session
|
|
second = FakeProtocolClient()
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
monkeypatch.setattr(
|
|
ShieldConnectionManager, "build_protocol_client", lambda *a, **k: second
|
|
)
|
|
form = await flow.async_step_protocol_pair({CONF_HOST: "10.0.0.77"})
|
|
|
|
assert form["step_id"] == "protocol_pair"
|
|
assert first.disconnects == 1
|
|
assert second.pairing_started is True
|
|
assert flow._user_data[CONF_HOST] == "10.0.0.77"
|
|
|
|
async def test_adb_no_host_error_keeps_the_user_on_a_fixable_form(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
_patch_adb(monkeypatch, module, connected=False)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: True})
|
|
)
|
|
|
|
result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555})
|
|
|
|
assert result["errors"] == {"base": "adb_no_host"}
|
|
assert CONF_HOST in vol_mirror.declared_fields(result["data_schema"])
|
|
|
|
async def test_adb_failed_error_keeps_the_user_on_the_form(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
_patch_adb(monkeypatch, module, connected=False)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
|
|
result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555})
|
|
|
|
assert result["errors"] == {"base": "adb_failed"}
|
|
assert CONF_SKIP_PAIRING in vol_mirror.declared_fields(result["data_schema"])
|
|
|
|
async def test_missing_protocol_library_reports_instead_of_crashing(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""No androidtvremote2 (the default test variant) must not raise."""
|
|
module = _form_module()
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
monkeypatch.setattr(
|
|
ShieldConnectionManager, "build_protocol_client", lambda *a, **k: None
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "protocol_cert_paths",
|
|
lambda hass: ("/tmp/nsh-ux.pem", "/tmp/nsh-ux.key"),
|
|
)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
# the address IS known — only the library is missing
|
|
flow._user_data = {
|
|
"name": "mdarcy",
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
|
|
result = await flow.async_step_protocol_pair(None)
|
|
|
|
assert result["errors"] == {"base": "protocol_unavailable"}
|
|
assert CONF_SKIP_PAIRING in vol_mirror.declared_fields(result["data_schema"])
|
|
|
|
async def test_reauth_skip_aborts_honestly(self, tmp_path, monkeypatch):
|
|
"""An existing entry cannot be re-created — say so, never fake a repair."""
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
entry = SimpleNamespace(
|
|
entry_id="e1",
|
|
title="mdarcy",
|
|
data={},
|
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: DISCOVERED_HOST},
|
|
)
|
|
hass.config_entries.by_id["e1"] = entry
|
|
flow.context = {"entry_id": "e1"}
|
|
|
|
shown = await flow.async_step_reauth({})
|
|
assert shown["step_id"] == "reauth_confirm"
|
|
pair_form = await flow.async_step_reauth_confirm({"confirm": True})
|
|
assert pair_form["step_id"] == "protocol_pair"
|
|
|
|
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
assert result == {"type": "abort", "reason": "reauth_skipped"}
|
|
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
|
|
|
|
|
|
class TestProtocolReconfigure:
|
|
"""F1: an entry whose protocol transport is off can pair LATER (audit G5)."""
|
|
|
|
def _entry(self, **overrides):
|
|
options = {
|
|
CONF_PROTOCOL_ENABLED: False, # the "continue without pairing" outcome
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: "10.0.0.9", # t41: the entry's one address
|
|
CONF_ADB_PORT: 5555,
|
|
}
|
|
options.update(overrides)
|
|
return SimpleNamespace(
|
|
entry_id="e1", title="mdarcy", data={}, options=options,
|
|
unique_id=None,
|
|
)
|
|
|
|
def _reconfigure_flow(self, module, tmp_path, entry=None):
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
if entry is not None:
|
|
hass.config_entries.by_id["e1"] = entry
|
|
flow.context = {"entry_id": "e1", "source": "reconfigure"}
|
|
return flow, hass
|
|
|
|
async def test_reconfigure_pairs_a_protocol_disabled_entry(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Host editable → PIN step → options updated → entry reloaded."""
|
|
module = _form_module()
|
|
client = _patch_protocol(monkeypatch, module)
|
|
entry = self._entry()
|
|
flow, hass = self._reconfigure_flow(module, tmp_path, entry)
|
|
|
|
form = await flow.async_step_reconfigure()
|
|
|
|
assert form["step_id"] == "protocol_pair" # the PIN step
|
|
assert client.pairing_started is True
|
|
fields = vol_mirror.declared_fields(form["data_schema"])
|
|
assert CONF_HOST in fields # the address is editable here
|
|
assert CONF_PAIRING_CODE in fields
|
|
|
|
result = await flow.async_step_protocol_pair(
|
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
|
)
|
|
|
|
assert result["reason"] == "reconfigure_successful"
|
|
# the transport is ON and carries the address that just paired
|
|
assert entry.options[CONF_PROTOCOL_ENABLED] is True
|
|
assert entry.options[CONF_HOST] == "10.0.0.9"
|
|
# the rest of the entry's options survive the update
|
|
assert entry.options[CONF_ADB_ENABLED] is True
|
|
assert entry.options[CONF_HOST] == "10.0.0.9"
|
|
assert entry.unique_id == FAKE_MAC
|
|
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
|
|
|
# ...and the reloaded entry can really build the transport again: the
|
|
# settings point at the paired certificate + the address just paired,
|
|
# which is exactly what was impossible before reconfigure existed.
|
|
init = _integration_module()
|
|
settings = await init._build_protocol_settings(hass, entry)
|
|
assert settings is not None
|
|
assert settings["host"] == "10.0.0.9"
|
|
assert settings["certfile"].endswith("client.pem")
|
|
|
|
async def test_reconfigure_host_correction_reopens_the_pin_step(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The editable host wins: a corrected address starts a NEW session."""
|
|
module = _form_module()
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
first = FakeProtocolClient()
|
|
_patch_protocol(monkeypatch, module, first)
|
|
entry = self._entry()
|
|
flow, _hass = self._reconfigure_flow(module, tmp_path, entry)
|
|
await flow.async_step_reconfigure()
|
|
assert first.pairing_started is True
|
|
|
|
second = FakeProtocolClient()
|
|
monkeypatch.setattr(
|
|
ShieldConnectionManager, "build_protocol_client", lambda *a, **k: second
|
|
)
|
|
form = await flow.async_step_protocol_pair(
|
|
{CONF_HOST: "10.0.0.77", CONF_PAIRING_CODE: "0000"}
|
|
)
|
|
|
|
assert form["step_id"] == "protocol_pair" # asks for the NEW code
|
|
assert first.disconnects == 1 # the stale session was dropped
|
|
assert second.pairing_started is True # pairing followed the new address
|
|
assert first.finished_codes == [] # the stale code was never used
|
|
|
|
result = await flow.async_step_protocol_pair(
|
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
|
)
|
|
|
|
assert result["reason"] == "reconfigure_successful"
|
|
assert entry.options[CONF_HOST] == "10.0.0.77"
|
|
|
|
async def test_reconfigure_skip_leaves_the_entry_untouched(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
entry = self._entry()
|
|
flow, hass = self._reconfigure_flow(module, tmp_path, entry)
|
|
await flow.async_step_reconfigure()
|
|
|
|
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
assert result == {"type": "abort", "reason": "reconfigure_skipped"}
|
|
assert entry.options[CONF_PROTOCOL_ENABLED] is False # unchanged
|
|
assert hass.config_entries.reloaded_entry_ids == []
|
|
|
|
async def test_reconfigure_without_a_linked_entry_aborts(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = self._reconfigure_flow(module, tmp_path, entry=None)
|
|
|
|
result = await flow.async_step_reconfigure()
|
|
|
|
assert result == {"type": "abort", "reason": "reconfigure_entry_missing"}
|
|
|
|
|
|
class TestUnreachableShieldDuringPairing:
|
|
"""t44 F1 (high): an unreachable Shield is a FORM, never an exception.
|
|
|
|
The protocol transport is ON by default, so an asleep or rebooting Shield is
|
|
the ordinary case: `_start_protocol_pairing` used to let androidtvremote2's
|
|
CannotConnect escape `async_step_protocol_pair`, and the protocol_pair form —
|
|
the address field AND the "continue without pairing" escape — never rendered.
|
|
"""
|
|
|
|
def _raising(self, monkeypatch, module):
|
|
return _patch_protocol(
|
|
monkeypatch, module, client=FakeProtocolClient(start_raises=True)
|
|
)
|
|
|
|
async def test_fresh_setup_shows_the_form_instead_of_raising(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
self._raising(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: False,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "protocol_pair"
|
|
assert form["errors"] == {"base": "protocol_unreachable"}
|
|
# both escapes are on the form: a corrected address and the skip
|
|
fields = vol_mirror.declared_fields(form["data_schema"])
|
|
assert CONF_HOST in fields
|
|
assert CONF_SKIP_PAIRING in fields
|
|
|
|
async def test_the_adb_skip_route_into_the_protocol_step_also_shows_the_form(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Both transports are on by default, so ADB pairs first, then protocol."""
|
|
module = _form_module()
|
|
self._raising(monkeypatch, module)
|
|
_patch_adb(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
adb_form = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
assert adb_form["step_id"] == "adb_pair"
|
|
|
|
skipped = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
|
assert skipped["type"] == "menu"
|
|
assert "protocol_pair" 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"}
|
|
|
|
async def test_the_skip_path_still_finishes_the_entry(self, tmp_path, monkeypatch):
|
|
"""The whole point of degrading to the form: setup stays completable."""
|
|
module = _form_module()
|
|
self._raising(monkeypatch, module)
|
|
_patch_adb(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: DISCOVERED_HOST,
|
|
}
|
|
)
|
|
)
|
|
await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["options"][CONF_PROTOCOL_ENABLED] is False # passive
|
|
assert result["options"][CONF_ADB_ENABLED] is True
|
|
|
|
async def test_reconfigure_survives_an_unreachable_shield(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
module = _form_module()
|
|
self._raising(monkeypatch, module)
|
|
entry = SimpleNamespace(
|
|
entry_id="e1",
|
|
title="mdarcy",
|
|
data={},
|
|
options={
|
|
CONF_PROTOCOL_ENABLED: False,
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: "10.0.0.9",
|
|
CONF_ADB_PORT: 5555,
|
|
},
|
|
unique_id=None,
|
|
)
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
hass.config_entries.by_id["e1"] = entry
|
|
flow.context = {"entry_id": "e1", "source": "reconfigure"}
|
|
|
|
form = await flow.async_step_reconfigure()
|
|
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "protocol_pair"
|
|
assert form["errors"] == {"base": "protocol_unreachable"}
|
|
|
|
async def test_reauth_survives_an_unreachable_shield(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
self._raising(monkeypatch, module)
|
|
entry = SimpleNamespace(
|
|
entry_id="e1",
|
|
title="mdarcy",
|
|
data={},
|
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"},
|
|
)
|
|
flow, hass = _new_flow(module, tmp_path)
|
|
hass.config_entries.by_id["e1"] = entry
|
|
flow.context = {"entry_id": "e1"}
|
|
|
|
shown = await flow.async_step_reauth({})
|
|
assert shown["step_id"] == "reauth_confirm"
|
|
|
|
form = await flow.async_step_reauth_confirm({"confirm": True})
|
|
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "protocol_pair"
|
|
assert form["errors"] == {"base": "protocol_unreachable"}
|
|
|
|
def test_the_new_error_key_is_documented_in_both_string_files(self):
|
|
"""A form error with no copy renders as a bare key in the UI."""
|
|
for file in (_strings(), _en_strings()):
|
|
text = file["config"]["error"]["protocol_unreachable"]
|
|
assert "did not answer" in text
|
|
assert "Continue without protocol pairing" in text
|
|
|
|
|
|
class TestCopyContract:
|
|
"""F-C: plain, self-consistent copy with no unreachable-step pointers."""
|
|
|
|
def test_no_string_sends_the_user_to_the_previous_step(self):
|
|
for path, text in _iter_string_leaves(_strings()):
|
|
assert "previous step" not in text.lower(), ".".join(path)
|
|
|
|
def test_no_host_errors_name_the_field_and_the_discovery_prefill(self):
|
|
for file in (_strings(), _en_strings()):
|
|
errors = file["config"]["error"]
|
|
for key in ("adb_no_host", "protocol_no_host"):
|
|
text = errors[key].lower()
|
|
assert "host" in text, key
|
|
assert "discovered" in text, key
|
|
|
|
def test_protocol_skip_copy_names_the_later_pairing_path(self):
|
|
"""F1b: the transport stays OFF until paired, and the way back is named."""
|
|
for file in (_strings(), _en_strings()):
|
|
text = file["config"]["step"]["protocol_pair"]["data_description"][
|
|
"skip_pairing"
|
|
]
|
|
assert "stays off until you pair it" in text
|
|
assert "Reconfigure" in text
|
|
|
|
def test_every_error_and_abort_key_the_flow_raises_exists_in_both_files(self):
|
|
source = (BASE / "config_flow.py").read_text(encoding="utf-8")
|
|
raised = set(re.findall(r'errors\["base"\] = "([a-z_]+)"', source))
|
|
aborted = set(re.findall(r'reason="([a-z_]+)"', source))
|
|
|
|
assert raised, "no flow error keys found — the pattern drifted"
|
|
assert aborted, "no abort reasons found — the pattern drifted"
|
|
for file in (_strings(), _en_strings()):
|
|
assert raised <= set(file["config"]["error"])
|
|
assert aborted <= set(file["config"]["abort"])
|
|
|
|
async def test_every_rendered_setup_field_has_a_label(self, tmp_path, monkeypatch):
|
|
"""t57: the setup flow's steps AND its menu rows carry labels.
|
|
|
|
The menu rows are read from ``menu_options`` — the key core uses — so a
|
|
label under a bare ``menu`` key (which never reaches the browser) fails
|
|
here rather than surfacing as a row called "connection" in the UI.
|
|
"""
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
menu = await flow.async_step_menu(None)
|
|
|
|
rendered = {
|
|
"user": vol_mirror.declared_fields(
|
|
(await flow.async_step_user(None))["data_schema"]
|
|
),
|
|
"receiver": vol_mirror.declared_fields(
|
|
(await flow.async_step_receiver(None))["data_schema"]
|
|
),
|
|
"connection": vol_mirror.declared_fields(
|
|
(await flow.async_step_connection(None))["data_schema"]
|
|
),
|
|
"adb_pair": vol_mirror.declared_fields(
|
|
(await flow.async_step_adb_pair(None))["data_schema"]
|
|
),
|
|
"protocol_pair": vol_mirror.declared_fields(
|
|
(await flow.async_step_protocol_pair(None))["data_schema"]
|
|
),
|
|
}
|
|
|
|
for file in (_strings(), _en_strings()):
|
|
steps = file["config"]["step"]
|
|
menu_step = steps["menu"]
|
|
assert menu_step["description"]
|
|
assert "menu" not in menu_step, "setup menu uses a bare 'menu' key"
|
|
for row in menu["menu_options"]:
|
|
assert row in menu_step["menu_options"], f"setup menu row {row}"
|
|
for step, fields in rendered.items():
|
|
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"
|
|
|
|
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))
|
|
|
|
async def walk(step: str) -> dict:
|
|
"""The rendered screens from ``step`` down, keyed by the i18n step id."""
|
|
opened = await getattr(flow, f"async_step_{step}")(None)
|
|
if opened["type"] == "menu":
|
|
nested: dict = {step: ("menu", opened)}
|
|
for row in opened["menu_options"]:
|
|
if row in OPTIONS_ACTION_ROWS:
|
|
continue
|
|
nested.update(await walk(row))
|
|
return nested
|
|
return {step: ("form", opened)}
|
|
|
|
menu = await flow.async_step_init(None)
|
|
# t64: the Apps sub-screen is itself a MENU of row actions, so the walk
|
|
# goes as deep as the screen does — a nested step is checked exactly like
|
|
# a top-level one, and every row a nested menu offers must have a label.
|
|
rendered: dict = {}
|
|
for step in menu["menu_options"]:
|
|
if step in OPTIONS_ACTION_ROWS:
|
|
continue
|
|
rendered.update(await walk(step))
|
|
|
|
for file in (_strings(), _en_strings()):
|
|
menu_step = file["options"]["step"]["init"]
|
|
assert menu_step["description"]
|
|
# every row the menu offers has a label. HA reads these from
|
|
# `menu_options` (the key 56 core integrations use) — under a bare
|
|
# `menu` key the rows fall back to raw ids in the browser (t57 F3).
|
|
for option in menu["menu_options"]:
|
|
assert option in menu_step.get("menu_options", {}), f"menu row {option}"
|
|
|
|
steps = file["options"]["step"]
|
|
for step, (kind, opened) 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
|
|
if kind == "menu":
|
|
for row in opened["menu_options"]:
|
|
assert row in steps[step].get("menu_options", {}), (
|
|
f"{step} row {row} has no label"
|
|
)
|
|
continue
|
|
for field in vol_mirror.declared_fields(opened["data_schema"]):
|
|
assert field in steps[step]["data"], f"{step}.{field} has no label"
|
|
|
|
|
|
def _options_flow(tmp_path, options=None):
|
|
"""An options flow over an entry with `options`, on a fake hass."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = SimpleNamespace(
|
|
options=dict(options or {}), entry_id="e1", title="Shield"
|
|
)
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
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,
|
|
CONF_SCREEN_CAPTURE_ENABLED,
|
|
},
|
|
"receiver": {"receiver_entity", "receiver_input"},
|
|
# t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu
|
|
# of row actions), so the field that owns the map is `apps_text`.
|
|
"apps_text": {"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},
|
|
},
|
|
}
|
|
|
|
#: t64: the Apps editor's rows live one level deeper — the Apps sub-screen is a
|
|
#: MENU of row actions, exactly as the browser navigates it: open the parent row,
|
|
#: then the row the user picked.
|
|
OPTIONS_NESTED_STEPS = {
|
|
"apps_text": "apps",
|
|
"apps_add": "apps",
|
|
"apps_edit": "apps",
|
|
"apps_edit_form": "apps",
|
|
"apps_remove": "apps",
|
|
"apps_remove_confirm": "apps",
|
|
# t17: the Intents editor is the same shape — a menu, then one row per step
|
|
"intents_edit": "intents",
|
|
"intents_form": "intents",
|
|
"intents_remove": "intents",
|
|
"intents_remove_confirm": "intents",
|
|
"intents_scan": "intents",
|
|
"intents_scan_result": "intents",
|
|
}
|
|
|
|
#: Menu rows that ACT rather than render a form: HA dispatches them with no input,
|
|
#: so there is no data_schema and no field labels to check.
|
|
OPTIONS_ACTION_ROWS = frozenset(
|
|
{
|
|
"discover",
|
|
"finish",
|
|
"menu_again",
|
|
"apps_remove_yes",
|
|
"apps_remove_no",
|
|
"intents_remove_yes",
|
|
"intents_remove_no",
|
|
}
|
|
)
|
|
|
|
|
|
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():
|
|
parent = OPTIONS_NESTED_STEPS.get(step)
|
|
if parent is not None:
|
|
assert parent in result["menu_options"], (
|
|
f"{parent} is not offered by the menu: {result['menu_options']}"
|
|
)
|
|
result = await getattr(flow, f"async_step_{parent}")(None)
|
|
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:
|
|
# t64: a nested row (the apps table) returns to the APPS menu, so walk
|
|
# back the way the "Back to the main menu" row does before committing.
|
|
result = await flow.async_step_menu_again()
|
|
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)
|
|
parent = OPTIONS_NESTED_STEPS.get(step)
|
|
if parent is not None:
|
|
assert parent in result["menu_options"], result["menu_options"]
|
|
result = await getattr(flow, f"async_step_{parent}")(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.
|
|
|
|
The escape hatch for hardware this integration does not drive — a speaker switch
|
|
that owns the volume. Unset means the native protocol/ADB path, exactly as
|
|
before, so the page has to make unsetting as easy as setting.
|
|
"""
|
|
|
|
def _flow(self, tmp_path, options=None):
|
|
return _options_flow(tmp_path, options)
|
|
|
|
@staticmethod
|
|
def _submitted(schema):
|
|
return getattr(schema, "schema", schema) or {}
|
|
|
|
async def test_a_script_selector_exists_for_every_command(self, tmp_path):
|
|
flow = self._flow(tmp_path)
|
|
|
|
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
|
|
|
|
async def test_saving_stores_one_dict_option_and_no_flat_keys(self, tmp_path):
|
|
flow = self._flow(tmp_path)
|
|
|
|
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"
|
|
assert result["data"][CONF_COMMAND_SCRIPTS] == {
|
|
"volume_set": "script.shield_volume",
|
|
"turn_on": "script.shield_wake",
|
|
}
|
|
# the per-command field is UI only: the service layer reads the dict
|
|
assert not [key for key in result["data"] if key.startswith(SCRIPT_FIELD_PREFIX)]
|
|
|
|
async def test_configured_scripts_prefill_their_selectors(self, tmp_path):
|
|
flow = self._flow(
|
|
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}}
|
|
)
|
|
|
|
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"
|
|
# an unconfigured command is not suggested anything
|
|
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: clearing every selector removes them all."""
|
|
flow = self._flow(
|
|
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}}
|
|
)
|
|
|
|
result = await _options_submit(flow, _all_script_fields(""), save=True)
|
|
|
|
assert result["data"][CONF_COMMAND_SCRIPTS] == {}
|
|
|
|
async def test_a_stale_unknown_command_is_not_carried_forward(self, tmp_path):
|
|
flow = self._flow(
|
|
tmp_path,
|
|
{CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake", "gone": "script.x"}},
|
|
)
|
|
|
|
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 _options_open(flow, "scripts")
|
|
|
|
assert form["type"] == "form"
|
|
assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields(
|
|
form["data_schema"]
|
|
)
|
|
|
|
|
|
class TestClearingAnEntityField:
|
|
"""t47 F1: clearing must work whichever shape the field arrives in.
|
|
|
|
Home Assistant validates the submitted page against the schema the flow last
|
|
returned, and real HA's EntitySelector rejects BOTH ``''`` and ``None``
|
|
("Entity is neither a valid entity ID nor a valid UUID") — so an explicit clear
|
|
was reported as a malformed form. The three shapes are pinned here, validated
|
|
through the schema exactly as HA does, and the happy path is pinned too so the
|
|
tolerance cannot start swallowing real edits.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _validate(form, data):
|
|
"""Run a submitted page through the schema the form returned, as HA does.
|
|
|
|
This is the step between the frontend and the flow, and it is where the
|
|
clearing trap used to fire. The harness's voluptuous mirror carries marker
|
|
defaults but does not validate (``vol.Schema`` is the identity there), so
|
|
each field is sent through its own schema entry — the same call HA makes,
|
|
and the one that used to raise for a cleared entity field.
|
|
"""
|
|
raw = form["data_schema"]
|
|
validators = {
|
|
getattr(key, "schema", None): value
|
|
for key, value in raw.items()
|
|
if getattr(key, "schema", None) is not None
|
|
}
|
|
validated = {}
|
|
for field, value in data.items():
|
|
validator = validators.get(field)
|
|
validated[field] = validator(value) if callable(validator) else value
|
|
return validated
|
|
|
|
async def test_the_schema_accepts_an_omitted_none_and_empty_value(self, tmp_path):
|
|
flow = _options_flow(
|
|
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}}
|
|
)
|
|
form = await _options_open(flow, "scripts")
|
|
|
|
for value in ("", None):
|
|
assert self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": value}) == {
|
|
f"{SCRIPT_FIELD_PREFIX}turn_on": ""
|
|
}
|
|
# a real entity id is still passed through, and an omitted key stays omitted
|
|
validated = self._validate(form, {f"{SCRIPT_FIELD_PREFIX}turn_on": "script.other"})
|
|
assert validated[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.other"
|
|
assert f"{SCRIPT_FIELD_PREFIX}volume_up" not in validated
|
|
|
|
def test_a_plain_entity_selector_rejects_the_same_values(self):
|
|
"""The trap itself: without the tolerant selector these shapes blow up.
|
|
|
|
The stub mirrors real HA here (see entity_stublib.EntitySelectorStub), so
|
|
this stays honest without needing a real-HA interpreter.
|
|
"""
|
|
import homeassistant.helpers.selector as selector_module
|
|
|
|
strict = selector_module.EntitySelector(
|
|
selector_module.EntitySelectorConfig(domain="script")
|
|
)
|
|
|
|
# the stub raises ValueError; real HA raises probatio.Invalid for the same
|
|
# values ("Entity is neither a valid entity ID nor a valid UUID", verified)
|
|
for value in ("", None):
|
|
with pytest.raises(ValueError):
|
|
strict(value)
|
|
|
|
@pytest.mark.parametrize("value", ["", None])
|
|
async def test_an_explicit_empty_value_clears_the_script(self, tmp_path, value):
|
|
flow = _options_flow(
|
|
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}}
|
|
)
|
|
form = await _options_open(flow, "scripts")
|
|
|
|
# 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
|
|
|
|
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(
|
|
self, tmp_path, value
|
|
):
|
|
"""The pattern predates the script selectors — jellyfin had it too."""
|
|
flow = _options_flow(
|
|
tmp_path,
|
|
{CONF_JELLYFIN_ENTITY: "media_player.jellyfin", CONF_HOST: "10.0.0.9"},
|
|
)
|
|
form = await _options_open(flow, "connection")
|
|
|
|
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 an empty page clears it."""
|
|
flow = _options_flow(
|
|
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"}}
|
|
)
|
|
form = await _options_open(flow, "scripts")
|
|
|
|
# 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):
|
|
"""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 _options_open(flow, "connection")
|
|
|
|
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"
|
|
|
|
async def test_a_prefill_submit_round_trip_changes_nothing(self, tmp_path):
|
|
stored = {
|
|
CONF_COMMAND_SCRIPTS: {"turn_on": "script.watch"},
|
|
CONF_JELLYFIN_ENTITY: "media_player.jellyfin",
|
|
CONF_POWER_ENTITY: "switch.plug",
|
|
CONF_HOST: "10.0.0.9",
|
|
}
|
|
flow = _options_flow(tmp_path, stored)
|
|
scripts_form = await _options_open(flow, "scripts")
|
|
suggested = scripts_form["data_schema"].get("__suggested__", {})
|
|
assert suggested[f"{SCRIPT_FIELD_PREFIX}turn_on"] == "script.watch"
|
|
|
|
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(
|
|
scripts_form, {f"{SCRIPT_FIELD_PREFIX}turn_on": "script.watch"}
|
|
)
|
|
)
|
|
await flow.async_step_connection(
|
|
self._validate(
|
|
connection_form,
|
|
{
|
|
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"
|
|
assert result["data"][CONF_POWER_ENTITY] == "switch.plug"
|
|
assert result["data"][CONF_HOST] == "10.0.0.9"
|
|
|
|
|
|
class TestRemoteCommandScriptOption:
|
|
"""t47 B: one optional script for remote.send_command, on the same page."""
|
|
|
|
async def test_the_field_is_offered_and_stored_as_a_string(self, tmp_path):
|
|
flow = _options_flow(tmp_path)
|
|
form = await _options_open(flow, "scripts")
|
|
|
|
assert CONF_REMOTE_COMMAND_SCRIPT in vol_mirror.declared_fields(
|
|
form["data_schema"]
|
|
)
|
|
|
|
result = await _options_submit(
|
|
flow, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"}, save=True
|
|
)
|
|
|
|
assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote"
|
|
|
|
async def test_it_prefills_from_the_entry_options(self, tmp_path):
|
|
flow = _options_flow(
|
|
tmp_path, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"}
|
|
)
|
|
form = await _options_open(flow, "scripts")
|
|
|
|
assert form["data_schema"]["__suggested__"][CONF_REMOTE_COMMAND_SCRIPT] == (
|
|
"script.shield_remote"
|
|
)
|
|
|
|
async def test_a_padded_value_is_trimmed(self, tmp_path):
|
|
flow = _options_flow(tmp_path)
|
|
|
|
result = await _options_submit(
|
|
flow, {CONF_REMOTE_COMMAND_SCRIPT: " script.shield_remote "}, save=True
|
|
)
|
|
|
|
assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == "script.shield_remote"
|
|
|
|
async def test_an_explicit_empty_value_clears_it(self, tmp_path):
|
|
flow = _options_flow(
|
|
tmp_path, {CONF_REMOTE_COMMAND_SCRIPT: "script.shield_remote"}
|
|
)
|
|
form = await _options_open(flow, "scripts")
|
|
validated = TestClearingAnEntityField._validate(
|
|
form, {CONF_REMOTE_COMMAND_SCRIPT: ""}
|
|
)
|
|
|
|
await flow.async_step_scripts(validated)
|
|
result = await flow.async_step_finish(None)
|
|
|
|
assert result["data"][CONF_REMOTE_COMMAND_SCRIPT] == ""
|
|
|
|
|
|
class TestStringsTranslationsParity:
|
|
"""Both copies of the UI copy must expose the same key tree."""
|
|
|
|
def test_named_sections_exist_in_both_files(self):
|
|
for file in (_strings(), _en_strings()):
|
|
for section in ("config", "options", "entity", "services"):
|
|
assert section in file, section
|
|
|
|
def test_key_trees_match(self):
|
|
assert _key_tree(_strings()) == _key_tree(_en_strings())
|
|
|
|
|
|
class TestPassiveSetupNeverDemandsReadiness:
|
|
"""The skip paths rely on setup accepting an unpaired entry."""
|
|
|
|
def test_async_setup_entry_raises_nothing(self):
|
|
"""Setup accepts an unpaired entry — that is what the skip path needs."""
|
|
source = (BASE / "__init__.py").read_text(encoding="utf-8")
|
|
body = source.split("async def async_setup_entry", 1)[1]
|
|
body = body.split("\nasync def ", 1)[0].split("\ndef ", 1)[0]
|
|
|
|
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",
|
|
"intents",
|
|
"discover",
|
|
"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)
|
|
|
|
# `discover` and `finish` are ACTIONS, not sub-screens: HA dispatches
|
|
# them with no input and they return a terminal/menu result, so they are
|
|
# held to the handler-name rule only
|
|
action_rows = {"discover", "finish"}
|
|
for row in menu["menu_options"]:
|
|
assert hasattr(flow, f"async_step_{row}"), row
|
|
if row in action_rows:
|
|
continue
|
|
opened = await getattr(flow, f"async_step_{row}")(None)
|
|
# a sub-screen is a FORM — or, for Apps (t64), a MENU of row actions
|
|
assert opened["type"] in ("form", "menu"), (row, opened)
|
|
assert opened["step_id"] == row, opened["step_id"]
|
|
if opened["type"] == "menu":
|
|
# a nested menu's rows are dispatched by name as well
|
|
for nested in opened["menu_options"]:
|
|
assert hasattr(flow, f"async_step_{nested}"), (row, nested)
|
|
|
|
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"): # sub-screens
|
|
opened = await _options_open(flow, row)
|
|
# a sub-screen is a form, except Apps: a MENU of row actions (t64)
|
|
assert opened["type"] in ("form", "menu"), (row, opened)
|
|
# 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_text")
|
|
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_text")
|
|
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_text")
|
|
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "apps_text"
|
|
assert form["errors"] == {}
|
|
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
|
|
# no discovery data yet (there is no manager in this harness), so the
|
|
# field is empty — which is exactly the documented "blank uses the
|
|
# built-in list", and nothing to crash on
|
|
assert form["data_schema"].get("__suggested__", {}).get("app_name_map", "") == ""
|
|
|
|
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 TestDiscoveryFeedsTheAppsTable:
|
|
"""t57: the Apps table opens on the REAL app list, not four curated rows."""
|
|
|
|
class _Manager:
|
|
"""A stand-in connection manager with a canned discovered table."""
|
|
|
|
def __init__(self, rows=None, connected=True):
|
|
self.rows = list(rows or [])
|
|
self.connected = connected
|
|
self.refresh_calls: list[bool] = []
|
|
|
|
def installed_apps(self):
|
|
return [dict(row) for row in self.rows]
|
|
|
|
async def async_refresh_installed_apps(self, force=False):
|
|
self.refresh_calls.append(force)
|
|
return self.installed_apps()
|
|
|
|
def _flow_with(self, tmp_path, options, rows, connected=True):
|
|
flow = _options_flow(tmp_path, options)
|
|
manager = self._Manager(rows, connected)
|
|
flow.config_entry.runtime_data = manager
|
|
return flow, manager
|
|
|
|
async def test_the_table_lists_every_discovered_app(self, tmp_path):
|
|
flow, _m = self._flow_with(
|
|
tmp_path,
|
|
{"app_name_map": {"ar.tvplayer.tv": "Telly"}},
|
|
[
|
|
{"package": "ar.tvplayer.tv", "name": "TiviMate", "icon": "mdi:television-classic"},
|
|
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
|
# t66: a discovered app nothing can name comes through as name ""
|
|
# (the sweep no longer repeats the package id as a name)
|
|
{"package": "com.example.handmade", "name": "", "icon": "mdi:television"},
|
|
],
|
|
)
|
|
|
|
form = await _options_open(flow, "apps_text")
|
|
text = form["data_schema"]["__suggested__"]["app_name_map"]
|
|
|
|
lines = text.split("\n")
|
|
# the user's own row keeps its position, its name and its icon slot
|
|
assert lines[0] == "ar.tvplayer.tv=Telly"
|
|
# every discovered app is a row too — this is the live bug
|
|
assert "org.xbmc.kodi=Kodi" in lines
|
|
# ...but an app nothing names is NOT pre-filled as `pkg=pkg` (t66): the id
|
|
# would be stored as if it were a name. The row editor still offers it.
|
|
assert not [line for line in lines if line.startswith("com.example.handmade=")]
|
|
# the user's name wins over the device label for the same package
|
|
assert not [line for line in lines if line == "ar.tvplayer.tv=TiviMate"]
|
|
# ...and the discovered icon is NEVER written into the icon column
|
|
assert not [line for line in lines if "mdi:" in line]
|
|
|
|
async def test_a_discovered_app_the_map_never_mentions_is_appended(self, tmp_path):
|
|
flow, _m = self._flow_with(
|
|
tmp_path,
|
|
{},
|
|
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
|
)
|
|
|
|
form = await _options_open(flow, "apps_text")
|
|
|
|
assert form["data_schema"]["__suggested__"]["app_name_map"] == (
|
|
"com.netflix.ninja=Netflix"
|
|
)
|
|
|
|
async def test_no_discovery_leaves_the_users_own_rows_alone(self, tmp_path):
|
|
flow, _m = self._flow_with(
|
|
tmp_path, {"app_name_map": {"com.a": "A"}}, [], connected=False
|
|
)
|
|
|
|
form = await _options_open(flow, "apps_text")
|
|
|
|
assert form["data_schema"]["__suggested__"]["app_name_map"] == "com.a=A"
|
|
|
|
async def test_rendering_the_table_issues_no_shell(self, tmp_path):
|
|
"""A form render must never sweep — discovery is an explicit action."""
|
|
flow, manager = self._flow_with(tmp_path, {}, [])
|
|
|
|
await _options_open(flow, "apps_text")
|
|
await flow.async_step_init(None)
|
|
|
|
assert manager.refresh_calls == []
|
|
|
|
async def test_the_discover_row_runs_the_sweep_and_returns_to_the_menu(
|
|
self, tmp_path
|
|
):
|
|
flow, manager = self._flow_with(
|
|
tmp_path,
|
|
{},
|
|
[{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"}],
|
|
)
|
|
|
|
menu = await flow.async_step_init(None)
|
|
assert "discover" in menu["menu_options"]
|
|
|
|
result = await flow.async_step_discover(None)
|
|
|
|
assert result["type"] == "menu"
|
|
assert manager.refresh_calls == [True] # explicit force, exactly once
|
|
|
|
async def test_the_menu_says_how_many_apps_were_found(self, tmp_path):
|
|
"""The menu line is what tells the user discovery worked.
|
|
|
|
Asserted on the flow's own placeholder builder rather than the stub's
|
|
menu result: ``description_placeholders`` is passed to
|
|
``async_show_menu`` for the real frontend to substitute into the step
|
|
description, and a stub that echoed it would be testing itself.
|
|
"""
|
|
flow, _m = self._flow_with(
|
|
tmp_path,
|
|
{},
|
|
[{"package": "com.a", "name": "A", "icon": "mdi:television"}],
|
|
)
|
|
|
|
assert "1 apps found" in flow._menu_placeholders()["discovery"]
|
|
|
|
async def test_a_failed_sweep_still_renders_the_menu(self, tmp_path):
|
|
class _Boom(TestDiscoveryFeedsTheAppsTable._Manager):
|
|
async def async_refresh_installed_apps(self, force=False):
|
|
raise RuntimeError("adb went away")
|
|
|
|
flow = _options_flow(tmp_path, {})
|
|
flow.config_entry.runtime_data = _Boom([], connected=False)
|
|
|
|
result = await flow.async_step_discover(None)
|
|
|
|
assert result["type"] == "menu" # no traceback, no dead end
|
|
|
|
async def test_the_table_override_survives_a_submission(self, tmp_path):
|
|
"""Saving the merged table stores the user's rows, not the discovered ones."""
|
|
flow, _m = self._flow_with(
|
|
tmp_path,
|
|
{},
|
|
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
|
)
|
|
form = await _options_open(flow, "apps_text")
|
|
submitted = form["data_schema"]["__suggested__"]["app_name_map"]
|
|
|
|
await flow.async_step_apps_text({CONF_APP_NAME_MAP: submitted})
|
|
result = await flow.async_step_finish(None)
|
|
|
|
assert result["data"][CONF_APP_NAME_MAP] == {"com.netflix.ninja": "Netflix"}
|
|
|
|
|
|
#: t64: the apps ROW EDITOR's field names. They are UI only — a projection of one
|
|
#: ``package=name[|icon]`` row — so they are asserted against the flow module's own
|
|
#: constants (below) instead of being trusted to match.
|
|
APP_PACKAGE = "app_package"
|
|
APP_NAME = "app_name"
|
|
APP_ICON = "app_icon"
|
|
|
|
|
|
class TestAppsRowEditor:
|
|
"""t64: HA has no table selector, so a row is edited one step at a time."""
|
|
|
|
def _flow(self, tmp_path, options=None):
|
|
return _options_flow(tmp_path, options or {})
|
|
|
|
async def test_the_field_is_the_multiline_text_selector(self, tmp_path):
|
|
"""The mechanical cause of the live report, pinned without HA.
|
|
|
|
A selector-less ``str`` field renders as a SINGLE-LINE input, so the map
|
|
arrived flattened with nowhere to add a row. The field must carry the text
|
|
selector, and it must be the MULTILINE one — the flag the frontend reads
|
|
to render a textarea instead of a one-line box.
|
|
"""
|
|
flow = self._flow(tmp_path)
|
|
form = await _options_open(flow, "apps_text")
|
|
module = _form_module()
|
|
assert (
|
|
module.APP_FIELD_PACKAGE,
|
|
module.APP_FIELD_NAME,
|
|
module.APP_FIELD_ICON,
|
|
) == (APP_PACKAGE, APP_NAME, APP_ICON)
|
|
|
|
field = form["data_schema"][CONF_APP_NAME_MAP]
|
|
assert isinstance(field, entity_stublib.TextSelectorStub), field
|
|
assert field.config.multiline is True
|
|
|
|
async def test_the_dropdown_lists_detected_apps_too_and_labels_them(self, tmp_path):
|
|
"""t64: the entry point is the DETECTED list, named where a name is known.
|
|
|
|
The dropdown is what the user picks from, so it carries the saved rows AND
|
|
the apps discovery found; an app whose device label is missing is labelled
|
|
"(no name yet)" rather than with its own id twice, and the icon column is
|
|
never prefilled from the device icon (that is an ``mdi:`` name, which the
|
|
column does not accept).
|
|
"""
|
|
flow = _options_flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
|
|
flow.config_entry.runtime_data = TestDiscoverSaysWhatHappened._Manager(
|
|
[
|
|
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
|
{"package": "com.unnamed.tv", "name": "com.unnamed.tv", "icon": ""},
|
|
{"package": "com.a", "name": "Wrong name", "icon": "mdi:x"},
|
|
]
|
|
)
|
|
|
|
placeholder = flow._apps_placeholders()["apps"]
|
|
assert placeholder.startswith("3 apps known — ")
|
|
assert "Kodi (org.xbmc.kodi) [detected]" in placeholder
|
|
assert "com.unnamed.tv (no name yet) [detected]" in placeholder
|
|
assert "Alpha (com.a)" in placeholder # the saved row keeps the user's name
|
|
|
|
choose = await flow.async_step_apps_edit(None)
|
|
assert choose["step_id"] == "apps_edit"
|
|
|
|
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.unnamed.tv"})
|
|
suggested_values = form["data_schema"]["__suggested__"]
|
|
assert suggested_values[APP_NAME] == "" # no device label: nothing invented
|
|
assert suggested_values[APP_ICON] == "" # never the mdi: device icon
|
|
|
|
await flow.async_step_apps_edit_form(
|
|
{APP_NAME: "Unnamed TV", APP_ICON: "http://icons.local/u.png"}
|
|
)
|
|
|
|
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
|
"com.a": "Alpha",
|
|
"com.unnamed.tv": "Unnamed TV",
|
|
}
|
|
|
|
async def test_only_the_rows_that_can_act_are_offered(self, tmp_path):
|
|
empty = self._flow(tmp_path)
|
|
menu = await empty.async_step_apps(None)
|
|
|
|
assert list(menu["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
|
|
assert empty._apps_placeholders()["apps"].startswith("no apps known yet")
|
|
|
|
flow = self._flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
|
|
menu = await flow.async_step_apps(None)
|
|
|
|
assert list(menu["menu_options"]) == [
|
|
"apps_text",
|
|
"apps_add",
|
|
"apps_edit",
|
|
"apps_remove",
|
|
"menu_again",
|
|
]
|
|
assert flow._apps_placeholders()["apps"] == "1 app known — Alpha (com.a)"
|
|
|
|
async def test_add_writes_one_row_and_leaves_the_others_alone(self, tmp_path):
|
|
flow = self._flow(
|
|
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_HOST: "10.0.0.9"}
|
|
)
|
|
|
|
result = await flow.async_step_apps_add(
|
|
{
|
|
APP_PACKAGE: "com.b",
|
|
APP_NAME: "Beta",
|
|
APP_ICON: "http://icons.local/b.png",
|
|
}
|
|
)
|
|
|
|
assert result["type"] == "menu" # back at the editor, nothing committed yet
|
|
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
|
"com.a": "Alpha",
|
|
"com.b": "Beta",
|
|
}
|
|
assert flow._options_dict[CONF_APP_ICON_MAP] == {
|
|
"com.b": "http://icons.local/b.png"
|
|
}
|
|
assert flow._options_dict[CONF_HOST] == "10.0.0.9" # another screen's field
|
|
|
|
async def test_an_invalid_row_is_refused_and_stores_nothing(self, tmp_path):
|
|
flow = self._flow(tmp_path)
|
|
|
|
form = await flow.async_step_apps_add(
|
|
{APP_PACKAGE: "has spaces", APP_NAME: "Nope", APP_ICON: ""}
|
|
)
|
|
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "apps_add"
|
|
assert form["errors"] == {"base": "invalid_app_row"}
|
|
assert CONF_APP_NAME_MAP not in flow._options_dict
|
|
|
|
async def test_edit_prefills_the_stored_row_and_changes_only_it(self, tmp_path):
|
|
flow = self._flow(
|
|
tmp_path,
|
|
{
|
|
CONF_APP_NAME_MAP: {"com.a": "Alpha", "com.b": "Beta"},
|
|
CONF_APP_ICON_MAP: {"com.b": "http://icons.local/b.png"},
|
|
},
|
|
)
|
|
|
|
choose = await flow.async_step_apps_edit(None)
|
|
assert choose["step_id"] == "apps_edit"
|
|
|
|
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.b"})
|
|
suggested_values = form["data_schema"]["__suggested__"]
|
|
assert suggested_values[APP_NAME] == "Beta"
|
|
assert suggested_values[APP_ICON] == "http://icons.local/b.png"
|
|
|
|
result = await flow.async_step_apps_edit_form(
|
|
{APP_NAME: "Beta (living room)", APP_ICON: "http://icons.local/b2.png"}
|
|
)
|
|
|
|
assert result["type"] == "menu"
|
|
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
|
"com.a": "Alpha",
|
|
"com.b": "Beta (living room)",
|
|
}
|
|
assert flow._options_dict[CONF_APP_ICON_MAP] == {
|
|
"com.b": "http://icons.local/b2.png"
|
|
}
|
|
|
|
async def test_remove_needs_the_confirmation_row(self, tmp_path):
|
|
flow = self._flow(
|
|
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_APP_ICON_MAP: {}}
|
|
)
|
|
|
|
choose = await flow.async_step_apps_remove(None)
|
|
assert choose["step_id"] == "apps_remove"
|
|
|
|
confirm = await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
|
|
assert confirm["type"] == "menu"
|
|
assert confirm["step_id"] == "apps_remove_confirm"
|
|
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
|
|
|
|
kept = await flow.async_step_apps_remove_no(None)
|
|
assert kept["type"] == "menu"
|
|
assert flow._options_dict[CONF_APP_NAME_MAP] == {"com.a": "Alpha"}
|
|
|
|
await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
|
|
dropped = await flow.async_step_apps_remove_yes(None)
|
|
|
|
assert dropped["type"] == "menu"
|
|
assert flow._options_dict[CONF_APP_NAME_MAP] == {}
|
|
assert flow._options_dict[CONF_APP_ICON_MAP] == {}
|
|
|
|
|
|
class TestDiscoverSaysWhatHappened:
|
|
"""t64: "Discover ... seems to load then do nothing" must be impossible."""
|
|
|
|
class _Manager:
|
|
def __init__(self, rows=None, connected=True, fail=False):
|
|
self.rows = list(rows or [])
|
|
self.connected = connected
|
|
self.fail = fail
|
|
self.refresh_calls: list[bool] = []
|
|
|
|
def installed_apps(self):
|
|
return [dict(row) for row in self.rows]
|
|
|
|
async def async_refresh_installed_apps(self, force=False):
|
|
self.refresh_calls.append(force)
|
|
if self.fail:
|
|
raise RuntimeError("adb went away")
|
|
return self.installed_apps()
|
|
|
|
def _flow_with(self, tmp_path, manager):
|
|
flow = _options_flow(tmp_path, {})
|
|
flow.config_entry.runtime_data = manager
|
|
return flow
|
|
|
|
async def test_no_manager_says_adb_is_not_set_up(self, tmp_path):
|
|
flow = _options_flow(tmp_path, {})
|
|
|
|
await flow.async_step_discover(None)
|
|
|
|
assert flow._menu_placeholders()["discovery"] == (
|
|
"ADB is not set up for this entry — nothing could be discovered."
|
|
)
|
|
|
|
async def test_finding_apps_reports_the_count_and_updates_the_table(
|
|
self, tmp_path
|
|
):
|
|
manager = self._Manager(
|
|
[{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"}]
|
|
)
|
|
flow = self._flow_with(tmp_path, manager)
|
|
|
|
result = await flow.async_step_discover(None)
|
|
|
|
assert result["type"] == "menu"
|
|
assert manager.refresh_calls == [True]
|
|
note = flow._menu_placeholders()["discovery"]
|
|
assert note.startswith("1 apps found on the Shield") # the t57 wording
|
|
assert "updated" in note
|
|
form = await _options_open(flow, "apps_text")
|
|
assert "org.xbmc.kodi=Kodi" in form["data_schema"]["__suggested__"][
|
|
CONF_APP_NAME_MAP
|
|
]
|
|
|
|
async def test_finding_nothing_says_the_saved_rows_stay(self, tmp_path):
|
|
flow = self._flow_with(tmp_path, self._Manager([]))
|
|
|
|
await flow.async_step_discover(None)
|
|
|
|
note = flow._menu_placeholders()["discovery"]
|
|
assert "no installed apps" in note and "keeps your" in note
|
|
|
|
async def test_a_failed_sweep_says_so_instead_of_nothing(self, tmp_path):
|
|
flow = self._flow_with(tmp_path, self._Manager([], connected=False, fail=True))
|
|
|
|
result = await flow.async_step_discover(None)
|
|
|
|
assert result["type"] == "menu" # no traceback, no dead end
|
|
note = flow._menu_placeholders()["discovery"]
|
|
assert "could not reach" in note and "unchanged" in note
|
|
|
|
|
|
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
|
|
):
|
|
"""t57 F2: the rule HA enforces, checked for EVERY row.
|
|
|
|
Two halves, because a menu can break in two ways: a row with no handler
|
|
(UnknownStep the moment it is clicked) and a sub-screen whose rendered
|
|
``step_id`` is not its row id (the form cannot be submitted, because HA
|
|
dispatches a submission by its OWN step_id). The pre-fix tree failed
|
|
both — rows named ``adb``/``protocol`` with handlers called
|
|
``adb_pair``/``protocol_pair``, and forms rendering ``setup_receiver``.
|
|
"""
|
|
module = _form_module()
|
|
_patch_adb(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
# both transports on, so the MENU offers the pairing rows too
|
|
await flow.async_step_user(
|
|
_user_input(**{CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
|
|
menu = await flow.async_step_menu(None)
|
|
|
|
assert list(menu["menu_options"]) == [
|
|
"receiver",
|
|
"connection",
|
|
"adb_pair",
|
|
"protocol_pair",
|
|
"finish",
|
|
]
|
|
# ...and the row ids never include the menu itself, which would loop
|
|
assert "menu" not in menu["menu_options"]
|
|
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, (row, form["step_id"])
|
|
|
|
async def test_the_menu_stub_mirrors_the_real_signature(self):
|
|
"""t57 UNIT 0a systemic half: the stub must not accept anything.
|
|
|
|
The production bug was ``async_show_menu(..., errors=...)`` — a TypeError
|
|
under real HA — and the stub suite could not see it because every stub
|
|
took an ``errors`` keyword the real method does not have. The stub's
|
|
parameter set is pinned here, and the real-HA half is in
|
|
``ha_api_drift.HA_CALL_OWNERS`` (with a pre-fix source in
|
|
``tests/test_ha_api_drift.py`` proving it is non-vacuous).
|
|
"""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow.__new__(module.NvidiaShieldConfigFlow)
|
|
params = set(
|
|
inspect.signature(type(flow).async_show_menu).parameters
|
|
)
|
|
assert params == {
|
|
"self",
|
|
"step_id",
|
|
"menu_options",
|
|
"sort",
|
|
"description_placeholders",
|
|
}, "the menu stub drifted from HA's real signature"
|
|
|
|
options_flow = module.NvidiaShieldOptionsFlow.__new__(
|
|
module.NvidiaShieldOptionsFlow
|
|
)
|
|
assert "errors" not in inspect.signature(
|
|
type(options_flow).async_show_menu
|
|
).parameters
|
|
|
|
async def test_both_menus_key_their_row_labels_under_menu_options(self, tmp_path):
|
|
"""t57 F3: a label under the wrong key never reaches the browser."""
|
|
for file in (_strings(), _en_strings()):
|
|
for section, step in (("config", "menu"), ("options", "init")):
|
|
block = file[section]["step"][step]
|
|
assert "menu" not in block, f"{section}.step.{step} uses a bare 'menu' key"
|
|
assert block["menu_options"], f"{section}.step.{step} has no row labels"
|
|
|
|
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"
|
|
|
|
|
|
class TestScreenCaptureOption:
|
|
"""t66: the screen-capture image entity is opt-in, and OFF is the default."""
|
|
|
|
async def test_the_connection_screen_offers_it_and_defaults_off(self, tmp_path):
|
|
"""Default OFF at the SCHEMA level — the form the user is shown."""
|
|
flow = _options_flow(tmp_path)
|
|
|
|
result = await _options_open(flow, "connection")
|
|
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_SCREEN_CAPTURE_ENABLED] is False
|
|
|
|
async def test_an_untouched_toggle_stays_off(self, tmp_path):
|
|
"""A save that never renders the screen-capture row must not invent it."""
|
|
flow = _options_flow(tmp_path)
|
|
|
|
result = await _options_submit(flow, {CONF_HOST: "10.0.0.9"}, save=True)
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert not result["data"].get(CONF_SCREEN_CAPTURE_ENABLED)
|
|
|
|
async def test_switching_it_on_is_stored(self, tmp_path):
|
|
flow = _options_flow(tmp_path)
|
|
|
|
result = await _options_submit(
|
|
flow, {CONF_SCREEN_CAPTURE_ENABLED: True}, save=True
|
|
)
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is True
|
|
|
|
async def test_it_can_be_switched_back_off(self, tmp_path):
|
|
flow = _options_flow(tmp_path, {CONF_SCREEN_CAPTURE_ENABLED: True})
|
|
|
|
result = await _options_submit(
|
|
flow, {CONF_SCREEN_CAPTURE_ENABLED: False}, save=True
|
|
)
|
|
|
|
assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is False
|