"""t14: `nvidia_shield.adb_command` against a REAL Home Assistant install. The user's report was that the Shield could not be SELECTED for the action, so he ran his diagnostics through the deprecated ``androidtv`` integration. A stub suite cannot settle that: the picker is whatever HA hands the browser, and "was the command sent to the Shield I picked?" is decided by HA's own service registry, schema validation and target merging. What this file drives, on the installed HA (2026.10.1): * the REAL ``hass.services`` registry — the registration our ``async_setup`` makes; * the REAL websocket command the frontend calls (``get_services``), asserting the payload carries ``target: {entity: [{domain: media_player}]}`` — that payload IS the picker; * the REAL ``hass.services.async_call`` with ``return_response=True`` — asserting the output comes back under ``adb_response`` (the key the user's captures use) and that a call without it behaves as before; * the REAL entity registry, for the ``device_id`` shape an automation sends. WHAT THIS FILE CANNOT PROVE: there is no browser here, so nothing about how the picker LOOKS. It proves the wire contract — the registration, the metadata the UI consumes, and the resolved call. Every test skips cleanly when homeassistant is not installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``). """ from __future__ import annotations import importlib import json import sys from pathlib import Path from types import SimpleNamespace import pytest import pytest_asyncio pytest.importorskip( "homeassistant", reason="the real service registry is the point of this file", ) from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402 from homeassistant.config_entries import ( # noqa: E402 SOURCE_USER, ConfigEntries, ConfigEntry, ) from homeassistant.core import HomeAssistant # noqa: E402 from homeassistant.exceptions import ServiceValidationError # noqa: E402 from homeassistant.helpers import device_registry as device_registry # noqa: E402 from homeassistant.helpers import entity_registry as er # noqa: E402 from homeassistant.helpers.entity_registry import ( # noqa: E402 EntityRegistryItems, EntityRegistrySettings, ) from homeassistant.helpers.service import ( # noqa: E402 async_get_all_descriptions, ) _ROOT = Path(__file__).resolve().parent.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) DOMAIN = "nvidia_shield" SERVICE = "adb_command" ENTITY_ID = "media_player.shield" class FakePoller: """The ADB surface the handler uses: one shell call, real values back.""" def __init__(self, output="ok", error=None): self.output = output self.error = error self.calls: list[str] = [] async def shell(self, command): self.calls.append(command) if self.error is not None: raise self.error return self.output @pytest_asyncio.fixture(name="ha") async def ha_fixture(tmp_path): """A real HomeAssistant with the real registries and the real service registry. No core bootstrap: the registries are filled directly with the two containers core's loader fills (same objects), exactly as the t56 flow probe does — so an EntitySelector / target expansion sees the entities the real one would. """ hass = HomeAssistant(str(tmp_path)) device_registry.async_setup(hass) await device_registry.async_load(hass) registry = er.async_get(hass) registry.entities = EntityRegistryItems(hass) registry.deleted_entities = {} registry.settings = EntityRegistrySettings() registry._entities_data = registry.entities.data hass.data[er.DATA_REGISTRY] = registry # the real entry manager: device_registry.async_get_or_create needs an entry it # can look up, exactly as a real device registration does hass.config_entries = ConfigEntries(hass, {}) hass.config_entries.async_schedule_reload = lambda entry_id: None entry = ConfigEntry( version=1, minor_version=1, domain=DOMAIN, title="Shield", data={}, options={}, source=SOURCE_USER, unique_id=None, subentries_data=(), discovery_keys={}, ) object.__setattr__(entry, "_options", {}) hass.config_entries._entries[entry.entry_id] = entry hass._shield_entry = entry return hass @pytest.fixture(name="integration") def integration_fixture(): """The integration package, imported by path under its real name.""" return importlib.import_module("custom_components.nvidia_shield") @pytest.fixture(name="media_player_module") def media_player_fixture(): return importlib.import_module("custom_components.nvidia_shield.media_player") @pytest_asyncio.fixture(name="shield") async def shield_fixture(ha, integration, media_player_module): """Set the integration up, and register ONE Shield as an adb_command target. Yields the fake manager behind that entity, so a test can see exactly how many commands reached the device. """ await integration.async_setup(ha, {}) poller = FakePoller() manager = SimpleNamespace(adb_poller=poller, connected=True) media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = manager try: yield poller finally: media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None) def _service_payload(descriptions): return descriptions[DOMAIN][SERVICE] class RecordingConnection: """The websocket connection shape ``handle_get_services`` needs.""" def __init__(self): self.messages: list[dict] = [] def send_message(self, message): self.messages.append(message) def send_result(self, msg_id, payload): self.messages.append({"id": msg_id, "type": "result", "result": payload}) async def test_the_registration_declares_a_response(ha, integration, shield): """t14: a service that answers must say so — and OPTIONAL keeps callers free.""" from homeassistant.core import SupportsResponse service = ha.services.async_services()[DOMAIN][SERVICE] assert service.supports_response is SupportsResponse.OPTIONAL async def test_the_frontend_payload_carries_the_target_picker(ha, integration, shield): """THE fix: what the browser receives for this action must offer the Shield. Driven through the real websocket command (``get_services``), because that is the payload the UI builds its action form and target picker from. """ payload = await async_get_all_descriptions(ha) description = _service_payload(payload) # the entity target the picker is rendered from assert description["target"] == {"entity": [{"domain": ["media_player"]}]} # a response-bearing action: the UI can offer to return the output assert description["response"] == {"optional": True} # both fields survive the runtime description (the form would be empty without) assert set(description["fields"]) == {"command", "entity_id"} # ...and it serialises as JSON, which is what actually crosses the wire assert json.loads(json.dumps(payload))[DOMAIN][SERVICE]["target"] == { "entity": [{"domain": ["media_player"]}] } async def test_the_websocket_command_serves_the_same_payload(ha, integration, shield): """The exact command the frontend calls, not just the accessor behind it.""" connection = RecordingConnection() # the command is decorated: it schedules the handler as a background task and # returns None, which is exactly how the frontend's request is served ws_commands.handle_get_services(ha, connection, {"id": 7, "type": "get_services"}) await ha.async_block_till_done() # HA serialises the response to JSON BYTES before it reaches the socket, so this # is the exact text the browser parses for the action form assert len(connection.messages) == 1 message = json.loads(connection.messages[0]) assert message["id"] == 7 assert message["success"] is True served = message["result"][DOMAIN][SERVICE] assert served["target"] == {"entity": [{"domain": ["media_player"]}]} assert served["response"] == {"optional": True} assert set(served["fields"]) == {"command", "entity_id"} async def test_a_call_returns_the_command_output(ha, integration, shield): """The response the user's captures read: ``adb_response``, the old key.""" response = await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo hi", "entity_id": ENTITY_ID}, blocking=True, return_response=True, ) assert response == {"adb_response": "ok"} assert shield.calls == ["echo hi"] async def test_a_call_from_the_ui_target_shape_returns_the_output(ha, integration, shield): """HA merges a picked target into the call as a LIST — accept it.""" response = await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo list", "entity_id": [ENTITY_ID]}, blocking=True, return_response=True, ) assert response == {"adb_response": "ok"} assert shield.calls == ["echo list"] async def test_a_call_without_the_response_still_works(ha, integration, shield): """Existing YAML callers: same data, no ``return_response`` — unchanged.""" result = await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo old", "entity_id": ENTITY_ID}, blocking=True, ) assert result is None assert shield.calls == ["echo old"] async def test_the_device_target_shape_resolves_through_the_registry( ha, integration, shield, media_player_module ): """An automation that targets the DEVICE sends ``device_id``: resolve it.""" device = device_registry.async_get(ha).async_get_or_create( config_entry_id=ha._shield_entry.entry_id, identifiers={(DOMAIN, "shield-device")}, name="Shield", ) er.async_get(ha).async_get_or_create( "media_player", DOMAIN, "uid_shield", device_id=device.id, suggested_object_id="shield", ) response = await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo device", "device_id": device.id}, blocking=True, return_response=True, ) assert response == {"adb_response": "ok"} assert shield.calls == ["echo device"] async def test_no_target_is_an_error_naming_what_to_pass(ha, integration, shield): """Never a silent no-op: HA's own validation is not the only guard.""" with pytest.raises(ServiceValidationError) as err: await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo x"}, blocking=True ) assert err.value.translation_key == "no_target" assert shield.calls == [] async def test_two_targets_send_nothing(ha, integration, shield, media_player_module): """A raw shell command is never broadcast to Shields the user did not pick.""" other = SimpleNamespace(adb_poller=FakePoller(), connected=True) media_player_module._ADB_COMMAND_TARGETS["media_player.other"] = other try: with pytest.raises(ServiceValidationError) as err: await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo x", "entity_id": [ENTITY_ID, "media_player.other"]}, blocking=True, ) assert err.value.translation_key == "ambiguous_target" assert shield.calls == [] assert other.adb_poller.calls == [] finally: media_player_module._ADB_COMMAND_TARGETS.pop("media_player.other", None) async def test_an_unknown_entity_is_an_error(ha, integration, shield): """Another integration's media player is not a Shield to run shell on.""" with pytest.raises(ServiceValidationError) as err: await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo x", "entity_id": "media_player.someone_else"}, blocking=True, ) assert err.value.translation_key == "unsupported_target" assert shield.calls == [] async def test_an_unreachable_shield_is_an_error_not_an_empty_response( ha, integration, shield, media_player_module ): """The acceptance's honesty clause: ADB down → error, not ``None`` back.""" media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace( adb_poller=None, connected=False ) try: with pytest.raises(ServiceValidationError) as err: await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo x", "entity_id": ENTITY_ID}, blocking=True, return_response=True, ) assert err.value.translation_key == "adb_unavailable" finally: media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None) async def test_a_failing_transport_surfaces_the_error( ha, integration, shield, media_player_module ): """A transport failure mid-call propagates — never ``adb_response: None``.""" poller = FakePoller(error=ConnectionError("adb socket dead")) media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace( adb_poller=poller, connected=True ) try: with pytest.raises(ConnectionError): await ha.services.async_call( DOMAIN, SERVICE, {"command": "echo x", "entity_id": ENTITY_ID}, blocking=True, return_response=True, ) finally: media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)