The user's remaining universal-media-player dependency: some commands need hardware this integration deliberately does not drive — a speaker switch that owns the volume, an AVR input that has to move with the Shield, a mute that has to hit two devices. One script per command is the escape hatch, and the rule is absolute: a configured command calls its script INSTEAD of the native protocol/ADB path, and with nothing configured every command behaves exactly as it did (the whole existing suite passes untouched, which is what "byte-identical" means here). * const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command), SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set, volume_mute, select_source) and command_scripts(), the ONE normalizer of the stored shape — options round-trip through HA storage and can be hand-edited, so a value that is not a non-empty string for a known command is dropped instead of reaching the service layer. * media_player.py: async_run_command_script() calls script.turn_on with the script entity and its variables, blocking=True so a failure surfaces HERE — a mapped command that cannot run raises HomeAssistantError naming the command and script rather than falling back silently or doing nothing. Every command method checks its script first; volume_set passes `level` and select_source passes `app_name`. * volume_mute also passes `is_volume_muted`: without it a mute script cannot tell muting from unmuting, which is the case the user asked for. * volume_set and select_source have NO native path (C-VOL; app launching goes through play_media), so they are advertised only when configured — the feature flag is what makes HA offer the command at all. Unset leaves the feature set exactly as it was. * remote.py shares async_run_command_script for POWER: turn_on/turn_off is one configuration and the remote entity is the other route to it, so a script that owns the power sequence owns it on both. * config_flow.py: one entity selector per command (domain: script), stored as the single dict option, prefilled from the entry's options and cleared by saving without a value — the editor is the truth, as with the app map editor. Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine disagree with a wrong name), and the stub `homeassistant.exceptions` had no HomeAssistantError, so the stub now mirrors the real inheritance (ServiceValidationError derives from it). tests/test_command_scripts.py covers each command mapped and unmapped, the exact variable payloads, the native path untouched when unmapped (protocol key, then the ADB fallthrough with the protocol down), the rejecting script's error, hand-edited option shapes, and the feature set. test_config_flow_ux.py covers the selectors, the stored shape, prefill and clearing. New tests leaked the harness into the next file at first (a missing per-file scope); that is fixed, and tests/test_harness_scoping.py now asserts that every file loading the harness scopes it, so the next such mistake fails where it is made. Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order 401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter (23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
1568 lines
59 KiB
Python
1568 lines
59 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 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_ENABLED,
|
|
CONF_ADB_PORT,
|
|
CONF_COMMAND_SCRIPTS,
|
|
CONF_HOST,
|
|
CONF_JELLYFIN_ENTITY,
|
|
CONF_PAIRING_CODE,
|
|
CONF_PROTOCOL_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_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 {}),
|
|
}
|
|
|
|
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",
|
|
)
|
|
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")
|
|
selector.EntitySelector = lambda *a, **k: None
|
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
|
selector.SelectSelector = lambda *a, **k: None
|
|
selector.SelectSelectorConfig = lambda **k: ""
|
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
|
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"
|
|
|
|
result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
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})
|
|
|
|
assert result["step_id"] == "protocol_pair" # not a dead end either
|
|
|
|
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"
|
|
|
|
result = await flow.async_step_protocol_pair(
|
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
|
)
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
|
assert flow._unique_id == FAKE_MAC
|
|
|
|
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"
|
|
|
|
result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555})
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["options"][CONF_ADB_ENABLED] is True
|
|
test_connect.assert_awaited_once_with(DISCOVERED_HOST, 5555, KEY_PATH)
|
|
|
|
async def test_adb_form_host_correction_is_accepted_in_place(self, tmp_path, monkeypatch):
|
|
"""The user step is gone — a typed host must be usable where it is typed."""
|
|
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"] == "create_entry"
|
|
assert result["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"
|
|
|
|
result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
|
|
|
|
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):
|
|
module = _form_module()
|
|
_patch_protocol(monkeypatch, module)
|
|
flow, _hass = _new_flow(module, tmp_path)
|
|
|
|
rendered = {
|
|
"user": vol_mirror.declared_fields(
|
|
(await flow.async_step_user(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"]
|
|
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):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield")
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
form = await flow.async_step_init(None)
|
|
fields = vol_mirror.declared_fields(form["data_schema"])
|
|
|
|
for file in (_strings(), _en_strings()):
|
|
step = file["options"]["step"]["init"]
|
|
assert step["description"]
|
|
for field in fields:
|
|
assert field in step["data"], f"options.{field} has no label"
|
|
|
|
|
|
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):
|
|
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
|
|
|
|
@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 flow.async_step_init(None)
|
|
|
|
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 flow.async_step_init(
|
|
{
|
|
f"{SCRIPT_FIELD_PREFIX}volume_set": "script.shield_volume",
|
|
f"{SCRIPT_FIELD_PREFIX}turn_on": "script.shield_wake",
|
|
}
|
|
)
|
|
|
|
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 flow.async_step_init(None)
|
|
|
|
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: a save without scripts clears them."""
|
|
flow = self._flow(
|
|
tmp_path, {CONF_COMMAND_SCRIPTS: {"turn_on": "script.shield_wake"}}
|
|
)
|
|
|
|
result = await flow.async_step_init({})
|
|
|
|
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"}},
|
|
)
|
|
|
|
result = await flow.async_step_init({f"{SCRIPT_FIELD_PREFIX}turn_on": "script.a"})
|
|
|
|
assert result["data"][CONF_COMMAND_SCRIPTS] == {"turn_on": "script.a"}
|
|
|
|
async def test_the_page_still_renders_with_a_hand_edited_option(self, tmp_path):
|
|
"""A string where the dict belongs must not break the form."""
|
|
flow = self._flow(tmp_path, {CONF_COMMAND_SCRIPTS: "script.shield_wake"})
|
|
|
|
form = await flow.async_step_init(None)
|
|
|
|
assert form["type"] == "form"
|
|
assert f"{SCRIPT_FIELD_PREFIX}turn_on" in vol_mirror.declared_fields(
|
|
form["data_schema"]
|
|
)
|
|
|
|
|
|
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
|