FreshHarvestActions inherited from itself to graft on basket switching, which is legal Python and a trap. BasketMixin now sits above it and is inherited normally. The four control platforms each carried an identical private _fire; it moves to the base entity as fire_action. Rewriting those call sites mechanically broke two of them - select fired 'donate' instead of 'change_basket', and button lost an argument entirely, a TypeError reachable only by pressing it. Both fixed, and two tests now assert call arity and that no class inherits from itself, because neither fault is reachable from a unit test.
124 lines
4.5 KiB
Python
124 lines
4.5 KiB
Python
"""Every entity's translation_key must exist, and nothing may be orphaned.
|
|
|
|
These parse the platform sources with `ast` rather than importing them, so the
|
|
suite still runs without Home Assistant installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
COMPONENT = Path(__file__).parent.parent / "custom_components" / "freshharvest"
|
|
PLATFORMS = {
|
|
"sensor": "sensor.py",
|
|
"binary_sensor": "binary_sensor.py",
|
|
"switch": "switch.py",
|
|
"button": "button.py",
|
|
"select": "select.py",
|
|
}
|
|
|
|
|
|
def declared_keys(filename: str) -> set[str]:
|
|
"""Collect every `translation_key="..."` literal in a module."""
|
|
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
|
|
return {
|
|
node.value.value
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.keyword)
|
|
and node.arg == "translation_key"
|
|
and isinstance(node.value, ast.Constant)
|
|
and isinstance(node.value.value, str)
|
|
}
|
|
|
|
|
|
def attr_keys(filename: str) -> set[str]:
|
|
"""Collect `_attr_translation_key = "..."` assignments (entities without a
|
|
description object declare their name this way)."""
|
|
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
|
|
return {
|
|
node.value.value
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Assign)
|
|
and any(
|
|
getattr(t, "id", getattr(t, "attr", None)) == "_attr_translation_key"
|
|
for t in node.targets
|
|
)
|
|
and isinstance(node.value, ast.Constant)
|
|
}
|
|
|
|
|
|
@pytest.fixture(name="strings")
|
|
def strings_fixture() -> dict:
|
|
return json.loads((COMPONENT / "strings.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_en_matches_strings(strings):
|
|
"""Custom integrations read translations/en.json; it must not drift."""
|
|
english = json.loads(
|
|
(COMPONENT / "translations" / "en.json").read_text(encoding="utf-8")
|
|
)
|
|
assert english == strings
|
|
|
|
|
|
@pytest.mark.parametrize("platform,filename", PLATFORMS.items())
|
|
def test_every_entity_has_a_name(strings, platform, filename):
|
|
missing = declared_keys(filename) - set(strings["entity"][platform])
|
|
assert not missing, f"{platform} keys with no translation: {sorted(missing)}"
|
|
|
|
|
|
@pytest.mark.parametrize("platform,filename", PLATFORMS.items())
|
|
def test_no_orphaned_translations(strings, platform, filename):
|
|
orphans = set(strings["entity"][platform]) - declared_keys(filename)
|
|
assert not orphans, f"{platform} translations with no entity: {sorted(orphans)}"
|
|
|
|
|
|
def test_manifest_is_well_formed():
|
|
manifest = json.loads((COMPONENT / "manifest.json").read_text(encoding="utf-8"))
|
|
for key in ("domain", "name", "version", "documentation", "issue_tracker"):
|
|
assert manifest.get(key), f"manifest missing {key}"
|
|
assert manifest["domain"] == "freshharvest"
|
|
# A private forge URL would be unreachable for anyone installing this.
|
|
for key in ("documentation", "issue_tracker"):
|
|
assert manifest[key].startswith("https://github.com/"), (
|
|
f"{key} must be a public URL, got {manifest[key]}"
|
|
)
|
|
|
|
|
|
def test_todo_entity_is_named(strings):
|
|
"""todo.py declares its name via _attr_translation_key, not a description."""
|
|
assert attr_keys("todo.py") == set(strings["entity"]["todo"])
|
|
|
|
|
|
def test_fire_action_call_sites_are_well_formed():
|
|
"""Every fire_action call passes (action, ok, target, detail).
|
|
|
|
A refactor that moved this helper onto the base entity rewrote the call
|
|
sites mechanically and left one with three arguments — a TypeError that
|
|
only fires when a user presses the button, which no unit test reaches.
|
|
"""
|
|
bad = []
|
|
for path in COMPONENT.glob("*.py"):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
for node in ast.walk(tree):
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and getattr(node.func, "attr", None) == "fire_action"
|
|
and len(node.args) != 4
|
|
):
|
|
bad.append(f"{path.name}:{node.lineno} takes {len(node.args)}")
|
|
assert not bad, f"malformed fire_action calls: {bad}"
|
|
|
|
|
|
def test_no_class_inherits_from_itself():
|
|
"""`class X(X, Mixin)` is legal Python and a maintenance trap; it was here."""
|
|
for path in COMPONENT.glob("*.py"):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
names = {getattr(b, "id", None) for b in node.bases}
|
|
assert node.name not in names, f"{path.name}: {node.name} inherits itself"
|