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.
144 lines
5.0 KiB
Python
144 lines
5.0 KiB
Python
"""tests/test_readme.py — the README's user-facing contracts (t41).
|
|
|
|
Documentation drifts silently: the seed example is read by people who then
|
|
report "the key did nothing". These pins are deliberately textual — no YAML
|
|
parser dependency — but they check the two things that actually hurt:
|
|
|
|
* every key in the `nvidia_shield:` example is a key ``_yaml_seed_blocks``
|
|
honours (nothing documented that the integration ignores);
|
|
* the replacement guide names the integrations and the glue it retires, so the
|
|
t41 ruling stays visible to users, not only in the changelog.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
README = Path(__file__).resolve().parent.parent / "README.md"
|
|
|
|
# helpers.seed_host / helpers.seed_adb_enabled read these; nothing else in a
|
|
# seed block reaches the entry (the rest is the interactive flow's or the
|
|
# options page's to set).
|
|
SEEDED_KEYS = {
|
|
"name",
|
|
"host",
|
|
"adb_port",
|
|
"adb_enabled",
|
|
"protocol_host", # legacy, still accepted
|
|
"adb_host", # legacy, still accepted
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def readme_text() -> str:
|
|
return README.read_text(encoding="utf-8")
|
|
|
|
|
|
def _seed_example(text: str) -> str:
|
|
match = re.search(r"```yaml\n(nvidia_shield:\n(?:.*\n)*?)```", text)
|
|
assert match, "the README has no `nvidia_shield:` example block"
|
|
return match.group(1)
|
|
|
|
|
|
def test_the_example_block_only_uses_seeded_keys(readme_text):
|
|
keys = set(re.findall(r"^\s+([a-z_]+):", _seed_example(readme_text), re.M))
|
|
|
|
assert keys, "the example block declares no keys"
|
|
assert keys <= SEEDED_KEYS, f"documented but ignored: {sorted(keys - SEEDED_KEYS)}"
|
|
|
|
|
|
def test_the_example_block_documents_the_one_address_field(readme_text):
|
|
example = _seed_example(readme_text)
|
|
|
|
assert "host:" in example
|
|
# the legacy spellings are still explained, as back-compat
|
|
assert "protocol_host" in readme_text and "adb_host" in readme_text
|
|
|
|
|
|
def test_the_replacement_guide_retires_the_old_stack(readme_text):
|
|
for needle in ("androidtv_remote", "mediasession2mqtt", "universal", "Wholphin"):
|
|
assert needle in readme_text, needle
|
|
# and it says the UI is the primary path
|
|
assert "primary path" in readme_text
|
|
|
|
|
|
def test_the_readme_states_the_on_rule_precisely(readme_text):
|
|
"""t58 UNIT 0: "no data" was too loose; the README must not still say it."""
|
|
assert "no source reported a power or playback fact" in readme_text
|
|
# the four reporting-but-not-playing shapes, named
|
|
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
|