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:
James Mackay committed 2026-10-11 06:02:22 +01:00
1 parent acc7bc3d01
commit be3abae3e7
3 files changed
+460 -1

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
"""Entity-contract shape checks (t40) — the defect class stubs cannot see.
The stub suites drive entities through hand-written fakes and replace
``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
perfectly healthy there while a live install raises
``TypeError: 'method' object is not iterable`` on every state write and the
entity never renders.
The check lives in ``tests/ha_api_drift.py`` (``scan_entity_contracts``) so this
suite and the drift sweep share ONE implementation — and the CI drift job, which
runs against a real Home Assistant, gates the same code path.
"""
import ast
from pathlib import Path
import ha_api_drift
from conftest import _SHIELD_DIR
_PLAIN_DEF = " def extra_state_attributes(self) -> dict:"
#: The t40 shape: HA reads ``extra_state_attributes`` as an attribute
#: (``attr |= self.extra_state_attributes``), so a method here is a crash.
_PRE_FIX = f'''
from homeassistant.components.media_player import MediaPlayerEntity
class Player(MediaPlayerEntity):
{_PLAIN_DEF}
return {{"app_id": "x"}}
'''
_FIXED = f'''
from homeassistant.components.media_player import MediaPlayerEntity
class Player(MediaPlayerEntity):
@property
{_PLAIN_DEF}
return {{"app_id": "x"}}
'''
_INHERITED_BASE = '''
from homeassistant.components.sensor import SensorEntity
class Base(SensorEntity):
"""Our own intermediate base — subclasses are entities too."""
class Child(Base):
def native_value(self):
return "x"
'''
def _findings(tmp_path, source: str):
(tmp_path / "module.py").write_text(source, encoding="utf-8")
return ha_api_drift.scan_entity_contracts(tmp_path)
def test_no_entity_attribute_in_the_tree_is_a_plain_method():
"""t40: this is the assertion that fails on the pre-fix media_player.py."""
assert ha_api_drift.scan_entity_contracts() == []
def test_the_check_flags_the_pre_fix_shape(tmp_path):
expected_line = _PRE_FIX.splitlines().index(_PLAIN_DEF) + 1
findings = _findings(tmp_path, _PRE_FIX)
assert [finding.kind for finding in findings] == ["entity-attribute-is-a-method"]
assert findings[0].lineno == expected_line
assert "Player.extra_state_attributes" in findings[0].detail
assert "@property" in findings[0].detail
def test_the_check_accepts_the_property_form(tmp_path):
assert _findings(tmp_path, _FIXED) == []
def test_the_check_follows_our_own_intermediate_bases(tmp_path):
"""NvidiaShieldSensorBase-style bases must not hide their subclasses."""
findings = _findings(tmp_path, _INHERITED_BASE)
assert [finding.detail.split()[0] for finding in findings] == ["Child.native_value"]
def test_the_tree_classification_is_not_vacuous():
"""A classifier that sees no entities would pass every check above."""
classified: set[str] = set()
for path in sorted(_SHIELD_DIR.glob("*.py")):
classified |= ha_api_drift.entity_class_names(ast.parse(path.read_text(encoding="utf-8")))
assert {
"NvidiaShieldMediaPlayer",
"NvidiaShieldCurrentAppSensor",
"NvidiaShieldNowPlayingSensor",
"NvidiaShieldRemoteEntity",
"NvidiaShieldPairAdbButton",
} <= classified
def test_the_sweep_reports_the_same_findings_as_this_check(tmp_path):
"""One implementation: the sweep's report carries the contract pass too."""
(tmp_path / "module.py").write_text(_PRE_FIX, encoding="utf-8")
report = ha_api_drift.scan(tmp_path)
kinds = [finding.kind for finding in report.findings]
# The contract pass runs FIRST (pure AST). Without homeassistant installed the
# sweep then adds "cannot judge the API surface" — that path is covered in
# tests/test_ha_api_drift.py.
assert kinds[0] == "entity-attribute-is-a-method"
assert "no-homeassistant" in kinds
def test_the_check_ignores_missing_paths_instead_of_raising():
"""scan() owns the bad-path finding; the pass just skips."""
assert ha_api_drift.scan_entity_contracts(Path("/nonexistent-does-not-exist")) == []