Files
goodwe-addon/goodwe_controller/test_haentities.py
T
glenn schrooyenandClaude Opus 5.5 15627c38a6
checks / unit (pull_request) Successful in 9m32s
infra-pin / fresh (pull_request) Successful in 1s
UX-13 P0: layout table, page<->options derivation, warning tier, entity list
The foundation for the new settings page. No visible UI change.

- app/uilayout.py: screen -> group -> part -> field for every option in
  config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display
  scale, step, entity kind, show conditions, Simple/wizard placement and
  "applies after a restart". Ranges stay in the schema, words in
  translations and the new app/ui_text/en.json.
- app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one
  inverter vs the list, solar rows, static_profile from simple prices, a
  year-stamped region preset for the levies figure, and an OPT-11 hook for
  arbitrage_enabled that changes nothing yet). Unchanged derivations write
  the stored values back, so the round trip is exact.
- validate.py: validate.Note, a warn/info tier. Each logged warning is also
  a note when a list is passed; config_notes() adds checks that need live
  data (missing entity, a kW sensor read as W, a limit below this month's
  peak). /config/validate returns them as "notes". No refusal changed: 261
  recorded validator calls, 0 differ (tools/ux13_validator_calls.py).
- GET ha/entities: Home Assistant's entity list for the picker, read through
  hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200
  with the old list marked old, or an empty list that says why.
- DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI
  code": keys may appear in uilayout.py and uimodel.py only, both tested.
- Tests: test_uilayout, test_uimodel (round trip over the recorded calls,
  the TEST-23 option files and config.yaml defaults), test_haentities, a
  warning-tier part in test_validate; test_messages scans notes and
  ui_text; test_pv and test_selfconfig part 7 widened on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 23:11:31 +02:00

210 lines
7.0 KiB
Python

