Files
ha-nvidia-shield/tests/test_ha_api_drift.py
T
James Mackay 14c168c25e feat(remote): the app/activity picker, and a protocol transport that really connects (t15)
The user's remote displayed but offered no app picker, and the protocol
transport was never actually switched on. Both halves are fixed here.

REMOTE PARITY. The entity declares RemoteEntityFeature.ACTIVITY — exactly
what core's androidtv_remote declares — and publishes activity_list, which
is what makes the frontend render an activity picker at all. The list is
the app directory (the user's apps map, else the curated table as a floor)
in map order, then the apps ADB discovery found and something can name, in
the one name chain (user map > device label > curated > package id).
Nothing nameless is listed as a bare package id, and current_activity is
None until something is known: no activity is invented. remote.turn_on
honours activity= by waking the device when it reports itself off and then
launching through the existing launch path; a listed name resolves
case-insensitively, a package id or a detail link passes through, and
anything else raises unknown_activity naming the value instead of silently
doing nothing. An empty/absent activity keeps the previous behaviour
exactly, async_turn_off is untouched, and a configured command script still
owns the sequence.

WHY THE TRANSPORT WAS DEAD, MEASURED. androidtvremote2's keep_reconnecting
only starts _async_reconnect, whose whole body is
`while self._remote_message_protocol:` — false on the first check with
nothing connected, so the task returned immediately: no connect attempt, no
retry, and the auth callback was never reachable. HA core connects FIRST and
only then keeps it alive. Our _start_protocol called keep_reconnecting after
generating a certificate and never called async_connect anywhere, so every
send_key_command/send_launch_app_command raised ConnectionClosed internally,
the helpers swallowed it into `return False` plus a debug line, and no push
(is_available/is_on/current_app/volume_info) ever arrived.

The fix is a background connect loop: async_connect under a bounded timeout,
network failures retried with the ADB watchdog's jitter and backoff,
InvalidAuth routed into the EXISTING reauth callback (entry.async_start_reauth),
and keep_reconnecting only after a session is up. Setup never blocks on a
transport. A send that cannot run is no longer silent: it is counted, its
error recorded verbatim, and logged at WARNING with the transport state, and
the remote publishes protocol_state / protocol_connected /
protocol_ever_authenticated / protocol_last_error / protocol_failures /
protocol_certificate plus sticky per-push *_seen flags — so "sent" and
"never switched on" can never look alike again.

CASE (b) IS FIXED, NOT DOCUMENTED: switching the transport on in the options
flow for an entry that never paired now routes into the real
async_step_protocol_pair, and "continue without pairing" persists the
transport OFF. One marker carries the pairing (CONF_PROTOCOL_PAIRED),
written by the setup pairing step, the reauth finisher and the reconfigure
finisher, so no entry can sit enabled-but-unpaired. The certificate from the
first pairing is reused by design, so a paired entry is asked for no new PIN
— which is also why the marker, not certificate material, is the source of
truth.

ADB STAYS THE PRIMARY STATE SOURCE. Arming the protocol introduces pushes
that were never live before, so the reading now names its tier:
manager.foreground_source drives the media player's app_source and the
remote's activity_source, and the new tests prove a full protocol push set
leaves the media state, the now-playing reading and every ADB store field
untouched, while an ADB-only entry still serves media player, sensors, apps,
scripts and receiver with only the protocol surface degraded.

Copy: unknown_activity, activity_launch_failed and protocol_unavailable in
BOTH strings.json and translations/en.json, pinned by a test; the README
documents the picker, activity resolution, how to read the transport-state
attributes, the script bypass (a script-routed press is never evidence of
pairing), the connect-then-keep-alive lifecycle and cert reuse versus the
other integration's own pairing.

Evidence: 6 new real-HA FlowManager tests in tests/test_config_flow_real_ha.py
(unpaired -> pairs, PIN -> marker, skip -> off, paired -> not asked again, and
the setup flow's marker on success and on skip), a real-HA entity contract
test in tests/test_ha_api_drift.py (exact supported_features equality with
core, activity_list/current_activity reaching the state machine, live _attr_
updates) and new suites in tests/test_protocol_transport.py (connect-first,
keep_reconnecting alone is a no-op, retry, auth rejection, honest send
failures, ADB-primary precedence) and tests/test_command_routing.py (the
activity contract, turn_on(activity=), script ownership, the transport
attribute states). No device and no browser exist here: device behaviour is
inferred from the library and core source, and every UI-facing claim is
proven at the entity/registry/metadata level under real Home Assistant.

Verify: suites 719/4 and 720/3; real-HA glob (tests/test_adb_command_real_ha.py
+ tests/test_config_flow_real_ha.py) 56 passed, 0 skipped; ruff clean; drift
exit 0 (25 modules, 76 imports, 206 relative imports, 1322 statement blocks,
57 call arguments) with the floors raised 40->70, 100->195, 500->1300 and
52->55.
2026-10-12 12:15:58 +01:00

741 lines
28 KiB
Python

"""Run the Home-Assistant API-drift sweep (t37).
Skips cleanly when homeassistant is not installed — every other test file runs
against hand-written stubs, and this is the one check that must see the real
package. CI's `ha-api-drift` job installs it:
uvx --with homeassistant --with pytest-asyncio pytest tests/test_ha_api_drift.py -q
The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
"""
from __future__ import annotations
import ast
import asyncio
import importlib
import json
import subprocess
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
import ha_api_drift
import pytest
pytest.importorskip(
"homeassistant",
reason="the API-drift sweep resolves our call sites against a real install",
)
#: The three t36 drifts plus the `options_updates` kwarg t35 removed — the
#: sweep must flag every one of them, or it cannot protect us from the next.
PRE_FIX_SOURCES = {
"d1_browse_media_source.py": (
"from homeassistant.components.media_player import BrowseMediaSource\n"
),
"d2_util_diagnostic.py": (
"from homeassistant.util.diagnostic import async_redact_data\n"
),
"d3_unawaited_discovery.py": (
"from homeassistant import config_entries\n"
"\n"
"\n"
"class PreFixFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_zeroconf(self, discovery_info):\n"
" self._async_handle_discovery_without_unique_id()\n"
),
# t57 UNIT 0a: a wrong kwarg on a FLOW-DISPLAY API. `async_show_menu` takes
# no `errors` in HA 2026.10.1, and the integration passed one — so every
# ordinary setup path raised TypeError at the moment a user submitted the
# first form, which is invisible to a stub suite whose stubs accept it.
"t57_show_menu_errors.py": (
"from homeassistant import config_entries\n"
"\n"
"\n"
"class PreFixSetupMenu(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_user(self, user_input=None):\n"
" return self.async_show_menu(\n"
" step_id='menu',\n"
" menu_options=['finish'],\n"
" errors={\'base\': \'nope\'},\n"
" )\n"
),
"t35_options_updates.py": (
"from homeassistant import config_entries\n"
"\n"
"\n"
"class PreFixReauthFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_reauth_confirm(self, user_input=None):\n"
" self.async_update_reload_and_abort(\n"
" self.entry, options_updates={'protocol_enabled': True}\n"
" )\n"
" return self.async_abort(reason='reauth_successful')\n"
),
}
def _require_installed_homeassistant():
"""Skip when sys.modules holds a test stub rather than an install."""
import sys
module = sys.modules["homeassistant"]
path = getattr(module, "__file__", None)
if path is None or not Path(path).exists():
pytest.skip("homeassistant in sys.modules is a test stub, not an install")
def test_no_api_drift_in_the_production_tree():
"""The gate: every HA import, kwarg and await matches the real package."""
_require_installed_homeassistant()
report = ha_api_drift.scan()
assert report.findings == [], "\n" + str(report)
def test_the_sweep_is_not_vacuous():
"""The counters prove the walk really covered the production tree."""
_require_installed_homeassistant()
report = ha_api_drift.scan()
# Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source
# of truth for this wrapper and the standalone entry point (t39 F1) — so a
# broken or mis-targeted walk fails here instead of looking green.
violations = report.floor_violations()
assert violations == [], "\n".join(violations)
def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
"""Red proof: the t36 drifts and the options_updates kwarg are all caught."""
_require_installed_homeassistant()
for name, source in PRE_FIX_SOURCES.items():
(tmp_path / name).write_text(source, encoding="utf-8")
report = ha_api_drift.scan(tmp_path)
found = {(finding.path, finding.kind) for finding in report.findings}
assert found == {
("d1_browse_media_source.py", "broken-import"),
("d2_util_diagnostic.py", "broken-import"),
("d3_unawaited_discovery.py", "missing-await"),
("t35_options_updates.py", "unknown-kwarg"),
("t57_show_menu_errors.py", "unknown-kwarg"),
}, "\n" + str(report)
def test_the_sweep_accepts_the_fixed_forms(tmp_path):
"""The same call sites written the way production writes them are clean."""
_require_installed_homeassistant()
(tmp_path / "fixed.py").write_text(
"from homeassistant.components.media_player import BrowseMedia\n"
"from homeassistant.helpers.redact import async_redact_data\n"
"from homeassistant import config_entries\n"
"\n"
"\n"
"class FixedFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_zeroconf(self, discovery_info):\n"
" await self._async_handle_discovery_without_unique_id()\n"
"\n"
" def finish(self):\n"
" return self.async_update_reload_and_abort(\n"
" self.entry, options={'protocol_enabled': True}\n"
" )\n",
encoding="utf-8",
)
report = ha_api_drift.scan(tmp_path)
assert report.findings == [], "\n" + str(report)
def _run_drift_module(*paths):
"""Run the standalone entry point in this (HA-bearing) interpreter."""
return subprocess.run(
[
sys.executable,
str(Path(ha_api_drift.__file__).resolve()),
*[str(path) for path in paths],
],
capture_output=True,
text=True,
check=False,
)
def test_the_standalone_entry_point_passes_on_the_production_tree():
"""F1: the CLI is the sweep, with the same verdict on the real tree."""
result = _run_drift_module()
assert result.returncode == 0, result.stderr
assert "no Home-Assistant API drift" in result.stdout
def test_the_standalone_entry_point_fails_on_an_empty_directory(tmp_path):
"""t39 F1: zeros must not exit 0 — the floors apply to the CLI too."""
result = _run_drift_module(tmp_path)
assert result.returncode != 0
assert "0 modules" in result.stdout # the counter that used to look green
assert "FLOOR:" in result.stderr
assert "drift sweep FAILED" in result.stderr
def test_the_standalone_entry_point_fails_on_a_missing_path(tmp_path):
"""A mis-specified path is a failure, not a silent zero-module sweep."""
result = _run_drift_module(tmp_path / "not-a-directory")
assert result.returncode != 0
assert "bad-path" in result.stdout
def test_no_floor_constants_are_duplicated_in_this_wrapper():
"""F1: the floors come from ha_api_drift.FLOORS — nowhere else."""
source = Path(__file__).read_text(encoding="utf-8")
tree = ast.parse(source)
local_ints = [
target.id
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, int)
]
assert local_ints == [], f"floor-like constants belong in ha_api_drift: {local_ints}"
assert "floor_violations" in source
def test_the_declared_python_floor_never_lags_homeassistant():
"""F2: the CI guard's floor is HA's own Requires-Python, not a guess."""
_require_installed_homeassistant()
import importlib.metadata as metadata
declared = metadata.metadata("homeassistant")["Requires-Python"]
parsed = ha_api_drift.parse_requires_python(declared)
assert parsed is not None, f"unparsable Requires-Python: {declared!r}"
assert tuple(parsed) <= ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR, (
f"tests/ha_api_drift.py declares {ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR} "
f"but the installed homeassistant requires {declared} — raise the constant "
f"and the setup-python version in .gitea/workflows/test.yml"
)
# ------------------------------------------------- entity contract (t40, real HA)
#
# The stub suites cannot see this class of defect: their harness replaces
# ``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
# fine there. Only HA's own ``Entity.__async_calculate_state`` — which does
# ``attr |= self.extra_state_attributes`` — turns it into
# ``TypeError: 'method' object is not iterable`` and stops the entity from ever
# rendering. These tests drive that real path.
_REPO_ROOT = Path(__file__).resolve().parent.parent
class _RecordingStates:
"""Minimal ``hass.states``: keeps what HA's entity writes."""
def __init__(self):
self.registry: dict = {}
self.written: list = []
def get(self, entity_id):
return self.registry.get(entity_id)
def async_set_internal(self, entity_id, state, attr, *args):
self.written.append((entity_id, state, dict(attr)))
def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
"""Build a production entity under real homeassistant (no platform)."""
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
module = importlib.import_module(f"custom_components.nvidia_shield.{module_name}")
states = _RecordingStates()
hass = SimpleNamespace(
loop_thread_id=threading.get_ident(),
states=states,
data={},
config=SimpleNamespace(components=set()),
# t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which
# registers a shutdown listener on ``hass.bus``. The fake hass carries
# just enough of that surface for the real base class to construct.
bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)),
)
entry = SimpleNamespace(
entry_id="e1", data={}, options=dict(options or {}), title="Shield"
)
entity = getattr(module, class_name)(hass, entry)
entity.entity_id = entity_id
# HA's real translation-key paths (``unit_of_measurement``, device-class
# names) need the platform's translation data; without it a translation-keyed
# entity raises "cannot have a translation key ... before being added to the
# entity platform". PlatformData is the real container, so the entity is
# exercised with the same shape it sees in production.
from homeassistant.helpers.entity_platform import PlatformData
entity.platform_data = PlatformData(
hass, domain=module_name, platform_name="nvidia_shield"
)
# Not added through an EntityComponent: HA would warn once via
# _suggest_report_issue (which needs hass.data["integrations"]) — set the
# flag it uses to report that once instead of faking a whole platform.
entity._no_platform_reported = True
return entity, states
def _write_ha_state(entity, states):
"""Run HA's real state write and return what reached the state machine."""
entity.async_write_ha_state()
assert states.written, f"{type(entity).__name__} wrote no state"
return states.written[-1]
#: Every concrete entity class in the integration, exercised above against the
#: REAL state machine. t40: the media player's defect was a state-write crash, so
#: "the whole tree renders" is the contract under test, not a single entity.
_REAL_HA_MATRIX = (
("media_player", "NvidiaShieldMediaPlayer", None, "media_player.shield"),
("sensor", "NvidiaShieldCurrentAppSensor", None, "sensor.shield_current_app"),
(
"sensor",
"NvidiaShieldNowPlayingSensor",
{"jellyfin_entity": "media_player.jellyfin"},
"sensor.shield_now_playing",
),
(
"sensor",
"NvidiaShieldAppsSensor",
{"adb_enabled": True},
"sensor.shield_installed_apps",
),
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
(
"image",
"NvidiaShieldScreenImage",
{"screen_capture_enabled": True},
"image.shield_screen_capture",
),
)
#: Classified as an entity by the sweep, but an abstract intermediate base HA
#: never instantiates — its concrete subclasses are in the matrix.
_INTERMEDIATE_BASES = frozenset({"NvidiaShieldSensorBase"})
@pytest.mark.parametrize(
("module_name", "class_name", "options", "entity_id"), _REAL_HA_MATRIX
)
def test_entities_calculate_state_under_real_homeassistant(
module_name, class_name, options, entity_id
):
"""t40 blocker: HA's own state write must not raise for ANY entity.
``Entity.async_write_ha_state`` → ``__async_calculate_state`` merges the
extra attributes with ``attr |= ...``; the pre-fix media player raised
``TypeError: 'method' object is not iterable`` here, so it could never render.
"""
_require_installed_homeassistant()
entity, states = _real_ha_entity(
module_name, class_name, options=options, entity_id=entity_id
)
written_id, state, attrs = _write_ha_state(entity, states)
assert written_id == entity_id
assert isinstance(state, str)
assert isinstance(attrs, dict)
def test_the_media_player_extra_attributes_reach_the_state_machine():
"""The property's payload must survive HA's merge, not merely not raise."""
_require_installed_homeassistant()
entity, states = _real_ha_entity(
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
)
_entity_id, _state, attrs = _write_ha_state(entity, states)
# t15: ``app_source`` names the tier that produced the app identity, so the
# protocol push can never look like the ADB reading it outranked.
assert {"app_id", "app_name", "app_source"} <= set(attrs), sorted(attrs)
def test_the_remote_entity_publishes_the_activity_contract_under_real_ha():
"""t15: the app/activity picker contract, measured against real HA.
Core androidtv_remote's remote is the parity target. HA renders the picker from
``RemoteEntityFeature.ACTIVITY`` plus ``activity_list``/``current_activity``,
published through the base class's FINAL ``state_attributes`` — so this asserts
what the STATE MACHINE receives, not what our own attributes happen to say. The
transport evidence rides the same entity, which is what makes "paired?" and
"switched on?" answerable without a device test.
"""
_require_installed_homeassistant()
from homeassistant.components.remote import RemoteEntityFeature
entity, states = _real_ha_entity(
"remote",
"NvidiaShieldRemoteEntity",
options={
"app_name_map": {
"com.netflix.ninja": "Netflix",
"ar.tvplayer.tv": "TiviMate",
}
},
entity_id="remote.shield",
)
assert entity.supported_features == RemoteEntityFeature.ACTIVITY # exactly core's
assert entity.activity_list == ["Netflix", "TiviMate"]
assert entity.current_activity is None, "nothing is known yet — never fabricated"
_entity_id, _state, attrs = _write_ha_state(entity, states)
assert attrs["activity_list"] == ["Netflix", "TiviMate"]
assert attrs["current_activity"] is None
# ...and the transport's real state, on the same entity (no runtime_data here,
# so there is no transport at all — which must read as "off", not as unpaired)
assert attrs["protocol_state"] == "off"
assert attrs["protocol_connected"] is False
assert attrs["protocol_ever_authenticated"] is False
assert attrs["power_on_route"] == "protocol"
assert attrs["protocol_current_app_seen"] is False
# The LIVE update paths our entity uses: HA's own metaclass turns the class-level
# ``_attr_*`` declarations into properties backed by ``__attr_*`` plus cache
# invalidation, so both assignments must take effect on the published state. A
# private attribute name would leave ``activity_list``/``current_activity`` frozen
# at their first value — silent in a unit test, wrong in a dashboard.
entity._attr_current_activity = "Netflix"
entity._attr_activity_list = ["Netflix", "TiviMate", "YouTube"]
assert entity.current_activity == "Netflix"
assert entity.activity_list == ["Netflix", "TiviMate", "YouTube"]
_entity_id, _state, attrs = _write_ha_state(entity, states)
assert attrs["current_activity"] == "Netflix"
assert attrs["activity_list"] == ["Netflix", "TiviMate", "YouTube"]
#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the
#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in.
_SCREEN_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
"Af+4BDXgAAAAAElFTkSuQmCC"
)
def test_the_screen_capture_entity_serves_png_bytes_under_real_ha():
"""t66: HA's OWN image pieces accept what the capture path produces.
The stub suite proves the logic; this proves the contract with real HA: the
real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image
proxy runs before it will serve bytes), and the real state write — whose
recorded state must be the capture TIMESTAMP and whose attributes must never
carry the image itself (the "never a state source" boundary, measured).
"""
_require_installed_homeassistant()
import base64
from homeassistant.components.image import valid_image_content_type
class _Poller:
connected = True
async def shell(self, cmd):
assert cmd == "screencap -p | base64"
return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode()
entity, states = _real_ha_entity(
"image",
"NvidiaShieldScreenImage",
options={"screen_capture_enabled": True},
entity_id="image.shield_screen_capture",
)
entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller())
data = asyncio.run(entity.async_image())
assert isinstance(data, bytes)
assert data.startswith(b"\x89PNG\r\n\x1a\n")
assert valid_image_content_type(entity.content_type) == "image/png"
written_id, state, attrs = _write_ha_state(entity, states)
assert written_id == "image.shield_screen_capture"
# HA's image state IS ``image_last_updated`` — the timestamp, never the bytes
assert state == entity.image_last_updated.isoformat()
assert attrs["capture_status"] == "ok"
assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values())
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
"""A new entity class must join the matrix above, or this test fails.
The sweep classifies entities from the AST; this keeps the real-HA coverage
in step with that classification instead of silently shrinking.
"""
_require_installed_homeassistant()
classified: set[str] = set()
for path in sorted(ha_api_drift.SHIELD_DIR.glob("*.py")):
classified |= ha_api_drift.entity_class_names(
ast.parse(path.read_text(encoding="utf-8"))
)
covered = {class_name for _module, class_name, _options, _entity_id in _REAL_HA_MATRIX}
uncovered = classified - covered - _INTERMEDIATE_BASES
assert uncovered == set(), (
f"entity classes never exercised against real HA: {sorted(uncovered)} — "
f"add them to _REAL_HA_MATRIX"
)
# ------------------------------------------------- t62: dead code and the floor
#
# Both defects pinned below shipped while every other gate was green:
# connection.py carried a duplicated copy of the label-drain body AFTER its
# `return cached` (valid Python, dead, invisible to this sweep), and the
# call-argument floor sat at 20 while the counts had moved to ~46, so deleting a
# flow-display API owner from HA_CALL_OWNERS did not trip it.
#: t62 F2: the shape that shipped — a bare docstring plus the whole drain left
#: after `return cached`, inside the same method. Line 6 is the dead statement.
UNREACHABLE_PRE_FIX = (
"class Sweep:\n"
" def labels(self, requests):\n"
" cached = {}\n"
" return cached\n"
"\n"
' """Start at most one label fetch per watchdog cycle."""\n'
" if not requests:\n"
" return\n"
" requests.discard(min(requests))\n"
)
def test_the_sweep_flags_the_pre_fix_unreachable_body(tmp_path):
"""t62 F2 red proof: the duplicated drain body after `return cached` is dead."""
(tmp_path / "dead_sweep.py").write_text(UNREACHABLE_PRE_FIX, encoding="utf-8")
findings = ha_api_drift.scan_unreachable(tmp_path)
assert [(finding.path, finding.kind) for finding in findings] == [
("dead_sweep.py", "unreachable-statement")
], findings
assert findings[0].lineno == 6 # the dead docstring, not the return
def test_every_terminal_statement_kind_is_flagged(tmp_path):
"""`return`, `raise`, `continue` and `break` all end a block (t62 F2)."""
(tmp_path / "terminals.py").write_text(
"def a():\n"
" return 1\n"
" x = 1\n"
"\n"
"\n"
"def b():\n"
" raise SystemExit(1)\n"
" y = 2\n"
"\n"
"\n"
"def c(items):\n"
" for item in items:\n"
" continue\n"
" z = 3\n"
" while True:\n"
" break\n"
" w = 4\n",
encoding="utf-8",
)
findings = ha_api_drift.scan_unreachable(tmp_path)
assert [finding.kind for finding in findings] == ["unreachable-statement"] * 4, findings
def test_the_production_tree_has_no_unreachable_statements():
"""The gate itself: dead code is a finding, so it cannot ship silently."""
assert ha_api_drift.scan_unreachable() == []
def test_the_unreachable_pass_walks_real_statement_blocks():
"""The floor proves "no findings" is not a pass that stopped parsing."""
report = ha_api_drift.scan()
assert report.statement_blocks >= ha_api_drift.FLOORS["statement_blocks"]
def test_removing_a_flow_display_owner_trips_the_call_argument_floor(monkeypatch):
"""t62: the floor must notice an API OWNER going away, not just a bad walk."""
_require_installed_homeassistant()
intact = ha_api_drift.scan()
assert intact.floor_violations() == []
reduced = dict(ha_api_drift.HA_CALL_OWNERS)
del reduced["async_show_menu"] # the t57 UNIT 0a owner: 6 checked arguments
monkeypatch.setattr(ha_api_drift, "HA_CALL_OWNERS", reduced)
fewer = ha_api_drift.scan()
assert fewer.call_args < intact.call_args
assert fewer.call_args < ha_api_drift.FLOORS["call_args"], (
f"removing async_show_menu left {fewer.call_args} checked arguments, "
f"still at or above the floor of {ha_api_drift.FLOORS['call_args']} — "
"the flow-display owners are not load-bearing"
)
assert any("call_args" in line for line in fewer.floor_violations()), (
fewer.floor_violations()
)
# --------------------------------------- t63: the media-browser root (real HA)
#
# The live report ("Browse media seems to spin and never finish loading") came
# from what async_browse_media HANDED BACK, not from the handler: our method
# returns with no awaits, so nothing inside it can hang. HA's handler only
# forwards the payload, which makes the defect visible on one path only:
#
# websocket_browse_media -> player.async_browse_media -> payload.as_dict()
#
# The frontend bundle is NOT installed here (no hass_frontend, and there is no
# browser to drive), so the rendered dialog itself cannot be exercised. What
# these tests prove is that backend path end to end, decorators included. The
# pre-fix payload carried ``media_content_id=None`` — a node shape no core
# integration ever serves, and the assertions below fail on it.
class _RecordingConnection:
"""The pieces of websocket_api.ActiveConnection the browse handler calls."""
def __init__(self):
self.results: list = []
self.messages: list = []
self.exceptions: list = []
def send_result(self, msg_id, result):
self.results.append((msg_id, result))
def send_message(self, message):
self.messages.append(message)
def async_handle_exception(self, msg, err):
self.exceptions.append(err)
def _wire_the_browser(entity):
"""Give the entity's own hass what websocket_browse_media walks.
``hass.data[DATA_COMPONENT].get_entity`` is how the handler finds the player,
and ``async_create_background_task`` is how ``@websocket_api.async_response``
schedules the real coroutine — both real shapes, so the DECORATED handler
runs unmodified.
"""
from homeassistant.components.media_player import DATA_COMPONENT
scheduled: list = []
def async_create_background_task(coro, name, *, eager_start=False):
task = asyncio.create_task(coro)
scheduled.append(task)
return task
entity.hass.data[DATA_COMPONENT] = SimpleNamespace(
get_entity=lambda entity_id: entity if entity_id == entity.entity_id else None
)
entity.hass.async_create_background_task = async_create_background_task
return scheduled
async def _browse_over_the_real_handler(entity, scheduled, *, content_id=None,
content_type=None):
"""Send the real websocket command and await the task it schedules."""
from homeassistant.components.media_player import websocket_browse_media
connection = _RecordingConnection()
msg = {"id": 7, "type": "media_player/browse_media", "entity_id": entity.entity_id}
if content_id is not None or content_type is not None:
msg["media_content_id"] = content_id
msg["media_content_type"] = content_type
websocket_browse_media(entity.hass, connection, msg) # schedules the coroutine
await asyncio.gather(*scheduled)
assert connection.exceptions == [], connection.exceptions
assert connection.messages == [], connection.messages
assert len(connection.results) == 1, connection.results
return connection.results[0][1]
async def test_the_real_websocket_handler_serves_a_string_id_root():
"""t63 regression: the browse root carries a real STRING id, not None.
Every core media_player platform serves a string id (a literal, a module
constant or ""), because the frontend keys the node it is loading on that id;
the live dialog re-requested the same node for ever with a null one.
"""
_require_installed_homeassistant()
entity, _states = _real_ha_entity(
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
)
scheduled = _wire_the_browser(entity)
payload = await _browse_over_the_real_handler(entity, scheduled)
assert isinstance(payload["media_content_id"], str)
assert payload["media_content_id"] == "root"
assert payload["media_content_type"] == "directory" # not MediaType.APPS
assert payload["children"] == []
assert payload["can_expand"] is False
assert payload["can_play"] is False
assert payload["media_class"] == "directory"
# the payload the frontend receives has to survive the websocket's own encode
assert json.loads(json.dumps(payload)) == payload
async def test_the_real_handler_answers_every_id_with_the_same_root():
"""Idempotent: the root id, None and a stale child id give ONE payload.
That is the property that stops a frontend bouncing between "loading" and
"loaded" for one id — browse(root_id) must be browse(None) in shape.
"""
_require_installed_homeassistant()
entity, _states = _real_ha_entity(
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
)
scheduled = _wire_the_browser(entity)
first = await _browse_over_the_real_handler(entity, scheduled)
root_id = first["media_content_id"]
for content_type, content_id in (
(None, None),
("apps", root_id),
("apps", "apps"),
("app", "ar.tvplayer.tv"),
):
repeat = await _browse_over_the_real_handler(
entity, scheduled, content_id=content_id, content_type=content_type
)
assert repeat == first