"""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_(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_`` and a submitted form to ``async_step_``, 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