test(ci): F4 — entity-contract pass in the drift sweep + real-HA state-write proof (t40)
The F1 blocker was invisible to every existing test: the stub harnesses call the
entity and replace ``async_write_ha_state``, so a non-property
``extra_state_attributes`` looked perfectly healthy. Two new gates close that
hole.
1. ``ha_api_drift.scan_entity_contracts()`` — a pure-AST pass (so it needs no
homeassistant, unlike passes 1-3) that walks every class inheriting from an
HA entity base, including our own intermediate ``NvidiaShieldSensorBase``, and
flags any HA-READ attribute (``extra_state_attributes``, ``available``,
``state``, ``native_value``, ``media_*``, …) declared as a plain method
instead of ``@property``. It is wired into ``scan()`` before the
homeassistant gate and exported for the fast suite, so ONE implementation
backs the sweep, the CLI red proof and tests/test_entity_contract.py (the t39
F1 lesson).
Red proof on the pre-fix tree:
media_player.py:828: entity-attribute-is-a-method:
NvidiaShieldMediaPlayer.extra_state_attributes is a plain method, but HA
reads it as an attribute (``attr |= self.<name>``) — declare it @property
…and HEAD is clean (13 modules, 60 imports, 35 call arguments, 7 awaited).
2. tests/test_ha_api_drift.py now builds each entity under REAL homeassistant
(real ``PlatformData``, real ``async_write_ha_state``) and asserts HA's own
``__async_calculate_state`` succeeds and that the media player's app_id/
app_name survive the merge — for the media player, both sensors, the remote
and the pairing button. A coverage test compares that matrix against the
sweep's classification, so a new entity class cannot quietly skip it.
Verified: 296 passed / 2 skipped (fast suite), 16 passed (drift suite under
Python 3.14 + homeassistant 2026.10.1), ruff clean; the entity-contract red
proof above reproduces exactly the reported line.
This commit is contained in:
1 parent
acc7bc3d01
commit
be3abae3e7
3 files changed
+460
-1
No files matched your search
@@ -12,9 +12,12 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import ha_api_drift
|
||||
import pytest
|
||||
@@ -206,3 +209,152 @@ def test_the_declared_python_floor_never_lags_homeassistant():
|
||||
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()),
|
||||
)
|
||||
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",
|
||||
),
|
||||
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
||||
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
||||
)
|
||||
|
||||
#: 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)
|
||||
|
||||
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
Reference in new issue
Block a user