|
| 1 | +"""Sensors dropped from a profile must be removed from the entity registry. |
| 2 | +
|
| 3 | +Removing a sensor from a profile's set stops it being created. It does not remove what |
| 4 | +earlier versions already registered, so the entity stays in Home Assistant showing |
| 5 | +`unavailable` — which reads as a broken sensor rather than one that was never meant to |
| 6 | +exist, and is arguably worse than the wrong value it replaced. |
| 7 | +
|
| 8 | +This cleanup has now failed twice, each time differently: |
| 9 | +
|
| 10 | + - v1.4.0 gated it on the inverter being reachable. `async_config_entry_first_refresh` |
| 11 | + seeds an empty `GrowattData()`, so the guard was never satisfied during setup and the |
| 12 | + block never ran at all (#362). |
| 13 | + - v1.5.3 dropped `dcdc_temp` from ~26 profiles, but the cleanup was a per-sensor block |
| 14 | + listing specific names, and the new removal never got one. The entities went |
| 15 | + unavailable instead of disappearing. |
| 16 | +
|
| 17 | +The fix for the second was a general rule: anything in SENSOR_DEFINITIONS that is not in |
| 18 | +the profile's sensor set is stale, because sensor.py creates exactly the intersection of |
| 19 | +those two and nothing else. These tests hold both properties. |
| 20 | +
|
| 21 | +Parsed from source: __init__.py imports Home Assistant, which the HA-free suite cannot |
| 22 | +load. |
| 23 | +""" |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import ast |
| 27 | +import re |
| 28 | +from pathlib import Path |
| 29 | + |
| 30 | +INIT = (Path(__file__).parent.parent / "custom_components" / "growatt_modbus" |
| 31 | + / "__init__.py") |
| 32 | +SOURCE = INIT.read_text(encoding="utf-8") |
| 33 | + |
| 34 | + |
| 35 | +def _setup_entry_source() -> str: |
| 36 | + for node in ast.walk(ast.parse(SOURCE)): |
| 37 | + if isinstance(node, ast.AsyncFunctionDef) and node.name == "async_setup_entry": |
| 38 | + return ast.get_source_segment(SOURCE, node) or "" |
| 39 | + raise AssertionError("async_setup_entry not found") |
| 40 | + |
| 41 | + |
| 42 | +def test_cleanup_is_driven_by_the_profile_sensor_set(): |
| 43 | + """A per-sensor list only cleans up the removals someone remembered to add to it.""" |
| 44 | + body = _setup_entry_source() |
| 45 | + assert "get_sensors_for_profile" in body, ( |
| 46 | + "stale-entity cleanup does not consult the profile's sensor set, so it can only " |
| 47 | + "remove sensors that were hard-coded into it by name" |
| 48 | + ) |
| 49 | + assert re.search(r"for sensor_key in SENSOR_DEFINITIONS", body), ( |
| 50 | + "cleanup does not iterate SENSOR_DEFINITIONS, so a dropped sensor with no " |
| 51 | + "dedicated block will linger in the registry as unavailable" |
| 52 | + ) |
| 53 | + |
| 54 | + |
| 55 | +def test_cleanup_actually_removes(): |
| 56 | + body = _setup_entry_source() |
| 57 | + assert "async_remove" in body, "cleanup never calls async_remove" |
| 58 | + |
| 59 | + |
| 60 | +def test_cleanup_is_not_gated_on_connectivity(): |
| 61 | + """The v1.4.0 failure. Profile membership is static — it needs no inverter and no |
| 62 | + poll, and requiring one means the cleanup never runs during setup, which is the only |
| 63 | + time it matters. |
| 64 | + """ |
| 65 | + body = _setup_entry_source() |
| 66 | + # Find the general cleanup block and check no connectivity guard wraps it. |
| 67 | + idx = body.find("for sensor_key in SENSOR_DEFINITIONS") |
| 68 | + assert idx != -1, "general cleanup block not found" |
| 69 | + window = body[max(0, idx - 900):idx] |
| 70 | + for guard in ("serial_number", "has_real_data", "last_update_success"): |
| 71 | + assert guard not in window, ( |
| 72 | + f"stale-entity cleanup appears gated on {guard!r}. coordinator.data is an " |
| 73 | + f"empty placeholder during setup, so such a guard stops the cleanup running " |
| 74 | + f"at all — this is the v1.4.0 regression (#362)." |
| 75 | + ) |
0 commit comments