diff --git a/tests/ha_api_drift.py b/tests/ha_api_drift.py index b25c406..41b3494 100644 --- a/tests/ha_api_drift.py +++ b/tests/ha_api_drift.py @@ -142,24 +142,30 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = { #: Non-vacuity floors — the ONE source of truth for the standalone entry point #: and the pytest wrapper (t39 F1). They sit below the current walk (26 modules, -#: 76 imports, 242 relative imports, 57 checked arguments, 6 awaited HA calls, -#: 1417 statement blocks) so a broken or mis-targeted walk fails loudly instead of +#: 77 imports, 282 relative imports, 65 checked arguments, 6 awaited HA calls, +#: 1637 statement blocks) so a broken or mis-targeted walk fails loudly instead of #: printing zeros and exiting 0. FLOORS: dict[str, int] = { # t41: the sweep is recursive, so the floor sits above the top-level-only # count (13) — a regression to the shallow glob trips it. - "modules": 20, + # t17: raised 20 -> 24, just under the current 26. + "modules": 24, # t15: raised from 40/100 to just under the current 76/206, because the remote # parity work is an IMPORTS-and-coupling feature: `RemoteEntityFeature`, # `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory # helpers all arrive as imports, and none of them may vanish unnoticed. - "imports": 70, + # t17: raised 70 -> 75, just under the current 77 (the intents work imports + # the scan/status vocabulary into the flow and the engine into the platform). + "imports": 75, # t16: raised 190 -> 195 -> 235, again just under the walk. The intent # discovery module adds the package's exports to `ha_adb_common`'s surface and # is imported through it by the manager, so the relative-import count is exactly # where a new module must show up — a floor left behind would let the manager's # import of it disappear unnoticed. - "relative_imports": 235, + # t17: raised 235 -> 275, just under the current 282. The registered-intent + # model, its statuses and its helpers move between modules, so this is again + # exactly where a detached import must show up. + "relative_imports": 275, # t62 F2: raised from 20 to just under the current 46. A floor's job is to # notice an API OWNER going away, not only a broken walk: at 20, deleting # `async_show_menu` from HA_CALL_OWNERS left 40 checked arguments and still @@ -176,7 +182,10 @@ FLOORS: dict[str, int] = { # t15: raised from 52 to just under the current 57 — the options-flow pairing # step is a real flow-display surface of its own (its own async_show_form with # step_id/data_schema/errors), so those call sites must not be able to vanish. - "call_args": 55, + # t17: raised 55 -> 63, just under the current 65 — the Intents screen adds + # real flow-display call sites of its own (its menu, its three forms and the + # scan's result step), none of which may vanish unnoticed. + "call_args": 63, "awaited_calls": 5, # t62 F2: the unreachable-statement pass walks ~1300 statement blocks. A # floor here means a pass that silently stopped parsing cannot look green. @@ -185,7 +194,11 @@ FLOORS: dict[str, int] = { # t16: raised 1200 -> 1300 -> 1400: the parser, the cache and the manager's # scan path are new statement blocks, and the unreachable pass must keep # covering them. - "statement_blocks": 1400, + # t17: raised 1400 -> 1620, just under the current 1637. The screen, the seed + # validator, the command builder and the button are new statement blocks, and + # the unreachable pass is what keeps covering them — which is the point of the + # floor: a pass that silently stopped parsing must not look green. + "statement_blocks": 1620, } #: The interpreter floor Home Assistant itself declares: PyPI metadata for diff --git a/tests/test_config_flow_real_ha.py b/tests/test_config_flow_real_ha.py index 4993332..f394258 100644 --- a/tests/test_config_flow_real_ha.py +++ b/tests/test_config_flow_real_ha.py @@ -21,6 +21,8 @@ receives and the exact ``user_input`` a submission produces. from __future__ import annotations +import json + import pytest import pytest_asyncio @@ -286,6 +288,7 @@ class TestRealMenuFlow: "connection", "receiver", "apps", + "intents", "discover", "scripts", "finish", @@ -304,10 +307,11 @@ class TestRealMenuFlow: opened = await _manager.async_configure( fresh["flow_id"], {"next_step_id": row} ) - # a sub-screen is a FORM — except Apps, which is a MENU of row - # actions: HA has no table selector, so a table is edited one row per - # step (t64), and every row it offers has to be dispatchable too - expected = "menu" if row == "apps" else "form" + # a sub-screen is a FORM — except the two EDITORS, which are MENUs of + # row actions: HA has no table selector, so a table is edited one row + # per step (t64 Apps, t17 Intents), and every row they offer has to be + # dispatchable too + expected = "menu" if row in ("apps", "intents") else "form" assert opened["type"] == expected, (row, opened) # HA dispatches a submitted form by its OWN step_id, so the rendered # step_id must be the menu row id (the method name) @@ -327,6 +331,165 @@ class TestRealMenuFlow: ) +class TestRealIntentsScreen: + """t17: the Intents editor driven by the REAL FlowManager. + + This is the class of bug this file exists for: HA dispatches a menu row to + ``async_step_`` and a submitted form to ``async_step_``, + so a step id that does not match its method is an UnknownStep — invisible to a + stub harness that calls the methods directly. + """ + + REGISTRATION = { + "intent_name": "Netflix: open a title", + "intent_package": "com.netflix.ninja", + "intent_activity": "com.netflix.ninja/.MainActivity", + "intent_action": "android.intent.action.VIEW", + "intent_categories": "android.intent.category.DEFAULT", + "intent_data": "https://www.netflix.com/watch/{id}", + "intent_fields": "id:string:required", + } + + async def _open_intents(self, ha, entry): + manager, menu = await open_options(ha, entry) + intents = await manager.async_configure( + menu["flow_id"], {"next_step_id": "intents"} + ) + assert intents["type"] == "menu", intents + return manager, intents + + async def test_the_screen_is_a_menu_of_row_actions(self, ha): + entry = make_entry(ha) + manager, intents = await self._open_intents(ha, entry) + + assert intents["step_id"] == "intents" + # nothing registered yet, so there is nothing to remove + assert set(intents["menu_options"]) == { + "intents_edit", + "intents_scan", + "menu_again", + } + assert field_names(intents["data_schema"]) == ["next_step_id"] + + async def test_the_selector_carries_the_add_new_sentinel(self, ha): + entry = make_entry(ha) + manager, intents = await self._open_intents(ha, entry) + + edit = await manager.async_configure( + intents["flow_id"], {"next_step_id": "intents_edit"} + ) + assert edit["type"] == "form" and edit["step_id"] == "intents_edit" + + choice = next(f for f in field_list(edit["data_schema"]) if f["name"] == "intent_choice") + options = choice["selector"]["select"]["options"] + assert [option["value"] for option in options] == ["__new__"], ( + "the Add-new sentinel is an OPTION the user can see" + ) + + async def test_a_registration_round_trips_through_the_real_flow(self, ha): + """ADD, SAVE, REOPEN: the registration is in the options afterwards.""" + entry = make_entry(ha, options={"host": "10.0.0.9"}) + manager, intents = await self._open_intents(ha, entry) + edit = await manager.async_configure( + intents["flow_id"], {"next_step_id": "intents_edit"} + ) + form = await manager.async_configure( + edit["flow_id"], {"intent_choice": "__new__"} + ) + assert form["type"] == "form" and form["step_id"] == "intents_form" + + saved = await manager.async_configure(form["flow_id"], dict(self.REGISTRATION)) + assert saved["type"] == "menu" and saved["step_id"] == "intents", ( + "the row is stored in the flow's options, nothing committed yet" + ) + + back = await manager.async_configure( + saved["flow_id"], {"next_step_id": "menu_again"} + ) + created = await manager.async_configure( + back["flow_id"], {"next_step_id": "finish"} + ) + + assert created["type"] == "create_entry" + stored = created["data"]["registered_intents"] + assert len(stored) == 1 + assert stored[0]["name"] == "Netflix: open a title" + assert stored[0]["activity"] == "com.netflix.ninja/.MainActivity" + assert stored[0]["fields"] == [{"key": "id", "type": "string", "required": True}] + # JSON, because that is how the options are persisted + json.dumps(created["data"]) + + async def test_a_bad_activity_is_refused_by_the_real_flow(self, ha): + """The validator runs where the user is: the form comes back with a note.""" + entry = make_entry(ha, options={"host": "10.0.0.9"}) + manager, intents = await self._open_intents(ha, entry) + edit = await manager.async_configure( + intents["flow_id"], {"next_step_id": "intents_edit"} + ) + form = await manager.async_configure( + edit["flow_id"], {"intent_choice": "__new__"} + ) + + rejected = await manager.async_configure( + form["flow_id"], {**self.REGISTRATION, "intent_activity": "no-slash"} + ) + + assert rejected["type"] == "form" and rejected["step_id"] == "intents_form" + assert rejected["errors"] == {"base": "invalid_intent_row"} + assert "not a component" in ( + rejected.get("description_placeholders") or {} + ).get("intent_note", "") + + async def test_a_registered_intent_can_be_removed_and_confirmed(self, ha): + entry = make_entry(ha, options={"host": "10.0.0.9"}) + manager, menu = await open_options(ha, entry) + # register one through the real flow first + intents = await manager.async_configure( + menu["flow_id"], {"next_step_id": "intents"} + ) + edit = await manager.async_configure( + intents["flow_id"], {"next_step_id": "intents_edit"} + ) + form = await manager.async_configure( + edit["flow_id"], {"intent_choice": "__new__"} + ) + saved = await manager.async_configure(form["flow_id"], dict(self.REGISTRATION)) + + # ...then remove it, through the same menu + again = await manager.async_configure( + saved["flow_id"], {"next_step_id": "menu_again"} + ) + back_in = await manager.async_configure( + again["flow_id"], {"next_step_id": "intents"} + ) + assert "intents_remove" in back_in["menu_options"], ( + "a registered intent offers removal" + ) + remove = await manager.async_configure( + back_in["flow_id"], {"next_step_id": "intents_remove"} + ) + confirm = await manager.async_configure( + remove["flow_id"], {"intent_choice": "netflix_open_a_title"} + ) + assert confirm["type"] == "menu" and confirm["step_id"] == ( + "intents_remove_confirm" + ) + + gone = await manager.async_configure( + confirm["flow_id"], {"next_step_id": "intents_remove_yes"} + ) + assert gone["step_id"] == "intents" + assert set(gone["menu_options"]) == {"intents_edit", "intents_scan", "menu_again"} + + finished = await manager.async_configure( + gone["flow_id"], {"next_step_id": "menu_again"} + ) + created = await manager.async_configure( + finished["flow_id"], {"next_step_id": "finish"} + ) + assert created["data"]["registered_intents"] == [] + + class TestRealAppsField: """[object Object]: the field list must carry TEXT, never a dict.""" diff --git a/tests/test_intents_registration.py b/tests/test_intents_registration.py index 3736865..ad593ac 100644 --- a/tests/test_intents_registration.py +++ b/tests/test_intents_registration.py @@ -935,6 +935,30 @@ class TestTheSeed: assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]] assert "repeats the id" in caplog.records[0].message + def test_every_documented_row_key_is_honoured(self, modules): + """The README's documented keys and the seed validator must agree (t17). + + A key the docs show but the validator drops would be worse than a typo: + it would look configured and do nothing. + """ + from test_readme import INTENT_ROW_KEYS + + row = { + "id": "example", + "name": "Example", + "package": "com.example.app", + "activity": "com.example.app/.MainActivity", + "action": "android.intent.action.VIEW", + "categories": ["android.intent.category.DEFAULT"], + "data": "https://example.test/{slug}", + "fields": [{"key": "slug", "type": "string"}], + } + assert set(row) == INTENT_ROW_KEYS, "the fixture must cover the docs" + + seeded = modules.flow._seeded_intent_rows([row]) + + assert set(seeded[0]) == INTENT_ROW_KEYS, "every documented key survives" + def test_a_non_list_block_is_ignored_with_a_warning(self, modules, caplog): with caplog.at_level("WARNING"): seeded = modules.flow._seeded_intent_rows("nope") diff --git a/tests/test_readme.py b/tests/test_readme.py index 331440e..382e548 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -70,3 +70,74 @@ def test_the_readme_states_the_on_rule_precisely(readme_text): for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"): assert shape in readme_text, shape assert "no source reported anything at all" not in readme_text + + +# ── t17: the intents documentation ── + +#: The keys a seeded intent row may carry (the seed validator in config_flow.py +#: decides what it ACCEPTS; these are the names the README may use). +INTENT_ROW_KEYS = { + "id", + "name", + "package", + "activity", + "action", + "categories", + "data", + "fields", +} + +#: The keys one declared field may carry. +INTENT_FIELD_KEYS = {"key", "type", "required", "default"} + + +def _intents_example(text: str) -> str: + """The `registered_intents:` block of the README's seed example (t17).""" + match = re.search( + r"```yaml\n(?:.*\n)*? registered_intents:\n((?:.*\n)*?)```", text + ) + assert match, "the README has no `registered_intents:` example block" + return match.group(1) + + +def test_the_intents_example_only_uses_keys_the_seed_honours(readme_text): + """Documented-but-ignored is the failure this pins (t30's rule, nested).""" + block = _intents_example(readme_text) + row_keys = set(re.findall(r"^\s*([a-z_]+):", block, re.M)) + field_keys = set(re.findall(r"[{,]\s*([a-z_]+):", block)) + + assert row_keys, "the intents example declares no row keys" + assert row_keys <= INTENT_ROW_KEYS, sorted(row_keys - INTENT_ROW_KEYS) + assert field_keys <= INTENT_ROW_KEYS | INTENT_FIELD_KEYS, sorted( + field_keys - INTENT_ROW_KEYS - INTENT_FIELD_KEYS + ) + + +def test_the_intents_screen_and_action_are_documented(readme_text): + """The screen, its fields, the button and the action are all named (t17).""" + for needle in ( + "### Intents (buttons and the `send_intent` action)", + "Configure → **Intents**", + "Variables (declared fields)", + "One button per registered intent", + "nvidia_shield.send_intent", + "intent_response", + ): + assert needle in readme_text, needle + + +def test_the_intents_copy_states_the_honest_failures(readme_text): + """The README names each way this can go wrong — and that it RAISES (t17).""" + for key in ( + "unknown_intent", + "missing_intent_field", + "unknown_intent_variable", + "invalid_intent_field", + "intent_failed", + "intents_need_adb", + ): + assert key in readme_text, key + assert "configuration problem" in readme_text + # the sentence wraps across lines in the source, so compare it flattened + flat = " ".join(readme_text.split()) + assert "no `eval`, no `format()`, no shell interpolation" in flat diff --git a/tests/test_send_intent_real_ha.py b/tests/test_send_intent_real_ha.py new file mode 100644 index 0000000..62aebe7 --- /dev/null +++ b/tests/test_send_intent_real_ha.py @@ -0,0 +1,478 @@ +"""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)