"""t17: `nvidia_shield.send_intent` against a REAL Home Assistant install. The intents feature has one claim a stub cannot settle: that the action RETURNS the device's output, and that the frontend is offered the registered intents as a dropdown. Both are decided by Home Assistant itself — the response flag comes from ``async_set_service_schema`` reading ``hass.services.supports_response()``, and the dropdown is whatever description the websocket command serves — so this file drives the installed HA (2026.10.1) end to end: * the REAL service registry: the registration ``async_setup`` makes, its schema, and ``SupportsResponse.OPTIONAL`` (a *required* response would break every caller that does not ask for one); * the REAL ``get_services`` payload — the exact JSON the frontend parses — so the intent dropdown and the entity target picker are proven where they are consumed; * the REAL ``hass.services.async_call`` with ``return_response=True``: the output comes back under ``intent_response``, and the command that reached the fake ADB surface is asserted verbatim; * the REAL exception machinery: an unknown intent, a refused intent and ADB being down each raise ``ServiceValidationError`` with our translation key and a usable message (never a silent success, never an empty response). WHAT THIS FILE CANNOT PROVE: there is no Shield and no browser here. The command string is asserted against the fake poller (the real ADB transport is t13/t14's subject), and "how the dropdown LOOKS" is the frontend's business — the wire contract is what this file pins. 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 = "send_intent" ENTITY_ID = "media_player.shield" #: The registration this file sends: one component, one action, one declared #: variable used by the data template, and one integer with a default. NETFLIX_INTENT = { "id": "netflix_title", "name": "Netflix: open a title", "package": "com.netflix.ninja", "activity": "com.netflix.ninja/.MainActivity", "action": "android.intent.action.VIEW", "categories": ["android.intent.category.DEFAULT"], "data": "https://www.netflix.com/watch/{id}", "fields": [ {"key": "id", "type": "string", "required": True}, {"key": "resume", "type": "bool", "default": "true"}, ], } #: What the handler must build for ``{"id": "80100172"}`` — asserted verbatim, so #: a change to the flag order or the quoting is a test failure, not a surprise. EXPECTED_COMMAND = ( "am start -n com.netflix.ninja/.MainActivity " "-a android.intent.action.VIEW -c android.intent.category.DEFAULT " "-d https://www.netflix.com/watch/80100172 " "--es id 80100172 --ez resume true" ) #: What a real `am start` prints when the device ACCEPTED the intent. DEVICE_ANSWER = ( "Starting: Intent { act=android.intent.action.VIEW " "cmp=com.netflix.ninja/.MainActivity }" ) class FakePoller: """The ADB surface the handler uses: one shell call, real values back.""" def __init__(self, output=DEVICE_ANSWER, 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. Same shape as the adb_command file (t14): no core bootstrap, but the two containers core's loader fills are constructed the very same way, so an entity 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 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.fixture(name="const") def const_fixture(): return importlib.import_module("custom_components.nvidia_shield.const") @pytest_asyncio.fixture(name="shield") async def shield_fixture(ha, integration, media_player_module, const): """Set the integration up with ONE Shield carrying ONE registered intent. The manager is a SimpleNamespace with the ENTRY on it, because the handler reads the intents from the ENTRY's options — the same place the options flow writes them, which is what makes this a real end-to-end path. """ await integration.async_setup(ha, {}) poller = FakePoller() entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) manager = SimpleNamespace(adb_poller=poller, connected=True, entry=entry) media_player_module._INTENT_TARGETS[ENTITY_ID] = manager # the domain store a real entry setup fills, then the SAME call # async_setup_entry makes — so the dropdown is published exactly as in # production (and, when nothing is stored, comes out empty) ha.data.setdefault(DOMAIN, {})["shield_entry"] = manager integration._publish_send_intent_description(ha) try: yield poller finally: media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None) ha.data.get(DOMAIN, {}).pop("shield_entry", None) def _description(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_an_optional_response(ha, integration, shield): """A response-bearing action must say so — OPTIONAL keeps callers free. REQUIRED would make every existing automation that does not ask for a response fail, so this is a compatibility gate, not a nicety. """ from homeassistant.core import SupportsResponse service = ha.services.async_services()[DOMAIN][SERVICE] assert service.supports_response is SupportsResponse.OPTIONAL async def test_the_intent_field_is_a_dropdown_of_the_registered_names( ha, integration, shield ): """THE dropdown: the frontend gets the registered intents as select options. The options are published from the integration's own runtime description (``async_set_service_schema``), because ``async_register`` has no field metadata parameter — the same mechanism t14 proved for the target picker. """ description = _description(await async_get_all_descriptions(ha)) intent_field = description["fields"]["intent"] assert intent_field["required"] is True assert intent_field["selector"]["select"]["options"] == [NETFLIX_INTENT["name"]] assert intent_field["selector"]["select"]["custom_value"] is True # the target picker, and the response flag the UI reads assert description["target"] == {"entity": [{"domain": ["media_player"]}]} assert description["response"] == {"optional": True} assert set(description["fields"]) == {"intent", "variables", "entity_id"} # ...and the whole payload is JSON, which is what crosses the wire assert json.loads(json.dumps(description))["fields"]["intent"]["selector"] async def test_a_publish_with_nothing_stored_offers_no_options(ha, integration): """The honest empty state: no registrations means an EMPTY dropdown. The action still accepts a name (the selector keeps ``custom_value``), so the screen is not a dead end — it just does not invent options. """ await integration.async_setup(ha, {}) ha.data.setdefault(DOMAIN, {}).clear() integration._publish_send_intent_description(ha) description = _description(await async_get_all_descriptions(ha)) assert description["fields"]["intent"]["selector"]["select"]["options"] == [] async def test_the_websocket_command_serves_the_same_dropdown( ha, integration, shield ): """The exact command the frontend calls, not the accessor behind it.""" connection = RecordingConnection() ws_commands.handle_get_services(ha, connection, {"id": 11, "type": "get_services"}) await ha.async_block_till_done() assert len(connection.messages) == 1 message = json.loads(connection.messages[0]) assert message["id"] == 11 served = message["result"][DOMAIN][SERVICE] assert served["fields"]["intent"]["selector"]["select"]["options"] == [ NETFLIX_INTENT["name"] ] assert served["response"] == {"optional": True} async def test_a_call_returns_the_device_s_own_output(ha, integration, shield): """The whole point: the Shield's answer comes back to the caller.""" response = await ha.services.async_call( DOMAIN, SERVICE, { "intent": NETFLIX_INTENT["name"], "variables": {"id": "80100172"}, "entity_id": ENTITY_ID, }, blocking=True, return_response=True, ) assert response == {"intent_response": DEVICE_ANSWER} assert shield.calls == [EXPECTED_COMMAND] async def test_a_caller_that_asks_for_no_response_still_sends(ha, integration, shield): """A template that ignores the answer must not change the behaviour.""" response = await ha.services.async_call( DOMAIN, SERVICE, { "intent": NETFLIX_INTENT["name"], "variables": {"id": "80100172"}, "entity_id": ENTITY_ID, }, blocking=True, return_response=False, ) assert response is None assert shield.calls == [EXPECTED_COMMAND] async def test_the_variables_are_typed_onto_the_command(ha, integration, shield): """A declared int/bool reaches the device as its own flag, not as text.""" await ha.services.async_call( DOMAIN, SERVICE, { "intent": NETFLIX_INTENT["name"], "variables": {"id": "80100172", "resume": "no"}, "entity_id": ENTITY_ID, }, blocking=True, return_response=True, ) assert shield.calls == [ EXPECTED_COMMAND.replace("--ez resume true", "--ez resume false") ] async def test_a_required_field_left_out_names_the_field(ha, integration, shield): """The action refuses BEFORE it sends: nothing travels half-specified.""" with pytest.raises(ServiceValidationError) as caught: await ha.services.async_call( DOMAIN, SERVICE, {"intent": NETFLIX_INTENT["name"], "entity_id": ENTITY_ID}, blocking=True, return_response=True, ) assert caught.value.translation_key == "missing_intent_field" assert caught.value.translation_placeholders["field"] == "id" assert "id" in str(caught.value) assert shield.calls == [], "nothing was sent" async def test_an_unknown_intent_lists_the_registered_ones(ha, integration, shield): with pytest.raises(ServiceValidationError) as caught: await ha.services.async_call( DOMAIN, SERVICE, {"intent": "TiviMate: channel", "entity_id": ENTITY_ID}, blocking=True, return_response=True, ) assert caught.value.translation_key == "unknown_intent" assert NETFLIX_INTENT["name"] in str(caught.value) assert shield.calls == [] async def test_a_refused_intent_carries_the_device_s_message(ha, integration, shield): """The uninstalled-package case: the device's words, and an ERROR, not "ok".""" shield.output = ( "Starting: Intent { act=android.intent.action.VIEW }\n" "Error: Activity class {com.netflix.ninja/.MainActivity} does not exist." ) with pytest.raises(ServiceValidationError) as caught: await ha.services.async_call( DOMAIN, SERVICE, { "intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}, "entity_id": ENTITY_ID, }, blocking=True, return_response=True, ) assert caught.value.translation_key == "intent_failed" assert "does not exist" in str(caught.value) assert len(shield.calls) == 1, "it was attempted exactly once, not retried" async def test_adb_down_says_the_protocol_cannot_carry_an_intent( ha, integration, media_player_module, const ): """The honest answer when the transport is the problem — not an empty reply.""" await integration.async_setup(ha, {}) poller = FakePoller() entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]}) media_player_module._INTENT_TARGETS[ENTITY_ID] = SimpleNamespace( adb_poller=poller, connected=False, entry=entry ) try: with pytest.raises(ServiceValidationError) as caught: await ha.services.async_call( DOMAIN, SERVICE, { "intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}, "entity_id": ENTITY_ID, }, blocking=True, return_response=True, ) finally: media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None) assert caught.value.translation_key == "intents_need_adb" assert "protocol" in str(caught.value) assert poller.calls == [] async def test_the_target_is_required_and_never_guessed(ha, integration, shield): """No target, and an entity that does not participate — two errors, named.""" with pytest.raises(ServiceValidationError) as caught: await ha.services.async_call( DOMAIN, SERVICE, {"intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}}, blocking=True, return_response=True, ) assert caught.value.translation_key == "no_target" assert SERVICE in str(caught.value), "the error names the action to fix" with pytest.raises(ServiceValidationError) as caught: await ha.services.async_call( DOMAIN, SERVICE, { "intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}, "entity_id": "media_player.other", }, blocking=True, return_response=True, ) assert caught.value.translation_key == "unsupported_target" assert shield.calls == [] async def test_the_strings_cover_every_error_this_file_provokes(): """The translation keys the action raises exist in BOTH strings files. A key without copy reaches the user as the raw key, so this is checked where the real exception is raised rather than only in the unit suite. """ shield_dir = _ROOT / "custom_components" / "nvidia_shield" for name in ("strings.json", "translations/en.json"): data = json.loads((shield_dir / name).read_text(encoding="utf-8")) for key in ( "unknown_intent", "missing_intent_field", "unknown_intent_variable", "invalid_intent_field", "intent_failed", "intents_need_adb", "no_target", "unsupported_target", "ambiguous_target", ): assert key in data["exceptions"], (name, key) for field in ("intent", "variables", "entity_id"): assert field in data["services"][SERVICE]["fields"], (name, field)