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
107 lines
3.7 KiB
Python
107 lines
3.7 KiB
Python
"""UX-13: record every `validate_startup_config()` call the test suite makes,
|
|
and compare two validators over those calls.
|
|
|
|
python3 tools/ux13_validator_calls.py record OUT.jsonl.gz
|
|
python3 tools/ux13_validator_calls.py compare OLD_APP_PARENT OUT.jsonl.gz
|
|
|
|
`record` runs each test_*.py with the validator wrapped (in this directory, so
|
|
run it where the suite runs: the CI image). `compare` runs the recorded calls
|
|
through the validator under OLD_APP_PARENT (a directory holding an `app/`,
|
|
e.g. from `git archive <base> goodwe_controller/app`) and through this tree's,
|
|
and prints every call whose refusals differ. P0 of UX-13 used it to show that
|
|
adding the warning tier changed no refusal. scenarios/ux13/ holds the record
|
|
test_uimodel.py round-trips.
|
|
"""
|
|
|
|
import gzip
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
_WRAP = r"""
|
|
import json, runpy, sys
|
|
sys.path.insert(0, ".")
|
|
from app import validate
|
|
real = validate.validate_startup_config
|
|
out = open(sys.argv[1], "a")
|
|
def rec(opts, dev_min=None, dev_max=None, *a, **kw):
|
|
try:
|
|
out.write(json.dumps({"opts": opts, "dev_min": dev_min,
|
|
"dev_max": dev_max}) + "\n")
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return real(opts, dev_min, dev_max, *a, **kw)
|
|
validate.validate_startup_config = rec
|
|
sys.argv = sys.argv[2:]
|
|
try:
|
|
runpy.run_path(sys.argv[0], run_name="__main__")
|
|
except SystemExit:
|
|
pass
|
|
"""
|
|
|
|
|
|
def record(out_path):
|
|
tmp = out_path + ".part"
|
|
open(tmp, "w").close()
|
|
rows, seen = [], set()
|
|
for name in sorted(f for f in os.listdir(HERE)
|
|
if f.startswith("test_") and f.endswith(".py")):
|
|
before = sum(1 for _ in open(tmp))
|
|
subprocess.run([sys.executable, "-B", "-c", _WRAP, tmp, name],
|
|
cwd=HERE, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL)
|
|
with open(tmp) as fh:
|
|
for line in list(fh)[before:]:
|
|
call = json.loads(line)
|
|
key = json.dumps(call, sort_keys=True)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
rows.append(dict(call, source=name))
|
|
os.remove(tmp)
|
|
with gzip.open(out_path, "wt") as fh:
|
|
for row in rows:
|
|
fh.write(json.dumps(row, sort_keys=True) + "\n")
|
|
print(f"{len(rows)} distinct validator calls -> {out_path}")
|
|
|
|
|
|
def _run(app_parent, path):
|
|
code = r"""
|
|
import gzip, json, logging, sys
|
|
logging.disable(logging.CRITICAL)
|
|
sys.path.insert(0, sys.argv[1])
|
|
from app.validate import validate_startup_config as v
|
|
out = []
|
|
for line in gzip.open(sys.argv[2], "rt"):
|
|
c = json.loads(line)
|
|
try:
|
|
out.append(v(c["opts"], c["dev_min"], c["dev_max"]))
|
|
except Exception as e:
|
|
out.append("raised " + type(e).__name__)
|
|
print(json.dumps(out))
|
|
"""
|
|
got = subprocess.run([sys.executable, "-B", "-c", code, app_parent, path],
|
|
capture_output=True, text=True, check=True)
|
|
return json.loads(got.stdout)
|
|
|
|
|
|
def compare(old_parent, path):
|
|
old, new = _run(old_parent, path), _run(HERE, path)
|
|
differ = [i for i, (a, b) in enumerate(zip(old, new)) if a != b]
|
|
print(f"{len(old)} calls, {sum(1 for r in old if r)} refused by the old "
|
|
f"validator, {len(differ)} with different refusals")
|
|
for i in differ:
|
|
print(f" call {i}:\n old {old[i]}\n new {new[i]}")
|
|
return 1 if differ else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) == 3 and sys.argv[1] == "record":
|
|
record(sys.argv[2])
|
|
elif len(sys.argv) == 4 and sys.argv[1] == "compare":
|
|
sys.exit(compare(sys.argv[2], sys.argv[3]))
|
|
else:
|
|
sys.exit(__doc__)
|