Files
ha-nvidia-shield/tests/test_init.py
T
James Mackay 6aa3b6bfdd feat(adb_command): selectable target, strict resolution, and a real response (t14)
The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:

* ServiceRegistry.async_register has NO target parameter, so a target cannot be
  declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
  HA builds from services.yaml or from the runtime async_set_service_schema —
  and the runtime description WINS.

So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.

The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.

Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.

Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
2026-10-12 11:23:47 +01:00

301 lines
10 KiB
Python

"""tests/test_init.py — the t30 G6 structure test file.
Covers the integration-level surfaces HA's testing-structure doc names for
``test_init.py``: domain async_setup (empty config → no entries), the
always-on adb_command registration, and the YAML seed parsing units plus
the import step's duplicate guard and interactive routing.
"""
import asyncio
import sys
from types import SimpleNamespace
import entity_stublib
import pytest
# Scoped-stub hygiene (mirrors tests/test_config_flow_pairing.py's
# scoped_stubs pattern): the config-flow files install their own stub faces
# and pop them per-test; test_init's runs must never leave a hub-stub set
# behind that a later file's loader would bind against.
_INIT_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
# the G8 lazy import in async_setup registers this platform module —
# without popping it the install() guard would skip the rebuild below
"homeassistant.components.media_player",
"nsh.helpers",
"nsh.config_flow",
"nsh.init",
"nsh.media_player",
)
@pytest.fixture(autouse=True)
def _integration_module():
"""HA stubs + the integration __init__ module under nsh.init."""
snapshot = entity_stublib.snapshot_modules()
entity_stublib.install()
if "nsh.init" not in sys.modules:
import conftest
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
yield sys.modules["nsh.init"]
entity_stublib.restore_modules(snapshot)
class _SeedEntriesStub:
"""config-entries shape for flow seeding tests: entries + flow records."""
def __init__(self, entries=()):
self._entries = list(entries)
self.flows = [] # (domain, context, data) records of flow.async_init
def async_entries(self):
return list(self._entries)
class _Flows:
def __init__(self, holder):
self._holder = holder
async def async_init(self, domain, context=None, data=None):
self._holder.flows.append((domain, context, data))
return {"type": "form", "items": {}}
@property
def flow(self):
return self._Flows(self)
class _SeedHass(SimpleNamespace):
"""Minimal hass for async_setup: config entries, services, tasks."""
def __init__(self, entries=()):
config_entries = _SeedEntriesStub()
config_entries.updating_entry_calls = []
self._services = {("nvidia_shield", "adb_command"): None}
#: t14: per-service response support the registration declared.
self.response_support: dict[tuple, object] = {}
super().__init__(
config_entries=config_entries,
scheduled_tasks=[],
services=self,
)
def async_register(
self, domain, service, func, schema=None, supports_response=None
):
self._services[(domain, service)] = (func, schema)
#: t14: HA is told whether the action answers with a response.
self.response_support[(domain, service)] = supports_response
def has_service(self, domain, service):
return (domain, service) in self._services
def async_create_task(self, coro):
task = asyncio.ensure_future(coro)
self.scheduled_tasks.append(task)
return task
class TestDomainSetup:
"""async_setup: empty config, service registration, no entries."""
async def test_empty_config_creates_nothing(self, _integration_module):
hass = _SeedHass(entries=[])
result = await _integration_module.async_setup(hass, {})
assert result is True
assert hass.config_entries.flows == [] # nothing seeded
assert hass.services.has_service("nvidia_shield", "adb_command")
class TestYamlSeedBlocks:
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
def test_block_normalized(self, _integration_module):
from nsh.const import CONF_ADB_PORT, CONF_HOST, DEFAULT_ADB_PORT
# t41: a LEGACY block (adb_host) still seeds — normalized onto CONF_HOST
blocks = _integration_module._yaml_seed_blocks(
[{"name": "Living", "adb_host": "10.0.0.31"}]
)
assert len(blocks) == 1
seed = blocks[0]
assert seed["name"] == "Living"
assert seed[CONF_HOST] == "10.0.0.31"
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
assert seed["adb_enabled"] is True
assert "unique_id" not in seed # optional key only
def test_the_current_host_key_seeds_too(self, _integration_module):
from nsh.const import CONF_HOST
seed = _integration_module._yaml_seed_blocks(
[{"name": "Desk", CONF_HOST: "shield.local"}]
)[0]
assert seed[CONF_HOST] == "shield.local"
assert seed["adb_enabled"] is True # an address names the Shield
def test_single_block_and_protocol_hint(self, _integration_module):
blocks = _integration_module._yaml_seed_blocks(
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
)
assert len(blocks) == 1
# a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
assert blocks[0]["host"] == "shield.local"
assert blocks[0]["adb_port"] == 5000
assert blocks[0]["adb_enabled"] is False
def test_list_and_single_block_shapes(self, _integration_module):
single = _integration_module._yaml_seed_blocks({"name": "A"})
listed = _integration_module._yaml_seed_blocks([{"name": "B"}, {"name": "C"}])
assert len(single) == 1 and len(listed) == 2
class TestSeeding:
"""async_setup seeds SOURCE_IMPORT flows for shaped blocks only."""
async def test_shaped_block_seeds_a_flow(self, _integration_module):
hass = _SeedHass(entries=[])
await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "R", "adb_host": "10.0.0.5"}]}
)
if hass.scheduled_tasks:
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
assert len(hass.config_entries.flows) == 1
domain, context, data = hass.config_entries.flows[0]
assert domain == "nvidia_shield"
assert context["source"] == "import"
assert data["host"] == "10.0.0.5" # t41: one address field
class TestSeedingCurrentKey:
"""The t41 `host` key seeds exactly like the legacy one."""
async def test_host_key_seeds_a_flow(self, _integration_module):
hass = _SeedHass(entries=[])
await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "H", "host": "10.0.0.7"}]}
)
if hass.scheduled_tasks:
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
assert len(hass.config_entries.flows) == 1
assert hass.config_entries.flows[0][2]["host"] == "10.0.0.7"
async def test_hostless_block_never_seeds(self, _integration_module):
hass = _SeedHass(entries=[])
result = await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "X"}]}
)
assert result is True
assert hass.config_entries.flows == [] # hostless → warning only
class TestImportStep:
"""async_step_import: duplicate guards + interactive routing."""
def _flow(self, entries):
# the flow module loads under these stubs and the base plumbing is
# injected locally — importing test_standalone_setup here would
# leak its local stub set into later test files.
from nsh.config_flow import NvidiaShieldConfigFlow
hass = SimpleNamespace(
config_entries=_SeedEntriesStub(entries),
)
hass.config_entries.flows = []
flow = NvidiaShieldConfigFlow()
flow.hass = hass
flow.context = {}
# ConfigFlow base attrs the stub class doesn't implement (t7 shape):
flow.async_set_unique_id = None # set below
flow._collected_unique_id = None
async def set_unique_id(uid):
flow._collected_unique_id = uid
flow.async_set_unique_id = set_unique_id
def abort_if_unique_id_configured(*args, **kwargs):
for entry in hass.config_entries.async_entries():
if entry.unique_id == flow._collected_unique_id:
from nsh.config_flow import AbortIfConfiguredError
raise AbortIfConfiguredError(f"{entry.unique_id}")
flow._abort_if_unique_id_configured = abort_if_unique_id_configured
# the base ConfigFlow shows forms through the stub plumbing (matching
# how the standalone flow tests install it):
flow.async_show_form = lambda **kwargs: {
"type": "form",
**kwargs,
}
flow.async_create_entry = lambda **kwargs: {
"type": "create_entry",
**kwargs,
}
flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs}
flow._async_current_entries = lambda: list(
hass.config_entries.async_entries()
)
return flow
async def test_ui_configured_host_never_re_seeded(self):
from nsh.const import CONF_HOST
existing = SimpleNamespace(
entry_id="ui1",
unique_id=None,
options={CONF_HOST: "10.0.0.31"},
)
# options live on the entry (conf zeroconf guard reads them)
flow = self._flow([existing])
result = await flow.async_step_import(
{"name": "A", "adb_host": "10.0.0.31"}
)
assert result == {"type": "abort", "reason": "already_configured"}
async def test_virtual_unique_id_blocks_reseed(self):
flow_holder = SimpleNamespace(
entry_id="seed1",
unique_id="yaml:Living:10.0.0.31",
options={},
)
flow = self._flow([flow_holder])
result = await flow.async_step_import(
{"name": "Living", "adb_host": "10.0.0.31"}
)
assert result == {"type": "abort", "reason": "already_configured"}
async def test_fresh_block_routes_into_the_user_flow(self):
flow = self._flow([])
result = await flow.async_step_import(
{"name": "Fresh", "adb_host": "10.0.0.99"}
)
# interactive pairing — the pre-filled user step, one visit
assert result["type"] == "form"
assert result["step_id"] == "user"