test(intents): the real-HA action proof, the README pins and the raised drift floor (t17, part 3)
The evidence half of the ticket. Two things here are new facts about the
installed Home Assistant rather than about our code.
THE ACTION, DRIVEN BY REAL HOME ASSISTANT (tests/test_send_intent_real_ha.py).
The claim "the action returns the device's output" cannot be settled with a stub:
whether a service ANSWERS is HA's decision. On 2026.10.1, with the real service
registry and the real websocket command the frontend calls:
* `supports_response` is OPTIONAL, which is what keeps every caller that does not
ask for a response working — REQUIRED would break them;
* `get_services` serves the intent field as a dropdown whose options are the
registered names AND keeps `custom_value` on, so a name typed by hand is still
accepted;
* the entity target is `{entity: [{domain: [media_player]}]}` — the picker, the
same mechanism t14 proved for adb_command (async_register has no target
parameter; the runtime description is what the UI reads);
* a real `async_call(..., return_response=True)` comes back as
`{"intent_response": "Starting: Intent { … }"}`, and the command that reached the
ADB surface is asserted VERBATIM, including the `--es`/`--ez` flags and the
resolved URL;
* an unknown intent, a missing required field, a refused intent (the device's own
"does not exist" text is kept and the call RAISES), ADB being down and a missing
or unsupported target each raise ServiceValidationError with our translation key
and a message a user can act on.
The dropdown is published from `hass.data[DOMAIN]` — the store a real entry setup
fills — so the file also pins the EMPTY state: nothing stored means no options, not
invented ones.
THE OPTIONS SCREEN, DRIVEN BY THE REAL FLOW MANAGER
(tests/test_config_flow_real_ha.py). The Intents menu, the Add-new sentinel as a
real selector option, a registration that round-trips to `create_entry` (JSON-safe,
as the options store requires), a refused row coming back as a real form error with
its note, and a removal that only takes effect after the confirmation. This is the
file that exists because a step id which does not match its method name is an
UnknownStep that only a real FlowManager raises.
THE FLOOR (tests/ha_api_drift.py). The sweep now walks 26 modules, 77 imports, 282
relative imports, 65 call arguments and 1637 statement blocks (was 76/242/57/1417),
so the non-vacuity floors move with it: imports 70→75, relative_imports 235→275,
call_args 55→63, modules 20→24, statement_blocks 1400→1620. They stay just under
the walk on purpose — a floor's job is to notice a walk that silently stopped
covering the new surfaces, and the unreachable-statement pass is exactly the gate
that must keep covering the ~240 new blocks. Raised, never lowered.
THE README (tests/test_readme.py). The intents example is parsed and its keys are
required to be ones the seed honours (documented-but-ignored is the failure this
catches, t30's rule applied to the nested block), the screen and the action are
named, and every honest failure the README promises has its key. Plus a cross-check
in the intents suite: the row keys the README documents are exactly the keys the
seed validator KEEPS, so the docs cannot describe a key that silently vanishes.
Verify (all five, in the contract order):
uvx --with pytest-asyncio pytest tests -q → 872 passed, 5 skipped
uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 873 passed, 4 skipped
uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \
--with pytest-asyncio pytest tests/test_config_flow_real_ha.py -q → 49 passed
uvx ruff check custom_components tests → clean
uvx --python 3.14 --with 'homeassistant>=2026.10' --with pyyaml \
python tests/ha_api_drift.py → exit 0, no drift
The real-HA pattern collected and RAN three files (no skips):
tests/test_adb_command_real_ha.py, tests/test_config_flow_real_ha.py,
tests/test_send_intent_real_ha.py → 74 passed.
This commit is contained in:
1 parent
259a6c29bd
commit
e6c6bdb1e6
5 files changed
+760
-11
No files matched your search
+20
-7
@@ -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
|
#: 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,
|
#: 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,
|
#: 77 imports, 282 relative imports, 65 checked arguments, 6 awaited HA calls,
|
||||||
#: 1417 statement blocks) so a broken or mis-targeted walk fails loudly instead of
|
#: 1637 statement blocks) so a broken or mis-targeted walk fails loudly instead of
|
||||||
#: printing zeros and exiting 0.
|
#: printing zeros and exiting 0.
|
||||||
FLOORS: dict[str, int] = {
|
FLOORS: dict[str, int] = {
|
||||||
# t41: the sweep is recursive, so the floor sits above the top-level-only
|
# t41: the sweep is recursive, so the floor sits above the top-level-only
|
||||||
# count (13) — a regression to the shallow glob trips it.
|
# 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
|
# t15: raised from 40/100 to just under the current 76/206, because the remote
|
||||||
# parity work is an IMPORTS-and-coupling feature: `RemoteEntityFeature`,
|
# parity work is an IMPORTS-and-coupling feature: `RemoteEntityFeature`,
|
||||||
# `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory
|
# `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory
|
||||||
# helpers all arrive as imports, and none of them may vanish unnoticed.
|
# 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
|
# 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
|
# 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
|
# 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
|
# where a new module must show up — a floor left behind would let the manager's
|
||||||
# import of it disappear unnoticed.
|
# 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
|
# 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
|
# 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
|
# `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
|
# 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 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.
|
# 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,
|
"awaited_calls": 5,
|
||||||
# t62 F2: the unreachable-statement pass walks ~1300 statement blocks. A
|
# t62 F2: the unreachable-statement pass walks ~1300 statement blocks. A
|
||||||
# floor here means a pass that silently stopped parsing cannot look green.
|
# 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
|
# 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
|
# scan path are new statement blocks, and the unreachable pass must keep
|
||||||
# covering them.
|
# 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
|
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ receives and the exact ``user_input`` a submission produces.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
@@ -286,6 +288,7 @@ class TestRealMenuFlow:
|
|||||||
"connection",
|
"connection",
|
||||||
"receiver",
|
"receiver",
|
||||||
"apps",
|
"apps",
|
||||||
|
"intents",
|
||||||
"discover",
|
"discover",
|
||||||
"scripts",
|
"scripts",
|
||||||
"finish",
|
"finish",
|
||||||
@@ -304,10 +307,11 @@ class TestRealMenuFlow:
|
|||||||
opened = await _manager.async_configure(
|
opened = await _manager.async_configure(
|
||||||
fresh["flow_id"], {"next_step_id": row}
|
fresh["flow_id"], {"next_step_id": row}
|
||||||
)
|
)
|
||||||
# a sub-screen is a FORM — except Apps, which is a MENU of row
|
# a sub-screen is a FORM — except the two EDITORS, which are MENUs of
|
||||||
# actions: HA has no table selector, so a table is edited one row per
|
# row actions: HA has no table selector, so a table is edited one row
|
||||||
# step (t64), and every row it offers has to be dispatchable too
|
# per step (t64 Apps, t17 Intents), and every row they offer has to be
|
||||||
expected = "menu" if row == "apps" else "form"
|
# dispatchable too
|
||||||
|
expected = "menu" if row in ("apps", "intents") else "form"
|
||||||
assert opened["type"] == expected, (row, opened)
|
assert opened["type"] == expected, (row, opened)
|
||||||
# HA dispatches a submitted form by its OWN step_id, so the rendered
|
# HA dispatches a submitted form by its OWN step_id, so the rendered
|
||||||
# step_id must be the menu row id (the method name)
|
# 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_<row id>`` and a submitted form to ``async_step_<its step_id>``,
|
||||||
|
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:
|
class TestRealAppsField:
|
||||||
"""[object Object]: the field list must carry TEXT, never a dict."""
|
"""[object Object]: the field list must carry TEXT, never a dict."""
|
||||||
|
|
||||||
|
|||||||
@@ -935,6 +935,30 @@ class TestTheSeed:
|
|||||||
assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]]
|
assert [row["id"] for row in seeded] == [NETFLIX_INTENT["id"]]
|
||||||
assert "repeats the id" in caplog.records[0].message
|
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):
|
def test_a_non_list_block_is_ignored_with_a_warning(self, modules, caplog):
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
seeded = modules.flow._seeded_intent_rows("nope")
|
seeded = modules.flow._seeded_intent_rows("nope")
|
||||||
|
|||||||
@@ -70,3 +70,74 @@ def test_the_readme_states_the_on_rule_precisely(readme_text):
|
|||||||
for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"):
|
for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"):
|
||||||
assert shape in readme_text, shape
|
assert shape in readme_text, shape
|
||||||
assert "no source reported anything at all" not in readme_text
|
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
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user