Both observations from the t8-t12 batch review, settled by execution rather than
by argument.
O2 — WHICH STATES MAY CARRY A NOW-PLAYING ROW. The review read
`extra_state_attributes` and argued the row publishes the entity state verbatim,
so `now_playing_state: on` is reachable, while the README documents only
playing/paused/buffering/idle; t10's author had reported that `on` and `off`
publish no row at all. The probe settles both halves:
* the t10 account is true on the NATURAL ladder — `on` means no source reported a
power or playback fact, and every tier that can produce a reading sets
`reported = True` in the ladder, so the two cannot meet;
* the review is right about the RULE. It was "publish unless the state is off", an
assumption nothing enforced: forcing the ladder to `on` (and to `standby`, which
the README does not even mention) published `now_playing_state: on`/`standby`
beside a real title.
So the rule is now explicit — `NOW_PLAYING_STATE_VALUES = ("playing", "paused",
"buffering", "idle")` — and a reading is dropped whole when the entity state is
anything else, exactly as `off` always dropped it. Nothing legitimate is lost: a
tier only ever answers in one of those four (`idle` for "reported, nothing
playing", which is where the foreground tier's remembered-playback bridge lands).
The README says the same thing in the table row and in prose, and
tests/test_now_playing_publication.py walks EVERY `MediaPlayerState` member with a
reading present (the four publish themselves; on/off/standby publish nothing),
then reads the four values back OUT OF THE README and compares them to the
constant, so code and docs cannot drift again.
O3 — A REJECTED APP PASTE NAMES ITS BAD LINES. The bulk paste was all-or-nothing
and reported a generic `invalid_app_map`, so a 30-row paste with one typo had to
be bisected by hand. `_app_map_parse` now returns `(map | None, problems)` where
each problem carries the 1-based line NUMBER, the line as the user typed it, and
why (empty/space-bearing package, empty name, space in the icon), and the
rejection puts them in the form's `description_placeholders["invalid_lines"]`,
which the error copy interpolates — capped at 4 named lines with the remaining
count, so the message stays readable. The map is still None when anything is
wrong (never half-stored) and the detail is cleared as soon as a paste parses.
The single-row editor is untouched and keeps its own `invalid_app_row`; a test
asserts the new per-line report and `_valid_app_row` accept and refuse the same
rows, so a paste and a typed row cannot disagree.
Verify: 794 passed / 4 skipped, 795 passed / 3 skipped (with androidtvremote2),
ruff clean.
1563 lines
59 KiB
Python
1563 lines
59 KiB
Python
"""P3-1 tests: standalone setup — optional androidtv entity + app fallback.
|
|
|
|
The integration no longer hard-requires an external androidtv media_player:
|
|
the protocol current-app push can be the only state source. Covers the flow
|
|
validation (no state source → form error), the standalone pairing route,
|
|
entry data omitting the field, media_player app_id/app_name fallback, and
|
|
migration validity for v2 entries without the field.
|
|
"""
|
|
|
|
import asyncio # noqa: F401 (async steps need the event loop)
|
|
import gc
|
|
import logging
|
|
import sys
|
|
import types
|
|
import warnings
|
|
from pathlib import Path # noqa: E402 (F1 test key staging)
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
|
|
import entity_stublib
|
|
import pytest
|
|
import voluptuous_mirror as vol_mirror
|
|
from conftest import _SHIELD_DIR, _load_module
|
|
from nsh.const import (
|
|
CONF_ADB_ENABLED,
|
|
CONF_ADB_PORT,
|
|
CONF_ANDROIDTV_ENTITY,
|
|
CONF_HOST,
|
|
CONF_PROTOCOL_ENABLED,
|
|
SCRIPT_COMMANDS,
|
|
SCRIPT_FIELD_PREFIX,
|
|
)
|
|
|
|
_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",
|
|
"nsh.entry",
|
|
)
|
|
|
|
|
|
class DiscoveryDedupAbortError(Exception):
|
|
"""Stands in for data_entry_flow.AbortFlow inside the discovery stub.
|
|
|
|
HA's `ConfigFlow._async_handle_discovery_without_unique_id` RAISES on a
|
|
duplicate (the flow manager turns that into an abort result); modelling it
|
|
as a raise is what makes an un-awaited call fail loudly here.
|
|
"""
|
|
|
|
def __init__(self, reason):
|
|
super().__init__(reason)
|
|
self.reason = reason
|
|
|
|
|
|
def _install_stubs() -> None:
|
|
"""Import-time stand-ins (idempotent; same shape as the pairing tests).
|
|
|
|
t30: the guard rebuilds whenever the module-level holder is missing —
|
|
a later file's scoped teardown may pop the shared stubs this file's
|
|
tests rely on (the flow-class surfaces live on the local base which we
|
|
MUST rebind against)."""
|
|
if sys.modules.get("voluptuous") is not None and hasattr(
|
|
sys.modules.get(__name__), "_zc_holder"
|
|
):
|
|
return
|
|
|
|
vol = types.ModuleType("voluptuous")
|
|
# t33 F-A: a faithful mirror — the default stays ON the key, so a schema
|
|
# whose host default was dropped FAILS here instead of looking identical
|
|
# to one that carries it (the old bare-key stub hid exactly that bug).
|
|
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 MENU result shape — the flow manager dispatches the
|
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
|
return {
|
|
"type": "menu",
|
|
"step_id": step_id,
|
|
"menu_options": list(menu_options),
|
|
"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 _zc_holder.entries
|
|
|
|
def _async_in_progress(self, include_uninitialized=False, **kwargs):
|
|
return _zc_holder.in_progress
|
|
|
|
# t37 D3: HA's own helper, modelled faithfully — a COROUTINE that
|
|
# dedups uniqueness-less discovery by raising. An un-awaited call
|
|
# therefore does nothing at all, which is exactly the production bug
|
|
# this replaces.
|
|
async def _async_handle_discovery_without_unique_id(self):
|
|
_zc_holder.discovery_without_unique_id_calls += 1
|
|
if getattr(self, "_unique_id", None) is not None:
|
|
return
|
|
if self._async_current_entries():
|
|
raise DiscoveryDedupAbortError("already_configured")
|
|
await self.async_set_unique_id(_zc_holder.discovery_unique_id)
|
|
self._abort_if_unique_id_configured()
|
|
if self._async_in_progress(include_uninitialized=True):
|
|
raise DiscoveryDedupAbortError("already_in_progress")
|
|
|
|
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 MENU result shape — the flow manager dispatches the
|
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
|
return {
|
|
"type": "menu",
|
|
"step_id": step_id,
|
|
"menu_options": list(menu_options),
|
|
"description_placeholders": dict(
|
|
description_placeholders or {}
|
|
),
|
|
}
|
|
|
|
def async_create_entry(self, *, title, data):
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
# t29 G3b: the current options-flow API surface on the local stub
|
|
class _SuggestedMixin:
|
|
def add_suggested_values_to_schema(self, schema, suggested):
|
|
schema_dict = getattr(schema, "schema", None) or (
|
|
schema if isinstance(schema, dict) else {}
|
|
)
|
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
|
return schema
|
|
|
|
class OptionsFlowWithReload(_SuggestedMixin):
|
|
# t56: the MENU surface is shared with OptionsFlow, like async_show_form.
|
|
async_show_menu = OptionsFlow.async_show_menu
|
|
|
|
def async_create_entry(self, *, title, data):
|
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
|
if entry_id is not None and scheduler is not None and hasattr(
|
|
scheduler, "async_schedule_reload"
|
|
):
|
|
scheduler.async_schedule_reload(entry_id)
|
|
return {"type": "create_entry", "title": title, "data": data}
|
|
|
|
# Shared holder so tests can swap the current-entry list through the
|
|
# module object (the stub closure reads this same object).
|
|
_zc_holder = types.SimpleNamespace(
|
|
entries=[],
|
|
in_progress=[],
|
|
discovery_unique_id="default_discovery_unique_id",
|
|
discovery_without_unique_id_calls=0,
|
|
)
|
|
sys.modules[__name__]._zc_holder = _zc_holder
|
|
|
|
class AbortIfConfiguredError(Exception):
|
|
pass
|
|
|
|
config_entries.ConfigFlow = ConfigFlow
|
|
config_entries.OptionsFlow = OptionsFlow
|
|
OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing
|
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
|
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"
|
|
|
|
class _Platform:
|
|
MEDIA_PLAYER = "media_player"
|
|
SENSOR = "sensor"
|
|
REMOTE = "remote"
|
|
BUTTON = "button"
|
|
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
|
|
|
|
ha_const.Platform = _Platform
|
|
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={})
|
|
helpers.entity_registry = entity_registry
|
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
|
sys.modules["homeassistant.helpers"] = helpers
|
|
|
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
|
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
|
helpers.device_registry = device_registry
|
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
|
entity_stublib.install_selector_faces(selector)
|
|
sys.modules["homeassistant.helpers.selector"] = selector
|
|
|
|
|
|
class FakeConfigEntries:
|
|
def __init__(self):
|
|
self.updating_entry_calls: list = []
|
|
self.reloaded_entry_ids: list[str] = []
|
|
|
|
def async_get_entry(self, entry_id):
|
|
return None
|
|
|
|
def async_update_entry(self, entry, **kwargs):
|
|
self.updating_entry_calls.append((entry, kwargs))
|
|
for key, value in kwargs.items():
|
|
if key in ("options", "data", "unique_id"):
|
|
setattr(entry, key, value)
|
|
|
|
def async_schedule_reload(self, entry_id):
|
|
self.reloaded_entry_ids.append(entry_id)
|
|
|
|
|
|
class FakeHass:
|
|
def __init__(self, tmp_dir: str, states: dict | None = None):
|
|
self.config = SimpleNamespace(config_dir=tmp_dir)
|
|
self.config_entries = FakeConfigEntries()
|
|
self._states = states or {}
|
|
|
|
class _States:
|
|
def get(self, entity_id):
|
|
return self._states.get(entity_id)
|
|
|
|
self._states_ref = _States()
|
|
self._states_ref._states = self._states
|
|
self.states = self._states_ref
|
|
|
|
@staticmethod
|
|
async def async_add_executor_job(fn, *args):
|
|
return fn(*args)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def scoped_stubs():
|
|
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
|
snapshot = entity_stublib.snapshot_modules()
|
|
_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 _entry_module():
|
|
_install_stubs()
|
|
if "nsh.entry" not in sys.modules:
|
|
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
|
|
return sys.modules["nsh.entry"]
|
|
|
|
|
|
def _record(sink):
|
|
"""An async_set_unique_id stand-in that records what the flow tried."""
|
|
|
|
async def _set(unique_id):
|
|
sink.append(unique_id)
|
|
|
|
return _set
|
|
|
|
|
|
def _user_input(**overrides) -> dict:
|
|
base = {
|
|
"name": "mdarcy",
|
|
CONF_PROTOCOL_ENABLED: False,
|
|
CONF_HOST: "",
|
|
CONF_ADB_ENABLED: False,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
class TestStandaloneValidation:
|
|
async def test_neither_source_shows_form_error(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
result = await flow.async_step_user(_user_input())
|
|
|
|
assert result["type"] == "form"
|
|
assert result["step_id"] == "user"
|
|
assert result["errors"] == {"base": "no_state_source"}
|
|
|
|
async def test_the_androidtv_field_is_gone_from_the_form(self, tmp_path):
|
|
"""t41: we own the stack — no other integration is a state source."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
result = await flow.async_step_user(None)
|
|
|
|
fields = vol_mirror.declared_fields(result["data_schema"])
|
|
assert CONF_ANDROIDTV_ENTITY not in fields
|
|
# the two native transports are what is left to choose from
|
|
assert CONF_PROTOCOL_ENABLED in fields
|
|
assert CONF_ADB_ENABLED in fields
|
|
|
|
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow._user_data = {}
|
|
from nsh.connection import ShieldConnectionManager
|
|
|
|
class _Client:
|
|
async def async_generate_cert_if_missing(self):
|
|
return True
|
|
|
|
async def async_start_pairing(self):
|
|
return "8421"
|
|
|
|
monkeypatch.setattr(
|
|
ShieldConnectionManager, "build_protocol_client",
|
|
lambda *a, **k: _Client(),
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "protocol_cert_paths", lambda hass: ("/tmp/a.pem", "/tmp/a.key")
|
|
)
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(**{CONF_PROTOCOL_ENABLED: True})
|
|
)
|
|
|
|
assert result["type"] == "form"
|
|
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
|
|
|
async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch):
|
|
"""t41: every value lives in options; `data` carries no leftovers."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow._user_data = {
|
|
"name": "mdarcy",
|
|
CONF_PROTOCOL_ENABLED: True,
|
|
CONF_HOST: "shield.local",
|
|
}
|
|
|
|
# skip the pairing route: drive _create_entry directly
|
|
result = flow._create_entry()
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["data"] == {}
|
|
|
|
|
|
class TestAdbOnlyStandalone:
|
|
async def test_adb_only_standalone_is_valid(self, tmp_path):
|
|
"""F2: ADB media-session mode is a third accepted state source."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(
|
|
**{
|
|
CONF_PROTOCOL_ENABLED: False,
|
|
"adb_enabled": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
# routed into ADB pairing — no no_state_source error
|
|
assert result["type"] == "form"
|
|
assert result["step_id"] == "adb_pair"
|
|
assert result["errors"] == {}
|
|
|
|
async def test_error_still_fires_with_no_source_at_all(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
result = await flow.async_step_user(
|
|
_user_input(**{"adb_enabled": False})
|
|
)
|
|
|
|
assert result["errors"] == {"base": "no_state_source"}
|
|
|
|
|
|
class TestMigrationDropsTheEntityKey:
|
|
"""t41: the androidtv key is dropped from BOTH dicts with a logged note.
|
|
|
|
An entry that still carries it keeps working until then (nothing reads it) —
|
|
the migration is what removes the last trace of the entity path.
|
|
"""
|
|
|
|
async def test_v1_entry_reaches_v3_without_the_entity_key(self, tmp_path, caplog):
|
|
module = _entry_module()
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = SimpleNamespace(
|
|
version=1,
|
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
title="mdarcy",
|
|
)
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
ok = await module.async_migrate_entry(hass, entry)
|
|
|
|
assert ok is True
|
|
assert CONF_ANDROIDTV_ENTITY not in entry.data
|
|
assert CONF_ANDROIDTV_ENTITY not in entry.options
|
|
assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
|
|
assert entry.data == {}
|
|
# the note names what was dropped, so the log is actionable
|
|
notes = [record.getMessage() for record in caplog.records]
|
|
assert any("media_player.shield" in note for note in notes), notes
|
|
assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
|
|
|
|
async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
|
|
module = _entry_module()
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = SimpleNamespace(version=2, data={}, options={}, title="mdarcy")
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
ok = await module.async_migrate_entry(hass, entry)
|
|
|
|
assert ok is True
|
|
notes = [record.getMessage() for record in caplog.records]
|
|
assert any("Migrated mdarcy config entry to version 3" in note for note in notes), notes
|
|
|
|
async def test_v2_entry_with_a_legacy_address_migrates_to_the_one_field(self, tmp_path):
|
|
"""An entry written before the unification lands on CONF_HOST."""
|
|
module = _entry_module()
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = SimpleNamespace(
|
|
version=2,
|
|
data={},
|
|
options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
|
|
title="mdarcy",
|
|
)
|
|
|
|
ok = await module.async_migrate_entry(hass, entry)
|
|
|
|
assert ok is True
|
|
assert entry.options[CONF_HOST] == "shield.local"
|
|
assert "protocol_host" not in entry.options
|
|
|
|
def test_the_stored_version_and_the_flow_cannot_drift(self, tmp_path):
|
|
# _form_module owns the scoped stub install/pop — never a bare
|
|
# entity_stublib.install() here: a shallow stub set leaks into the
|
|
# entity-module loader of later tests.
|
|
module = _form_module()
|
|
from nsh.const import ENTRY_VERSION
|
|
|
|
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
|
|
|
|
|
|
_OPTIONS_SUBSTEP_FIELDS = {
|
|
"connection": {
|
|
CONF_HOST,
|
|
CONF_ADB_PORT,
|
|
CONF_ADB_ENABLED,
|
|
CONF_PROTOCOL_ENABLED,
|
|
"adb_store_app_info",
|
|
"logcat_events",
|
|
"adb_command_enabled",
|
|
"jellyfin_entity",
|
|
"power_entity",
|
|
},
|
|
"receiver": {"receiver_entity", "receiver_input"},
|
|
# 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": {
|
|
"remote_command_script",
|
|
"channel_digit_delay",
|
|
"channel_send_enter",
|
|
"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 them.
|
|
_OPTIONS_NESTED_STEPS = {
|
|
"apps_text": "apps",
|
|
"apps_add": "apps",
|
|
"apps_edit": "apps",
|
|
"apps_edit_form": "apps",
|
|
"apps_remove": "apps",
|
|
"apps_remove_confirm": "apps",
|
|
}
|
|
|
|
def _options_step_of(key: str) -> str:
|
|
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
|
|
if key in fields:
|
|
return step
|
|
if key.startswith(SCRIPT_FIELD_PREFIX):
|
|
return "scripts"
|
|
raise AssertionError(f"no options sub-screen owns {key!r}")
|
|
|
|
|
|
async def _options_submit(flow, payload: dict, *, save=False):
|
|
"""Drive the t56 options MENU into the sub-screens owning `payload` keys.
|
|
|
|
Returns the last step result, so an error assertion reads the same as it did
|
|
when the page was a single form. With ``save=True`` the menu's "Save and
|
|
close" row is chosen afterwards, returning the create_entry result.
|
|
"""
|
|
grouped: dict[str, dict] = {}
|
|
for key, value in payload.items():
|
|
grouped.setdefault(_options_step_of(key), {})[key] = value
|
|
result = await flow.async_step_init(None)
|
|
for step, sub in grouped.items():
|
|
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"]
|
|
result = await getattr(flow, f"async_step_{step}")(sub)
|
|
if save:
|
|
# t64: a nested row returns to the APPS menu, so walk back to the main menu
|
|
# 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
|
|
|
|
|
|
async def _options_open(flow, step: str):
|
|
"""Choose an options sub-screen from the menu and open its form."""
|
|
result = await flow.async_step_init(None)
|
|
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)
|
|
|
|
|
|
async def _setup_finish(flow, result):
|
|
"""Walk the t56 setup MENU to the entry, starting from a step result.
|
|
|
|
Chooses any pairing row it needs to (the flow's menu_options are the source
|
|
of truth) and then Finish, returning the create_entry result.
|
|
"""
|
|
while result["type"] == "menu":
|
|
choice = (
|
|
"finish"
|
|
if "finish" in result["menu_options"]
|
|
else result["menu_options"][0]
|
|
)
|
|
result = await getattr(flow, f"async_step_{choice}")(None)
|
|
return result
|
|
|
|
|
|
def _cmd_player(modules):
|
|
"""Standalone media_player entity via the command-routing harness."""
|
|
import test_command_routing as routing
|
|
|
|
player, _served, _intents = routing.make_player(modules)
|
|
return player, None, None
|
|
|
|
|
|
class TestAppIconColumn:
|
|
"""P5-2: the editor's optional icon column (package=name|icon)."""
|
|
|
|
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
|
module = _form_module()
|
|
entry = SimpleNamespace(options={}, title="Shield")
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
|
|
|
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
|
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
|
|
|
async def test_single_column_map_stays_valid(self, tmp_path):
|
|
module = _form_module()
|
|
entry = SimpleNamespace(options={}, title="Shield")
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate"})
|
|
|
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
|
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
|
|
|
|
async def test_prefill_round_trips_the_icon_column(self):
|
|
module = _form_module()
|
|
|
|
# the serializer writes the second column through on prefill
|
|
text = module._app_map_to_text(
|
|
{"ar.tvplayer.tv": "TiviMate"},
|
|
{"ar.tvplayer.tv": "http://i.local/t.png"},
|
|
)
|
|
assert text == "ar.tvplayer.tv=TiviMate|http://i.local/t.png"
|
|
|
|
# and the parser round-trips it back
|
|
icons: dict[str, str] = {}
|
|
app_map = module._app_map_from_text(text, icons)
|
|
assert app_map == {"ar.tvplayer.tv": "TiviMate"}
|
|
assert icons == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
|
|
|
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
|
module = _form_module()
|
|
entry = SimpleNamespace(options={}, title="Shield")
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
result = await _options_submit(flow, {"app_name_map": "p=Name|has spaces"})
|
|
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "invalid_app_map"}
|
|
|
|
async def test_a_nameless_row_is_rejected_by_the_editor(self, tmp_path):
|
|
"""t49: the editor already refuses 'com.x=' — a blank name cannot get in.
|
|
|
|
The source list filters blanks anyway, because the map can also be
|
|
hand-edited; this pins the other half: the page will not STORE one.
|
|
"""
|
|
module = _form_module()
|
|
|
|
assert module._app_map_from_text("com.blank=", {}) is None
|
|
assert module._app_map_from_text("com.blank= ", {}) is None
|
|
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = SimpleNamespace(options={}, title="Shield")
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
result = await _options_submit(
|
|
flow, {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
|
|
)
|
|
|
|
assert result["type"] == "form" # the Apps sub-screen re-rendered
|
|
assert result["errors"] == {"base": "invalid_app_map"}
|
|
assert "app_name_map" not in flow._options # nothing half-stored
|
|
|
|
|
|
class TestAppPasteRejectionNamesTheLine:
|
|
"""t17/O3: a rejected paste says WHICH line is wrong, not just that one is.
|
|
|
|
The all-or-nothing rule stays (nothing is half-stored), but the message now
|
|
quotes each offending line with its number and the reason, so a 30-row paste
|
|
with one typo is fixed instead of bisected by hand. The row editor's own
|
|
single-row behaviour is pinned alongside, because neither half may regress.
|
|
"""
|
|
|
|
def _flow(self, tmp_path, options=None):
|
|
module = _form_module()
|
|
entry = SimpleNamespace(options=dict(options or {}), title="Shield")
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
return module, flow
|
|
|
|
async def test_the_message_names_the_numbered_bad_line(self, tmp_path):
|
|
module, flow = self._flow(tmp_path)
|
|
|
|
result = await _options_submit(
|
|
flow,
|
|
{
|
|
"app_name_map": (
|
|
"ar.tvplayer.tv=TiviMate\n"
|
|
"com.no.equals.sign\n"
|
|
"com.netflix.ninja=Netflix"
|
|
)
|
|
},
|
|
)
|
|
|
|
assert result["errors"] == {"base": "invalid_app_map"}
|
|
detail = result["description_placeholders"]["invalid_lines"]
|
|
# the SECOND line is the bad one, and it is quoted as the user wrote it
|
|
assert "line 2" in detail
|
|
assert "com.no.equals.sign" in detail
|
|
assert "no '='" in detail
|
|
assert "line 1" not in detail and "line 3" not in detail
|
|
assert "app_name_map" not in flow._options # still all-or-nothing
|
|
|
|
async def test_every_bad_line_is_named_up_to_the_cap(self, tmp_path):
|
|
module, flow = self._flow(tmp_path)
|
|
|
|
result = await _options_submit(
|
|
flow,
|
|
{
|
|
"app_name_map": (
|
|
"com.good.one=Name\n"
|
|
"com.bad.space in id=Name\n"
|
|
"com.bad.blank=\n"
|
|
"com.bad.icon=Name|http://x y/z.png"
|
|
)
|
|
},
|
|
)
|
|
|
|
detail = result["description_placeholders"]["invalid_lines"]
|
|
assert "line 2" in detail and "package id" in detail
|
|
assert "line 3" in detail and "name after the '='" in detail
|
|
assert "line 4" in detail and "icon after the '|'" in detail
|
|
assert "line 1" not in detail # no false accusation
|
|
|
|
async def test_a_long_bad_paste_is_capped_and_counts_the_rest(self, tmp_path):
|
|
module, flow = self._flow(tmp_path)
|
|
|
|
result = await _options_submit(
|
|
flow,
|
|
{"app_name_map": "\n".join(f"no-equals-{n}" for n in range(1, 8))},
|
|
)
|
|
|
|
detail = result["description_placeholders"]["invalid_lines"]
|
|
assert module.APP_MAP_PROBLEM_LIMIT == 4
|
|
assert "line 4" in detail
|
|
assert "line 5" not in detail
|
|
assert "3 more line(s)" in detail, detail
|
|
|
|
async def test_a_blank_line_is_not_a_row_and_never_a_problem(self, tmp_path):
|
|
module, flow = self._flow(tmp_path)
|
|
|
|
await _options_submit(
|
|
flow, {"app_name_map": "\n\nar.tvplayer.tv=TiviMate\n\n"}
|
|
)
|
|
|
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
|
|
|
async def test_a_good_paste_clears_the_previous_rejection(self, tmp_path):
|
|
module, flow = self._flow(tmp_path)
|
|
await _options_submit(flow, {"app_name_map": "com.bad row=Name"})
|
|
assert flow._app_map_problems
|
|
|
|
result = await _options_submit(flow, {"app_name_map": "com.good.one=Name"})
|
|
|
|
assert result["type"] == "menu" # back at the Apps screen, nothing left over
|
|
assert flow._app_map_problems == ""
|
|
assert flow._options_dict["app_name_map"] == {"com.good.one": "Name"}
|
|
|
|
async def test_the_parser_reports_problems_in_a_pure_call(self):
|
|
module = _form_module()
|
|
|
|
app_map, problems = module._app_map_parse("com.a=One\nbroken\ncom.b=", {})
|
|
|
|
assert app_map is None
|
|
assert [number for number, _line, _why in problems] == [2, 3]
|
|
assert problems[0][1] == "broken"
|
|
assert "no '='" in problems[0][2][0]
|
|
assert "name after the '='" in problems[1][2][0]
|
|
# and a valid paste reports nothing at all
|
|
assert module._app_map_parse("com.a=One", {}) == ({"com.a": "One"}, [])
|
|
|
|
async def test_the_report_and_the_wrapper_cannot_disagree(self):
|
|
module = _form_module()
|
|
icons: dict[str, str] = {}
|
|
|
|
assert module._app_map_from_text("com.a=One|http://x/i.png", icons) == {
|
|
"com.a": "One"
|
|
}
|
|
assert icons == {"com.a": "http://x/i.png"}
|
|
assert module._app_map_from_text("com.bad row=One", {}) is None
|
|
|
|
def test_the_paste_report_and_the_row_editor_apply_the_same_rule(self):
|
|
"""t17/O3 rider: the one-row path is unchanged, and both paths agree.
|
|
|
|
The row editor is driven in tests/test_config_flow_ux.py
|
|
(``test_an_invalid_row_is_refused_and_stores_nothing``); what matters HERE
|
|
is that the new per-line report cannot reject a row the editor accepts (or
|
|
wave one through that the editor refuses) — otherwise a paste and a typed
|
|
row would disagree about the same text.
|
|
"""
|
|
module = _form_module()
|
|
rows = [
|
|
("com.a", "Alpha", "", True),
|
|
("com.a", "Alpha", "http://x/i.png", True),
|
|
("com.bad row", "Alpha", "", False),
|
|
("", "Alpha", "", False),
|
|
("com.a", "", "", False),
|
|
("com.a", "Alpha", "http://x y/i.png", False),
|
|
]
|
|
|
|
for package, name, icon, valid in rows:
|
|
assert module._valid_app_row(package, name, icon) is valid, (package, name)
|
|
assert bool(module._app_row_problems(package, name, icon)) is not valid, (
|
|
package,
|
|
name,
|
|
icon,
|
|
)
|
|
|
|
# the paste path STRIPS before checking, which is why a whitespace-only
|
|
# name cannot get in through it either (t49: nothing blank is stored)
|
|
app_map, problems = module._app_map_parse("com.a= ", {})
|
|
assert app_map is None
|
|
assert problems[0][2] == ["the name after the '=' is empty"]
|
|
|
|
|
|
class TestChannelEntryOptions:
|
|
"""P5-2: digit-delay + optional send-enter on the options page."""
|
|
|
|
def _options_flow(self, tmp_path, options):
|
|
from types import SimpleNamespace as DefaultEntry
|
|
|
|
module = _form_module()
|
|
entry = DefaultEntry(options=dict(options) if options else {})
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
|
hass = FakeHass(str(tmp_path))
|
|
flow.hass = hass
|
|
flow._options = dict(options) if options else {}
|
|
return module, flow
|
|
|
|
async def test_digit_delay_and_send_enter_store(self, tmp_path):
|
|
module, flow = self._options_flow(tmp_path, options={})
|
|
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
await _options_submit(flow, {"channel_digit_delay": "0.3", "channel_send_enter": True})
|
|
|
|
assert flow._options["channel_digit_delay"] == 0.3
|
|
assert flow._options["channel_send_enter"] is True
|
|
|
|
async def test_digit_delay_out_of_window_rejects(self, tmp_path):
|
|
module, flow = self._options_flow(tmp_path, options={})
|
|
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
result = await _options_submit(flow, {"channel_digit_delay": "2.0"})
|
|
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
|
|
|
async def test_digit_delay_not_a_number_rejects(self, tmp_path):
|
|
module, flow = self._options_flow(tmp_path, options={})
|
|
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
|
|
|
result = await _options_submit(flow, {"channel_digit_delay": "slow"})
|
|
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
|
|
|
|
|
class TestUniqueIdSource:
|
|
"""t41: no unique_id is derived from another integration, at any stage.
|
|
|
|
t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
|
|
removes the entity path entirely, so the ONLY identity source left is the
|
|
protocol MAC learned during pairing (``async_set_unique_id(mac)``).
|
|
"""
|
|
|
|
def test_no_entity_derived_unique_id_remains(self):
|
|
flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
|
|
|
assert "_unique_id_from_atv_device" not in flow_src
|
|
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
|
# no registry lookups for another integration's devices either
|
|
assert "device_registry" not in flow_src
|
|
|
|
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
|
recorded = []
|
|
flow.async_set_unique_id = _record(recorded)
|
|
|
|
await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
|
|
|
|
assert recorded == []
|
|
|
|
|
|
class TestListenerComboGone:
|
|
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
|
|
def test_no_update_listener_remains_on_source_and_flow_paths(self):
|
|
from pathlib import Path
|
|
|
|
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
|
init_src = (base / "__init__.py").read_text()
|
|
flow_src = (base / "config_flow.py").read_text()
|
|
# no update listener registration remains on the integration side
|
|
assert "add_update_listener" not in init_src
|
|
assert "_async_update_listener" not in init_src
|
|
# the flows no longer schedule_reload (zeroconf host update / reauth)
|
|
assert "async_schedule_reload" not in flow_src
|
|
# the current reauth contract is in place
|
|
assert "async_update_reload_and_abort" in flow_src
|
|
assert "_get_reauth_entry" in flow_src
|
|
|
|
def test_reauth_success_reloads_via_the_new_api(self):
|
|
"""reload-on-reauth-success passes through the new helper."""
|
|
# the stub records a scheduled reload on the reauth path
|
|
import entity_stublib
|
|
|
|
assert entity_stublib is not None # the stub API exists (config flow class)
|
|
|
|
|
|
class TestLegacyUniqueIdMigration:
|
|
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
|
|
|
async def test_migrates_to_entry_id_without_protocol(self, tmp_path):
|
|
import entity_stublib
|
|
|
|
entity_stublib.load_entity_modules()
|
|
modules = sys.modules["nsh.media_player"]
|
|
from types import SimpleNamespace as MigrationEntry
|
|
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = MigrationEntry(
|
|
entry_id="cfgid123",
|
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
options={},
|
|
title="Shield",
|
|
unique_id="media_player.shield", # legacy
|
|
runtime_data=None,
|
|
)
|
|
|
|
await modules._migrate_legacy_unique_id(hass, entry)
|
|
|
|
assert entry.unique_id == "cfgid123" # config-entry id fallback
|
|
|
|
async def test_migrates_to_protocol_mac_when_resolvable(self, tmp_path):
|
|
import entity_stublib
|
|
|
|
entity_stublib.load_entity_modules()
|
|
modules = sys.modules["nsh.media_player"]
|
|
from types import SimpleNamespace as MigrationEntry
|
|
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = MigrationEntry(
|
|
entry_id="cfgid123",
|
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
options={},
|
|
title="Shield",
|
|
unique_id="media_player.shield",
|
|
runtime_data=None,
|
|
)
|
|
|
|
class _Manager:
|
|
async def async_protocol_device_info(self):
|
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
|
|
|
entry.runtime_data = _Manager()
|
|
|
|
await modules._migrate_legacy_unique_id(hass, entry)
|
|
|
|
assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # MAC preferred
|
|
|
|
async def test_mac_collision_falls_back_to_entry_id(self, tmp_path):
|
|
"""t26 F4: an existing entry owning the target MAC blocks the move."""
|
|
from types import SimpleNamespace as MigrationEntry
|
|
|
|
import entity_stublib
|
|
|
|
entity_stublib.load_entity_modules()
|
|
modules = sys.modules["nsh.media_player"]
|
|
entry = MigrationEntry(
|
|
entry_id="cfgid123",
|
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
options={},
|
|
title="Shield",
|
|
unique_id="media_player.shield",
|
|
runtime_data=None,
|
|
)
|
|
|
|
class _Manager:
|
|
async def async_protocol_device_info(self):
|
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
|
|
|
entry.runtime_data = _Manager()
|
|
|
|
class _Entries:
|
|
entries = [
|
|
SimpleNamespace(entry_id="cfgid999", unique_id="aa:bb:cc:dd:ee:ff")
|
|
]
|
|
updating_entry_calls: list = []
|
|
|
|
def async_update_entry(self, entry, **kwargs):
|
|
self.updating_entry_calls.append((entry, kwargs))
|
|
for key, value in kwargs.items():
|
|
if key in ("options", "data", "unique_id"):
|
|
setattr(entry, key, value)
|
|
|
|
entries = _Entries()
|
|
entries.hass = FakeHass(str(tmp_path))
|
|
hass = entries.hass
|
|
hass.config_entries = entries
|
|
|
|
await modules._migrate_legacy_unique_id(hass, entry)
|
|
|
|
# collision → the config-entry id is used instead
|
|
assert entry.unique_id == "cfgid123"
|
|
|
|
async def test_free_mac_is_taken_without_collision(self, tmp_path):
|
|
"""t26 F4: no other entry holds the MAC → the MAC is used."""
|
|
from types import SimpleNamespace as MigrationEntry
|
|
|
|
import entity_stublib
|
|
|
|
entity_stublib.load_entity_modules()
|
|
modules = sys.modules["nsh.media_player"]
|
|
entry = MigrationEntry(
|
|
entry_id="cfgid123",
|
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
options={},
|
|
title="Shield",
|
|
unique_id="media_player.shield",
|
|
runtime_data=None,
|
|
)
|
|
|
|
class _Manager:
|
|
async def async_protocol_device_info(self):
|
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
|
|
|
entry.runtime_data = _Manager()
|
|
hass = FakeHass(str(tmp_path))
|
|
hass.config_entries.entries = [
|
|
SimpleNamespace(entry_id="cfgid999", unique_id="other:mac") # different MAC
|
|
]
|
|
|
|
await modules._migrate_legacy_unique_id(hass, entry)
|
|
|
|
assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # free MAC taken
|
|
|
|
async def test_never_runs_twice_for_a_migrated_entry(self, tmp_path):
|
|
"""The legacy pattern no longer matches after the one-time move."""
|
|
import entity_stublib
|
|
|
|
entity_stublib.load_entity_modules()
|
|
modules = sys.modules["nsh.media_player"]
|
|
from types import SimpleNamespace as MigrationEntry
|
|
|
|
hass = FakeHass(str(tmp_path))
|
|
entry = MigrationEntry(
|
|
entry_id="cfgid123",
|
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
|
options={},
|
|
title="Shield",
|
|
unique_id="aa:bb:cc:dd:ee:ff", # already migrated
|
|
runtime_data=None,
|
|
)
|
|
|
|
await modules._migrate_legacy_unique_id(hass, entry)
|
|
|
|
assert hass.config_entries.updating_entry_calls == [] # untouched
|
|
|
|
|
|
class TestIpChangeSelfHealF1:
|
|
"""t32 F1 (HIGH): the poller factory re-reads entry.options at call
|
|
time, so the NEXT built poller targets the NEW address after a
|
|
zeroconf IP change (the audit-sanctioned factory re-read option)."""
|
|
|
|
def _adb_entry(self, host="192.168.1.50"):
|
|
return SimpleNamespace(
|
|
entry_id="e1",
|
|
title="Shield",
|
|
data={},
|
|
options={
|
|
CONF_ADB_ENABLED: True,
|
|
CONF_HOST: host, # t41: the entry's one address
|
|
CONF_ADB_PORT: 5555,
|
|
},
|
|
runtime_data=None,
|
|
)
|
|
|
|
def _hass_with_key(self, tmp_path):
|
|
import conftest
|
|
import entity_stublib
|
|
|
|
entity_stublib.install()
|
|
if "nsh.init" not in sys.modules:
|
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
|
init_module = sys.modules["nsh.init"]
|
|
hass = FakeHass(str(tmp_path))
|
|
key_dir = Path(hass.config.config_dir) / ".storage" / "nvidia_shield"
|
|
key_dir.mkdir(parents=True, exist_ok=True)
|
|
(key_dir / "adbkey").write_text("test-key")
|
|
return init_module, hass
|
|
|
|
async def test_built_poller_targets_the_new_address(self, tmp_path):
|
|
"""The delivered self-heal: after the zeroconf host update the
|
|
NEXT built poller targets the new address (no reload involved)."""
|
|
init_module, hass = self._hass_with_key(tmp_path)
|
|
entry = self._adb_entry()
|
|
hass.config_entries.entries = [entry]
|
|
|
|
factory = await init_module._build_poller_factory(hass, entry)
|
|
assert factory is not None
|
|
|
|
first = factory()
|
|
assert first is not None
|
|
# AdbClientBase exposes the connection config as a dict (t27 F-NEW3)
|
|
assert first.config["host"] == "192.168.1.50"
|
|
|
|
# the config-flow zeroconf path updates the entry's hosts via
|
|
# async_update_entry — the NEXT built poller targets the NEW address
|
|
entry.options = {**entry.options, CONF_HOST: "192.168.1.77"}
|
|
|
|
second = factory()
|
|
assert second is not None
|
|
assert second.config["host"] == "192.168.1.77"
|
|
|
|
async def test_unresolvable_call_stays_passive(self, tmp_path):
|
|
"""A momentary unresolvable host yields None (passive cycle) — the
|
|
watchdog's existing None handling stays intact."""
|
|
init_module, hass = self._hass_with_key(tmp_path)
|
|
entry = self._adb_entry(host="10.255.255.1")
|
|
|
|
factory = await init_module._build_poller_factory(hass, entry)
|
|
assert factory is not None
|
|
|
|
with mock.patch("nsh.init.resolve_adb_config", return_value=None):
|
|
assert factory() is None
|
|
|
|
|
|
class TestMacLessDiscoveryF2:
|
|
"""t32 F2 (rider): MAC-less zeroconf discovery records the
|
|
uniqueness-less discovery and can't stack a duplicate prefill."""
|
|
|
|
def _discovery(self, host="192.168.1.50", props=None):
|
|
return SimpleNamespace(
|
|
ip_address=host, host=host, properties=props or {}
|
|
)
|
|
|
|
def _holder(self):
|
|
return sys.modules[__name__]._zc_holder
|
|
|
|
async def test_mac_less_discovery_awaits_the_helper(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
self._holder().discovery_without_unique_id_calls = 0
|
|
|
|
result = await flow.async_step_zeroconf(self._discovery())
|
|
|
|
assert result["type"] == "form" # prefill still offered once
|
|
# t37 D3: only an AWAITED coroutine body runs — before the fix this
|
|
# counter stayed at 0 and Python warned "coroutine ... was never
|
|
# awaited", while HA's dedup never happened
|
|
assert self._holder().discovery_without_unique_id_calls == 1
|
|
# the helper claims HA's discovery sentinel for the flow
|
|
assert flow._unique_id == self._holder().discovery_unique_id
|
|
|
|
async def test_mac_less_discovery_with_entries_aborts(self, tmp_path):
|
|
"""Real contract: any entry for the handler → already_configured."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
entry = SimpleNamespace(
|
|
entry_id="e1",
|
|
unique_id=None, # MAC-less entry
|
|
options={CONF_HOST: "192.168.1.50"},
|
|
title="Shield",
|
|
)
|
|
self._holder().entries[:] = [entry]
|
|
|
|
with pytest.raises(DiscoveryDedupAbortError) as raised:
|
|
await flow.async_step_zeroconf(self._discovery())
|
|
|
|
assert raised.value.reason == "already_configured"
|
|
# the entries list is untouched: one-entry outcome
|
|
assert len(self._holder().entries) == 1
|
|
|
|
async def test_mac_less_discovery_while_another_flow_runs_aborts(self, tmp_path):
|
|
"""The other half of the real contract: one flow at a time."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
self._holder().in_progress[:] = [{"flow_id": "other"}]
|
|
|
|
with pytest.raises(DiscoveryDedupAbortError) as raised:
|
|
await flow.async_step_zeroconf(self._discovery())
|
|
|
|
assert raised.value.reason == "already_in_progress"
|
|
|
|
async def test_mac_less_discovery_leaves_no_un_awaited_coroutine(
|
|
self, tmp_path
|
|
):
|
|
"""t37 D3: the helper must never be left un-awaited."""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
await flow.async_step_zeroconf(self._discovery())
|
|
del flow
|
|
gc.collect()
|
|
|
|
never_awaited = [
|
|
str(w.message) for w in caught if "never awaited" in str(w.message)
|
|
]
|
|
assert never_awaited == []
|
|
|
|
|
|
class TestSeedBlockShapeF4:
|
|
"""t32 F4 (rider): protocol-only seed blocks don't force ADB on."""
|
|
|
|
def test_protocol_only_block_keeps_adb_off(self):
|
|
import conftest
|
|
import entity_stublib
|
|
|
|
entity_stublib.install()
|
|
if "nsh.init" not in sys.modules:
|
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
|
init_module = sys.modules["nsh.init"]
|
|
|
|
blocks = init_module._yaml_seed_blocks(
|
|
{"name": "Proto", "protocol_host": "shield.local"}
|
|
)
|
|
|
|
assert blocks[0]["adb_enabled"] is False
|
|
assert blocks[0]["host"] == "shield.local" # t41: one address field
|
|
|
|
def test_adb_block_keeps_adb_on(self):
|
|
import conftest
|
|
import entity_stublib
|
|
|
|
entity_stublib.install()
|
|
if "nsh.init" not in sys.modules:
|
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
|
init_module = sys.modules["nsh.init"]
|
|
|
|
blocks = init_module._yaml_seed_blocks(
|
|
{"name": "Adb", "adb_host": "10.0.0.31"}
|
|
)
|
|
|
|
assert blocks[0]["adb_enabled"] is True
|
|
|
|
async def test_protocol_only_import_prefills_adb_off(self):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass("/tmp/nsh-f4")
|
|
flow._async_current_entries = lambda: []
|
|
|
|
result = await flow.async_step_import(
|
|
{"name": "Proto", "protocol_host": "shield.local"}
|
|
)
|
|
|
|
assert result["type"] == "form"
|
|
assert flow._user_data["adb_enabled"] is False
|
|
|
|
|
|
class TestZeroconfDiscovery:
|
|
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
|
|
|
def _discovery(self, host="192.168.1.50", props=None):
|
|
return SimpleNamespace(
|
|
ip_address=host, host=host, properties=props or {}
|
|
)
|
|
|
|
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
|
"""t33 F-A: the discovered host is the DEFAULT OF THE SCHEMA KEYS.
|
|
|
|
The old check (the attribute was stored) stayed green while the
|
|
rendered form was blank — this one reads the schema itself.
|
|
"""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
result = await flow.async_step_zeroconf(self._discovery())
|
|
|
|
assert result["type"] == "form"
|
|
assert result["step_id"] == "user"
|
|
assert flow._discovered_host == "192.168.1.50"
|
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
|
assert defaults[CONF_HOST] == "192.168.1.50"
|
|
# ...and a discovery-driven form says the address is already known
|
|
note = result["description_placeholders"]["discovery_note"]
|
|
assert "Discovered Shield at 192.168.1.50" in note
|
|
|
|
async def test_discovery_without_address_aborts(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
|
|
result = await flow.async_step_zeroconf(
|
|
SimpleNamespace(ip_address=None, host=None, properties={})
|
|
)
|
|
|
|
assert result == {"type": "abort", "reason": "zeroconf_no_host"}
|
|
|
|
async def test_known_mac_ip_change_updates_entry_hosts(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
hass = FakeHass(str(tmp_path))
|
|
flow.hass = hass
|
|
|
|
entry = SimpleNamespace(
|
|
unique_id="aa:bb:cc:dd:ee:ff",
|
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.1"},
|
|
entry_id="e1",
|
|
title="Shield",
|
|
)
|
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
|
|
|
result = await flow.async_step_zeroconf(
|
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
|
)
|
|
|
|
assert result == {"type": "abort", "reason": "already_configured"}
|
|
# t41: the ONE address field moved to the discovery address
|
|
assert entry.options[CONF_HOST] == "192.168.1.50"
|
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
|
|
|
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
hass = FakeHass(str(tmp_path))
|
|
flow.hass = hass
|
|
|
|
# the address already holds the discovered value: nothing changes
|
|
entry = SimpleNamespace(
|
|
unique_id="aa:bb:cc:dd:ee:ff",
|
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "192.168.1.50"},
|
|
entry_id="e1",
|
|
title="Shield",
|
|
)
|
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
|
|
|
result = await flow.async_step_zeroconf(
|
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
|
)
|
|
|
|
assert result == {"type": "abort", "reason": "already_configured"}
|
|
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
|
assert hass.config_entries.reloaded_entry_ids == []
|
|
|
|
async def test_the_per_host_heal_cases_collapsed_into_one_rule(self):
|
|
"""t41: the blank / self-heal / deliberate-override cases are gone.
|
|
|
|
They only existed because the address was stored twice (t21 F2). With one
|
|
field the discovery address simply IS the entry's address, so this pins
|
|
the simplification: a second address field cannot quietly come back.
|
|
"""
|
|
source = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
|
|
|
assert "CONF_ADB_HOST" not in source
|
|
assert "CONF_PROTOCOL_HOST" not in source
|
|
|
|
async def test_an_explicit_address_follows_the_device(self, tmp_path):
|
|
"""t41: one Shield, one address — discovery is its current source.
|
|
|
|
The old rule kept a "deliberately different ADB host" untouched; that
|
|
override is precisely what the single-field ruling removes.
|
|
"""
|
|
module = _form_module()
|
|
flow = module.NvidiaShieldConfigFlow()
|
|
hass = FakeHass(str(tmp_path))
|
|
flow.hass = hass
|
|
|
|
entry = SimpleNamespace(
|
|
unique_id="aa:bb:cc:dd:ee:ff",
|
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"}, # stale
|
|
entry_id="e1",
|
|
title="Shield",
|
|
)
|
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
|
|
|
result = await flow.async_step_zeroconf(
|
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
|
)
|
|
|
|
assert result == {"type": "abort", "reason": "already_configured"}
|
|
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
|
assert update[CONF_HOST] == "192.168.1.50"
|
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
|
|
|
|
|
class TestAppsMapOptionsEditor:
|
|
"""P4-1b: the apps name/icon map editor in the options flow."""
|
|
|
|
def _options_flow(self, tmp_path, options):
|
|
module = _form_module()
|
|
entry = SimpleNamespace(options=dict(options), title="Shield")
|
|
flow = module.NvidiaShieldOptionsFlow()
|
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
|
flow.hass = FakeHass(str(tmp_path))
|
|
return flow
|
|
|
|
async def test_map_text_stored_as_dict(self, tmp_path):
|
|
flow = self._options_flow(tmp_path, options={})
|
|
|
|
result = await _options_submit(
|
|
flow,
|
|
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"},
|
|
save=True,
|
|
)
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["data"]["app_name_map"] == {
|
|
"ar.tvplayer.tv": "TiviMate",
|
|
"com.plex.hdx": "Plex",
|
|
}
|
|
|
|
async def test_invalid_map_shows_form_error(self, tmp_path):
|
|
flow = self._options_flow(tmp_path, options={})
|
|
|
|
result = await _options_submit(flow, {"app_name_map": "no equals sign here"})
|
|
|
|
assert result["type"] == "form"
|
|
assert result["errors"] == {"base": "invalid_app_map"}
|
|
|
|
async def test_blank_map_clears_the_entries(self, tmp_path):
|
|
flow = self._options_flow(
|
|
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
|
)
|
|
|
|
result = await _options_submit(flow, {"app_name_map": " "}, save=True)
|
|
|
|
assert result["type"] == "create_entry"
|
|
assert result["data"]["app_name_map"] == {}
|
|
|
|
async def test_existing_map_prefills_the_text_field(self, tmp_path):
|
|
flow = self._options_flow(
|
|
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
|
)
|
|
|
|
# first visit: the form carries the serialized current map as the
|
|
# field default — NEVER the dict (that is the [object Object] bug)
|
|
result = await _options_open(flow, "apps_text")
|
|
|
|
assert result["type"] == "form"
|
|
fields = getattr(result["data_schema"], "schema", result["data_schema"])
|
|
suggested = fields.get("__suggested__", {})
|
|
assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate" # t57
|
|
# the editor carried the existing map through the defaults path
|
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
|
|
|
|
|
class TestAppNameMapResolution:
|
|
"""app_name resolves through the map after the entity/push tiers."""
|
|
|
|
async def test_mapped_package_resolves_the_friendly_name(self):
|
|
import entity_stublib
|
|
|
|
modules = entity_stublib.load_entity_modules()
|
|
player, _s, _i = _cmd_player(modules)
|
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
|
|
|
manager = player._manager
|
|
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
|
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
|
|
|
assert player.app_name == "TiviMate"
|
|
assert player.app_id == "ar.tvplayer.tv" # app_id stays raw
|
|
|
|
async def test_a_curated_package_reads_by_its_name(self):
|
|
"""t66: an app the user did NOT map still gets a name — from the curated table.
|
|
|
|
This is the device the report describes: `pm dump` names nothing, and a
|
|
package absent from the user's own map used to read as its raw id.
|
|
"""
|
|
import entity_stublib
|
|
|
|
modules = entity_stublib.load_entity_modules()
|
|
player, _s, _i = _cmd_player(modules)
|
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
|
|
|
manager = player._manager
|
|
manager._on_current_app("com.google.android.youtube.tv")
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert player.app_name == "YouTube"
|
|
|
|
async def test_an_unmapped_uncurated_package_stays_raw(self):
|
|
"""The chain's last tier, on the same surface: nothing names it → the id."""
|
|
import entity_stublib
|
|
|
|
modules = entity_stublib.load_entity_modules()
|
|
player, _s, _i = _cmd_player(modules)
|
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
|
|
|
manager = player._manager
|
|
manager._on_current_app("com.example.nobody")
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert player.app_name == "com.example.nobody"
|
|
|
|
async def test_cached_manifest_label_is_the_second_tier(self):
|
|
"""P5-1: below the explicit map sits the device manifest label."""
|
|
import entity_stublib
|
|
|
|
modules = entity_stublib.load_entity_modules()
|
|
player, _s, _i = _cmd_player(modules)
|
|
player._entry.options["app_name_map"] = {} # no override
|
|
manager = player._manager
|
|
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
|
|
|
|
manager._on_current_app("ar.tvplayer.tv")
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert player.app_name == "TiviMate"
|