feat(adb_command): selectable target, strict resolution, and a real response (t14)

The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:

* ServiceRegistry.async_register has NO target parameter, so a target cannot be
  declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
  HA builds from services.yaml or from the runtime async_set_service_schema —
  and the runtime description WINS.

So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.

The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.

Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.

Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
This commit is contained in:
James Mackay committed 2026-10-12 11:23:47 +01:00
1 parent 8a4e72b80a
commit 6aa3b6bfdd
13 files changed
+1087 -56

No files matched your search

+43 -19
View File
@@ -114,24 +114,34 @@ jobs:
- name: HA API-drift sweep
run: python3 -m pytest tests/test_ha_api_drift.py -q
# The real-HA config-flow tests (tests/test_config_flow_real_ha.py) are the
# ONLY place the config flow meets the real homeassistant.data_entry_flow
# machinery — and that file SKIPS ITSELF at import time when homeassistant is
# absent (pytest.importorskip at module scope). A skipped file and a passing
# one are indistinguishable in the jobs above: `python3 -m pytest tests -q`
# ends in "N passed, 1 skipped" and still exits 0, and the drift sweep never
# touches this file. That is exactly how three real-HA-only facts stayed
# hidden while five gates were green:
# The real-HA tests are the ONLY place the integration meets the installed Home
# Assistant — and EVERY one of them SKIPS ITSELF at import time when
# homeassistant is absent (pytest.importorskip at module scope). A skipped file
# and a passing one are indistinguishable in the jobs above: `python3 -m pytest
# tests -q` ends in "N passed, 3 skipped" and still exits 0, and the drift sweep
# never touches them. That is exactly how three real-HA-only facts stayed hidden
# while five gates were green:
#
# * a menu row id must equal its async_step_<id> method name (otherwise the
# real FlowManager raises when the row is dispatched),
# * the rendered step_id must equal the row id, and
# * async_show_menu takes no errors kwarg.
#
# t14 added a second file, and with it the reason this job GLOBS instead of
# naming one: tests/test_adb_command_real_ha.py proves the adb_command action is
# SELECTABLE (the runtime service description carries the entity target the
# frontend builds its picker from — `async_register` has no target parameter) and
# that a real async_call returns the command's output, and it also caught an
# `async_entries_for_device` that is a MODULE-LEVEL helper, not a registry
# method. A named file list would have left it skipping silently here. The
# pattern `tests/test_*real_ha.py` is the rule: a new real-HA file is picked up
# by virtue of existing, and the files that ran are printed.
#
# So this job does two things the others cannot: it installs the real stack on
# Home Assistant's interpreter floor, and it FAILS LOUDLY if the tests are
# skipped instead of run. Do not "simplify" the guard at the bottom away — a
# test file that skips silently is indistinguishable from one that passes.
# skipped instead of run (or if the pattern matches nothing). Do not "simplify"
# the guard at the bottom away — a test file that skips silently is
# indistinguishable from one that passes.
real-ha-config-flow:
runs-on: ubuntu-latest
steps:
@@ -180,40 +190,54 @@ jobs:
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
PY
- name: Real-HA config-flow tests
- name: Real-HA tests
run: |
set -euo pipefail
LOG=/tmp/real-ha-config-flow.log
LOG=/tmp/real-ha.log
# THE PATTERN, not a filename: every tests/test_*real_ha.py runs, so a new
# real-HA file cannot skip silently merely because nobody listed it here.
# An empty match is a failure too — but it is checked first so the log says
# why instead of blaming pytest's argument parsing.
shopt -s nullglob
FILES=(tests/test_*real_ha.py)
shopt -u nullglob
if [ "${#FILES[@]}" -eq 0 ]; then
echo "::error::no tests/test_*real_ha.py file exists — this gate would pass vacuously"
exit 1
fi
echo "real-HA files collected (${#FILES[@]}): ${FILES[*]}"
# Verbatim the command this gate is validated with locally; -rs is what
# makes a silent skip visible in the log.
set +e
uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \
--with pytest-asyncio \
pytest tests/test_config_flow_real_ha.py -q -rs 2>&1 | tee "$LOG"
pytest "${FILES[@]}" -q -rs 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
set -e
if [ "$rc" -ne 0 ]; then
echo "::error::the real-HA config-flow tests exited $rc"
echo "::error::the real-HA tests exited $rc"
exit "$rc"
fi
# THE GUARD. Some pytest versions exit 0 for a file that skipped
# itself, so the exit code above is not enough. The last line of a -q
# run is the counts line ("26 passed in 1.6s" / "1 skipped in
# 0.01s"); it must report PASSED tests and nothing else.
# run is the counts line ("50 passed in 1.6s" / "1 skipped in
# 0.01s" / "no tests ran in 0.01s"); it must report PASSED tests and
# nothing else — a skip, a failure and a zero-collection run all fail
# here, which is what makes the glob safe to widen.
summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1)
echo "pytest summary: $summary"
case "$summary" in
[0-9]*" passed"*) ;;
*)
echo "::error::the real-HA config-flow tests did not run (summary: $summary)"
echo "The file skips itself when homeassistant/probatio is missing — a skip here is a FAILURE, not a pass."
echo "::error::the real-HA tests did not run (summary: $summary)"
echo "These files skip themselves when homeassistant is missing — a skip here is a FAILURE, not a pass."
exit 1
;;
esac
case "$summary" in
*skipped*|*failed*|*error*|*"no tests ran"*)
echo "::error::the real-HA config-flow run was not a clean pass (summary: $summary)"
echo "::error::the real-HA run was not a clean pass (summary: $summary)"
exit 1
;;
esac