t46 F1, reproduced against real Home Assistant: submitting the options page with a
cleared entity field (script_turn_on='') produced "Schema validation failed at
script_turn_on". HA validates the submitted page against the schema the flow last
returned, and real EntitySelector rejects BOTH '' and None — "Entity is neither a
valid entity ID nor a valid UUID" (verified directly against homeassistant's
EntitySelector). Clearing therefore depended on the frontend OMITTING the key, on
every entity field of the page, not only the script ones t44 added.
_ClearingEntitySelector subclasses EntitySelector and returns '' for None or an
empty/whitespace string, delegating everything else to the parent — so a real entity
id is still domain-checked, and the class is still an EntitySelector: its serialized
form is byte-identical to the real selector's
({"selector": {"entity": {"domain": ["script"], "multiple": false, "reorder": false}}}),
so the frontend keeps rendering an entity picker.
The step also normalizes a submitted None to '' before it acts: an explicit empty
value clears the setting (the script dict drops it, the patched options store '' which
every reader already treats as unset), while an OMITTED key still means "the page did
not touch it" for the patched options — and still means "cleared" for the script dict,
which is rebuilt from the page (t44's editor-is-the-truth rule, unchanged).
The test harness now models the trap instead of hiding it: EntitySelector is a CLASS
(entity_stublib.EntitySelectorStub) that rejects ''/None like real HA, defined once and
shared by all five stub installers through install_selector_faces(), so the stubs
cannot drift apart.
Tests (tests/test_config_flow_ux.py::TestClearingAnEntityField, 8): the schema accepts
omitted/None/'' while passing a real entity id through; the plain selector is pinned as
rejecting the same values (the trap itself); '' and None each clear a script AND a
pre-existing entity field (jellyfin, the older instance of the pattern) while
untouched fields survive; an omitted script field is cleared; an omitted entity field
is kept; and a prefill→submit round trip changes nothing.
RED PROOF: with the five selector sites switched back to the plain EntitySelector,
exactly the five tolerance tests fail (5 failed, 4 passed) and the rest of the file
stays green; restored, 70 pass. Real-HA probe run separately (see the task output):
strict rejects '' and None, the subclass accepts both and returns '', and serialization
is unchanged.
Verified: 423 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA.
236 lines
7.7 KiB
Python
236 lines
7.7 KiB
Python
"""F1 regression: the adb_pair submit path calls
|
|
ShieldConnectionManager.test_connect with its declared signature
|
|
(host, port, key_path) — nothing extra, arity exact.
|
|
|
|
config_flow.py imports Home Assistant; it is exercised here against small
|
|
module stubs (import-only; no HA behaviour needed on this path) with the
|
|
actual ADB handshake mocked on the manager.
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
from unittest.mock import AsyncMock
|
|
|
|
import entity_stublib
|
|
import pytest
|
|
from conftest import _SHIELD_DIR, _load_module
|
|
from nsh.const import (
|
|
CONF_ADB_ENABLED,
|
|
CONF_ADB_PORT,
|
|
CONF_ADB_STORE_APP_INFO,
|
|
CONF_HOST,
|
|
CONF_JELLYFIN_ENTITY,
|
|
CONF_POWER_ENTITY,
|
|
CONF_RECEIVER_ENTITY,
|
|
CONF_RECEIVER_INPUT,
|
|
)
|
|
|
|
_KEY_PATH = "/tmp/nsh-test-adbkey"
|
|
|
|
|
|
_STUB_NAMES = (
|
|
"voluptuous",
|
|
"homeassistant",
|
|
"homeassistant.config_entries",
|
|
"homeassistant.core",
|
|
"homeassistant.helpers",
|
|
"homeassistant.helpers.entity_registry",
|
|
"homeassistant.helpers.device_registry",
|
|
"homeassistant.helpers.selector",
|
|
# Loaded execution runs beneath the stubs; never escape this file either.
|
|
"nsh.helpers",
|
|
"nsh.config_flow",
|
|
)
|
|
|
|
|
|
def _install_import_stubs() -> None:
|
|
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
|
|
if sys.modules.get("voluptuous") is not None:
|
|
return
|
|
|
|
vol = types.ModuleType("voluptuous")
|
|
vol.Required = lambda key, default=None: key
|
|
vol.Optional = lambda key, default=None: key
|
|
vol.Schema = lambda schema: schema
|
|
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) # class statement passes domain=DOMAIN
|
|
super().__init_subclass__(**kwargs)
|
|
|
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
|
return _form(step_id, errors or {})
|
|
|
|
def async_create_entry(self, *, title, data, options=None):
|
|
return {"type": "create_entry", "title": title, "data": data,
|
|
"options": options}
|
|
|
|
def _form(step_id, errors):
|
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
|
|
|
config_entries.ConfigFlow = ConfigFlow
|
|
class _BaseOptionsFlow:
|
|
pass
|
|
|
|
# t29 G3b: the current options-flow API surface on the local stub
|
|
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):
|
|
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 and hasattr(
|
|
scheduler, "async_schedule_reload"
|
|
):
|
|
scheduler.async_schedule_reload(entry_id)
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
config_entries.OptionsFlow = _BaseOptionsFlow
|
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
|
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
|
|
|
|
helpers = types.ModuleType("homeassistant.helpers")
|
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
|
|
|
class _Registry:
|
|
entities: dict = {}
|
|
|
|
def async_get(self, entity_id):
|
|
return None
|
|
|
|
entity_registry.async_get = lambda hass: _Registry()
|
|
helpers.entity_registry = entity_registry
|
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
|
sys.modules["homeassistant.helpers"] = helpers
|
|
|
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
|
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
|
|
helpers.device_registry = device_registry
|
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
|
entity_stublib.install_selector_faces(selector)
|
|
sys.modules["homeassistant.helpers.selector"] = selector
|
|
|
|
|
|
class _Config:
|
|
config_dir = "/tmp/nsh-test-config"
|
|
|
|
|
|
class _FakeHass:
|
|
config = _Config
|
|
|
|
|
|
@staticmethod
|
|
async def async_add_executor_job(fn, *args):
|
|
return fn(*args)
|
|
|
|
|
|
def _config_flow_module():
|
|
_install_import_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 _flow(user_input_host: str, monkeypatch=None):
|
|
module = _config_flow_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = _FakeHass
|
|
flow._user_data = {
|
|
"name": "Nvidia Shield",
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: user_input_host,
|
|
CONF_ADB_PORT: 5555,
|
|
CONF_JELLYFIN_ENTITY: "",
|
|
CONF_RECEIVER_ENTITY: "",
|
|
CONF_RECEIVER_INPUT: "",
|
|
CONF_POWER_ENTITY: "",
|
|
CONF_ADB_STORE_APP_INFO: True,
|
|
}
|
|
if monkeypatch is not None:
|
|
# Patch module-level resolution — the call site is a plain global.
|
|
monkeypatch.setattr(
|
|
module, "ensure_adb_key", lambda _config_dir: _KEY_PATH
|
|
)
|
|
return flow
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def scoped_stubs():
|
|
"""Contain the import stubs to this module's tests (t7 INFO).
|
|
|
|
Snapshots every injected sys.modules entry and restores or removes it on
|
|
teardown, so later tests never observe the fake homeassistant/voluptuous
|
|
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
|
|
"""
|
|
snapshot = entity_stublib.snapshot_modules()
|
|
_install_import_stubs()
|
|
yield
|
|
entity_stublib.restore_modules(snapshot)
|
|
|
|
|
|
@pytest.fixture
|
|
def mocked_test_connect(monkeypatch):
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
mock = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(ShieldConnectionManager, "test_connect", mock)
|
|
# Preload + patch the lazy-loaded config-flow module's ensure_adb_key
|
|
# (no key material — and no adb_shell keygen — during tests).
|
|
module = _config_flow_module()
|
|
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: _KEY_PATH)
|
|
return mock
|
|
|
|
|
|
async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
|
|
"""F1 (HIGH): exactly (host, port, key_path) — no inherited hass arg."""
|
|
flow = _flow("shield.local")
|
|
|
|
result = await flow.async_step_adb_pair({"submit": True})
|
|
|
|
mocked_test_connect.assert_awaited_once_with(
|
|
"shield.local", 5555, _KEY_PATH
|
|
)
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
|
async def test_pairing_submit_reports_adb_failed_on_false(mocked_test_connect):
|
|
flow = _flow("shield.local")
|
|
mocked_test_connect.return_value = False
|
|
|
|
result = await flow.async_step_adb_pair({"submit": True})
|
|
|
|
mocked_test_connect.assert_awaited_once()
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "adb_failed"}
|
|
|
|
|
|
async def test_pairing_submit_without_host_reports_error(mocked_test_connect):
|
|
flow = _flow("") # no host resolvable
|
|
|
|
result = await flow.async_step_adb_pair({"submit": True})
|
|
|
|
mocked_test_connect.assert_not_awaited()
|
|
assert result["errors"] == {"base": "adb_no_host"}
|