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:
James Mackay committed 2026-10-12 12:59:08 +01:00
1 parent 259a6c29bd
commit e6c6bdb1e6
5 files changed
+760 -11

No files matched your search

+167 -4
View File
@@ -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_<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:
"""[object Object]: the field list must carry TEXT, never a dict."""