"""UX-13 P0: the entity list for the picker. `python3 test_haentities.py`
`GET ha/entities` reads Home Assistant's states through the add-on's own
session (hass.states()) and must never fail the page: part 1 drives the real
HomeAssistant client against a local server that answers 502, garbage, the
wrong shape, nothing at all, or too slowly. Part 2 checks the 30 s cache and
what is served while Home Assistant is down. Part 3 serves the real route.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import json
import sys
import time
from aiohttp import ClientSession, web as aioweb
from app import hass as H, haentities as E, web
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
STATES = [
{"entity_id": "sensor.grid", "state": "812",
"attributes": {"friendly_name": "Grid power", "unit_of_measurement": "W",
"device_class": "power"}},
{"entity_id": "sensor.soc", "state": "54",
"attributes": {"friendly_name": "Battery level", "unit_of_measurement": "%",
"device_class": "battery"}},
{"entity_id": "number.setpoint", "state": "0", "attributes": {}},
{"entity_id": "sensor.pv_kw", "state": "1.2",
"attributes": {"unit_of_measurement": "kW"}},
{"no_entity_id": True},
]
async def serve(handler):
app = aioweb.Application()
app.router.add_get("/api/states", handler)
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
return runner, site._server.sockets[0].getsockname()[1]
async def read_with(handler, timeout_s=10):
runner, port = await serve(handler)
H.CORE_API, H.STATES_TIMEOUT_S = f"http://127.0.0.1:{port}/api", timeout_s
try:
async with ClientSession() as session:
t0 = time.monotonic()
got = await H.HomeAssistant(session, "token").states()
read_with.took = time.monotonic() - t0
return got
finally:
await runner.cleanup()
async def ok(_r):
return aioweb.json_response(STATES)
async def bad_gateway(_r):
return aioweb.Response(status=502)
async def garbage(_r):
return aioweb.Response(text="<html>", content_type="application/json")
async def not_a_list(_r):
return aioweb.json_response({"message": "hi"})
async def slow(_r):
await asyncio.sleep(2)
return aioweb.json_response(STATES)
async def closed_port():
H.CORE_API = "http://127.0.0.1:9/api"
async with ClientSession() as session:
return await H.HomeAssistant(session, "token").states()
print("1. hass.states() answers None, never an exception")
check("a normal answer is the state list", asyncio.run(read_with(ok)) == STATES)
for name, handler in (("502 while HA restarts", bad_gateway),
("a body that is not JSON", garbage),
("JSON that is not a list", not_a_list)):
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
check("an answer slower than the timeout -> None, at the timeout",
asyncio.run(read_with(slow, timeout_s=0.3)) is None
and read_with.took < 1.0)
check("no server at all -> None", asyncio.run(closed_port()) is None)
check("the timeout for the list is the same 10 s as every other read",
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
class FakeHass:
def __init__(self):
self.answer, self.reads = STATES, 0
async def states(self):
self.reads += 1
await asyncio.sleep(0.01)
return self.answer
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
print("\n2. the cache, and Home Assistant being down")
_h, _c = FakeHass(), Clock()
_cache = E.EntityCache(_h, clock=_c)
async def burst():
return await asyncio.gather(*(_cache.payload("power") for _ in range(5)))
_p = asyncio.run(burst())
check("five pickers opening at once cost one read of Home Assistant",
_h.reads == 1 and all(x == _p[0] for x in _p))
_ids = [r["entity_id"] for r in _p[0]["entities"]]
check("power entities come first, then the rest, and a broken state is left "
f"out ({_ids})", _ids == ["sensor.grid", "sensor.pv_kw",
"number.setpoint", "sensor.soc"]
and [r["match"] for r in _p[0]["entities"]] == [True, True, False, False])
check("each row carries id, name, state, unit and device class",
_p[0]["entities"][0] == {"entity_id": "sensor.grid", "name": "Grid power",
"state": "812", "unit": "W",
"device_class": "power", "match": True})
check("the kinds filter as the pickers need",
[r["entity_id"] for r in _p[0]["entities"]
if E.matches(r, "pct")] == ["sensor.soc"]
and [r["entity_id"] for r in _p[0]["entities"]
if E.matches(r, "number")] == ["number.setpoint"])
_c.t += 10
asyncio.run(_cache.payload())
check("within 30 s the cached list is served", _h.reads == 1)
check("peek() hands the validator the fresh list without a read",
set(_cache.peek()) == set(_ids) and _h.reads == 1)
_h.answer = None
_c.t += 30
_down = asyncio.run(_cache.payload())
check("Home Assistant down after a good read: the old list, marked old",
_h.reads == 2 and _down["available"] and _down["text"] == E.STALE_TEXT
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
check("...and peek() then gives nothing, so no warning rests on an old list",
_cache.peek() is None)
_h2 = FakeHass()
_h2.answer = None
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
check("Home Assistant down from the start: an empty list that says why",
_empty == {"available": False, "text": E.DOWN_TEXT, "entities": []})
print("\n3. the route")
class FakeController:
def __init__(self, hass):
self.hass, self.o = hass, {}
async def route(hass, query=""):
app = web.build_app(FakeController(hass), selfcfg=object())
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
try:
async with ClientSession() as s:
async with s.get(f"http://127.0.0.1:{port}/ha/entities{query}") as r:
return r.status, await r.json()
finally:
await runner.cleanup()
_st, _body = asyncio.run(route(FakeHass(), "?kind=pct"))
check("GET ha/entities?kind=pct answers 200 with the matching entity first",
_st == 200 and _body["entities"][0]["entity_id"] == "sensor.soc")
_st, _body = asyncio.run(route(_h2))
check("with Home Assistant down it still answers 200, with the reason",
_st == 200 and _body["available"] is False and _body["entities"] == [])
check("the words shown are short and plain (STYLE: one sentence)",
all(len(t.split()) <= 12 and t.count(".") == 1
for t in (E.DOWN_TEXT, E.STALE_TEXT)))
print(json.dumps(_body))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")