UX-08 2d: plain log lines, the rest of the status page, and DOCS
Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -289,20 +289,66 @@ print("\n2. no notification is worded outside app/messages.py")
|
||||
APP = HERE / "app"
|
||||
|
||||
|
||||
def render(node):
|
||||
"""A string expression as text, placeholders as {expr}; None otherwise."""
|
||||
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
|
||||
# setting is expanded to the label and key `messages.setting()` prints, so
|
||||
# `{S('max_w')}` counts as “Inverter rating” (max_w), three words, not one.
|
||||
SETTING_CALLS = ("S", "_S", "setting", "messages.setting")
|
||||
CONSTANTS = {} # module-level NAME = "text", per file, for keys
|
||||
|
||||
|
||||
def _key(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
return CONSTANTS.get(node.id)
|
||||
return None
|
||||
|
||||
|
||||
def expand_setting(node):
|
||||
"""The shown text of a placeholder that names a setting, else None."""
|
||||
if isinstance(node, ast.Call) and node.args:
|
||||
name = ast.unparse(node.func)
|
||||
if name in SETTING_CALLS:
|
||||
key = _key(node.args[0])
|
||||
if key not in messages.LABELS:
|
||||
return None
|
||||
unit = node.args[1] if len(node.args) > 1 else None
|
||||
plain = unit is None or (isinstance(unit, ast.Constant)
|
||||
and not unit.value)
|
||||
return messages.setting(key, "" if plain else "inverter-b")
|
||||
if name == "_labels" and isinstance(node.args[0], (ast.List, ast.Tuple)):
|
||||
keys = [_key(e) for e in node.args[0].elts]
|
||||
if all(k in messages.LABELS for k in keys):
|
||||
return " and ".join(messages.setting(k) for k in keys)
|
||||
if (isinstance(node, ast.Subscript)
|
||||
and ast.unparse(node.value) == "messages.LABELS"):
|
||||
key = _key(node.slice)
|
||||
return messages.LABELS.get(key) if key else None
|
||||
return None
|
||||
|
||||
|
||||
def render(node, expand=False):
|
||||
"""A string expression as text, placeholders as {expr}; None otherwise.
|
||||
`expand` shows setting placeholders as the page shows them."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
return "".join(v.value if isinstance(v, ast.Constant)
|
||||
else "{" + ast.unparse(v.value) + "}" for v in node.values)
|
||||
out = []
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.Constant):
|
||||
out.append(v.value)
|
||||
continue
|
||||
shown = expand_setting(v.value) if expand else None
|
||||
out.append(shown if shown is not None
|
||||
else "{" + ast.unparse(v.value) + "}")
|
||||
return "".join(out)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
a, b = render(node.left), render(node.right)
|
||||
a, b = render(node.left, expand), render(node.right, expand)
|
||||
if a is None and b is None:
|
||||
return None
|
||||
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
|
||||
if isinstance(node, ast.IfExp):
|
||||
a, b = render(node.body), render(node.orelse)
|
||||
a, b = render(node.body, expand), render(node.orelse, expand)
|
||||
if a is None and b is None:
|
||||
return None
|
||||
return (a or "") + " / " + (b or "")
|
||||
@@ -346,14 +392,14 @@ LIMITS = { # kind: (words, keys ok, entity ids ok)
|
||||
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
|
||||
|
||||
|
||||
def strings_in(node):
|
||||
def strings_in(node, expand=False):
|
||||
"""Top-level string expressions inside `node` (not their parts)."""
|
||||
t = render(node)
|
||||
t = render(node, expand)
|
||||
if t is not None:
|
||||
yield node, t
|
||||
return
|
||||
for child in ast.iter_child_nodes(node):
|
||||
yield from strings_in(child)
|
||||
yield from strings_in(child, expand)
|
||||
|
||||
|
||||
def collect():
|
||||
@@ -363,6 +409,12 @@ def collect():
|
||||
if f.name == "messages.py":
|
||||
continue
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
CONSTANTS.clear()
|
||||
CONSTANTS.update({t.id: n.value.value for n in tree.body
|
||||
if isinstance(n, ast.Assign)
|
||||
and isinstance(n.value, ast.Constant)
|
||||
and isinstance(n.value.value, str)
|
||||
for t in n.targets if isinstance(t, ast.Name)})
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Dict):
|
||||
for k, v in zip(node.keys, node.values):
|
||||
@@ -387,24 +439,26 @@ def collect():
|
||||
out.append(("log", f"{f.name}:{n.lineno}", t))
|
||||
elif re.search(r"problems\.(append|extend)$", name):
|
||||
for a in node.args:
|
||||
for n, t in strings_in(a):
|
||||
for n, t in strings_in(a, expand=True):
|
||||
if len(words(t)) > 1:
|
||||
out.append(("refusal", f"{f.name}:{n.lineno}", t))
|
||||
tr = TL.parse_translation_file(HERE / "translations" / "en.yaml")
|
||||
# UX-08 2d: the Dutch option texts are held to the same length.
|
||||
for lang in ("en", "nl"):
|
||||
tr = TL.parse_translation_file(HERE / "translations" / f"{lang}.yaml")
|
||||
|
||||
def walk(conf, path):
|
||||
for key, v in (conf or {}).items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if isinstance(v.get("name"), str):
|
||||
out.append(("option-name", f"en.yaml:{path}{key}",
|
||||
v["name"].split("·")[-1].strip()))
|
||||
if isinstance(v.get("description"), str):
|
||||
out.append(("option-desc", f"en.yaml:{path}{key}",
|
||||
v["description"]))
|
||||
if isinstance(v.get("fields"), dict):
|
||||
walk(v["fields"], f"{path}{key}.")
|
||||
walk(tr.get("configuration"), "")
|
||||
def walk(conf, path):
|
||||
for key, v in (conf or {}).items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if isinstance(v.get("name"), str):
|
||||
out.append(("option-name", f"{lang}.yaml:{path}{key}",
|
||||
v["name"].split("·")[-1].strip()))
|
||||
if isinstance(v.get("description"), str):
|
||||
out.append(("option-desc", f"{lang}.yaml:{path}{key}",
|
||||
v["description"]))
|
||||
if isinstance(v.get("fields"), dict):
|
||||
walk(v["fields"], f"{path}{key}.")
|
||||
walk(tr.get("configuration"), "")
|
||||
return out
|
||||
|
||||
|
||||
@@ -413,7 +467,7 @@ def problems_of(kind, text):
|
||||
p = []
|
||||
if len(words(text)) > lim:
|
||||
p.append(f"over {lim} words ({len(words(text))})")
|
||||
if kind != "log" and ";" in text: # logs are phase 2d
|
||||
if ";" in text:
|
||||
p.append("semicolon")
|
||||
return p + text_problems(text, keys=keys, entities=ents)
|
||||
|
||||
@@ -426,7 +480,7 @@ ALLOW_FILE = HERE / "ux08_allowlist.txt"
|
||||
# The allowlist may never grow. The test demands the list be exactly this long,
|
||||
# so deleting lines means lowering this number in the same change, and adding
|
||||
# one means raising it where a reviewer sees it.
|
||||
ALLOWLIST_MAX = 43
|
||||
ALLOWLIST_MAX = 0
|
||||
|
||||
|
||||
def read_allowlist(path):
|
||||
@@ -491,6 +545,30 @@ _own_banner = {"row_direction_trip", "row_site_direction_trip",
|
||||
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
|
||||
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
|
||||
and len(words(messages.banner_not_ready({"text": t}))) > 12]
|
||||
# The hint under the buttons (UX-08 2d): the last event in plain words, one
|
||||
# per key of messages.HINTS, held to the rule for a green row.
|
||||
for key, text in messages.hint_samples():
|
||||
p = row_problems(messages.GREEN, text)
|
||||
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
|
||||
_calls, _bare = 0, []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
if (isinstance(node, ast.Call)
|
||||
and ast.unparse(node.func).endswith("log_event")):
|
||||
_calls += 1
|
||||
h = next((k.value for k in node.keywords if k.arg == "hint"), None)
|
||||
if not (isinstance(h, ast.Call)
|
||||
and ast.unparse(h.func) in ("messages.hint", "hint")):
|
||||
_bare.append(f"{f.name}:{node.lineno}")
|
||||
check(f"every event the add-on records gives the page its plain words, from "
|
||||
f"messages.hint() ({_calls} calls, without: {_bare})",
|
||||
_calls >= 40 and not _bare)
|
||||
_raw = ("OUTPUT_CLAMP", "BUG SIGNAL", "FAILSAFE", "SIGN_DISAGREEMENT",
|
||||
"ALLOCATOR", "UNMET", "ARBITER", "PEAK GUARD")
|
||||
check("no hint carries the raw event codes the page used to show",
|
||||
not [t for _k, t in messages.hint_samples()
|
||||
if any(r in t for r in _raw)])
|
||||
|
||||
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
|
||||
f"(too long: {_long})", not _long)
|
||||
|
||||
@@ -555,6 +633,79 @@ check("...and a refusal quotes the label and then the key, so the config "
|
||||
"panel still files it under that setting",
|
||||
messages.setting("max_w", "inverter-b")
|
||||
== "“Inverter rating” for inverter-b (inverters.max_w)")
|
||||
# UX-08 2d: the refusals as the validator SHOWS them. The scan above expands
|
||||
# setting labels in a template, but a reason that carries a parser's error
|
||||
# (`{e}`) is only as long as that error, so the long ones found by the 2c test
|
||||
# are driven for real here and held to the same 30 words.
|
||||
from app import custom_formula as _cf # noqa: E402
|
||||
from app.validate import validate_startup_config # noqa: E402
|
||||
|
||||
_BASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000}
|
||||
_CARD = _cf._LEGS["price_formula"].example.replace("15.55", "15.05")
|
||||
_TABLE = ('{"periods":[{"name":"day","from":"07:00","to":"22:00","days":'
|
||||
'"mon-fri","eur_per_kwh":0.1},{"name":"night","from":"22:00","to":'
|
||||
'"07:00","days":"mon-fri","eur_per_kwh":0.08},{"name":"night",'
|
||||
'"from":"00:00","to":"24:00","days":"sat,sun","eur_per_kwh":0.08}]}')
|
||||
_ROSTER = {k: v for k, v in _BASE.items()
|
||||
if k not in ("setpoint_entity", "soc_entity", "batt_entity",
|
||||
"max_w")}
|
||||
_ROSTER["inverters"] = [
|
||||
dict(name=n, setpoint_entity=f"number.sp_{n[-1]}", max_w=w,
|
||||
soc_entity=f"sensor.soc_{n[-1]}", batt_entity=f"sensor.batt_{n[-1]}",
|
||||
bms_soc_floor=10)
|
||||
for n, w in (("inverter-a", 2000), ("inverter-b", 200))]
|
||||
_DRIVEN = {
|
||||
"formula card": dict(_BASE, price_provider="custom_formula",
|
||||
price_formula=_CARD),
|
||||
"formula not valid": dict(_BASE, price_provider="custom_formula",
|
||||
price_formula='{"index": "epex_be"} x'),
|
||||
"injection card": dict(_BASE, price_export_formula=_CARD),
|
||||
"roster thresholds": dict(_ROSTER, maintenance_charge_w=100,
|
||||
maintenance_discharge_w=100),
|
||||
"levies table and flat": dict(_BASE, price_taxes_profile=_TABLE,
|
||||
price_taxes_eur_kwh=0.1198),
|
||||
"feed and entities": dict(_BASE, price_provider="generic_dayahead",
|
||||
price_now_entity="sensor.now",
|
||||
price_avg_entity="sensor.avg"),
|
||||
"fixed tariff and entities": dict(_BASE, static_profile=_TABLE,
|
||||
price_now_entity="sensor.now",
|
||||
price_avg_entity="sensor.avg"),
|
||||
"fixed tariff not valid": dict(_BASE, static_profile='{"periods": []} x'),
|
||||
}
|
||||
_LEAKS = ("c_eur_per_kwh", "vat_pct", "its own card", "line 1 column",
|
||||
"None", "JSON", "one you do not use")
|
||||
_shown, _long, _leaky = [], [], []
|
||||
for _name, _o in _DRIVEN.items():
|
||||
_ps = validate_startup_config(_o, -5000, 5000)
|
||||
_shown.append((_name, len(_ps)))
|
||||
_long += [f"{_name}: {len(words(t))} words: {t}" for t in _ps
|
||||
if len(words(t)) > 30]
|
||||
_leaky += [f"{_name}: {t}" for t in _ps if any(x in t for x in _LEAKS)]
|
||||
check(f"every driven case is refused ({_shown})",
|
||||
all(n >= 1 for _n, n in _shown)
|
||||
and dict(_shown)["roster thresholds"] >= 2)
|
||||
check(f"...and every refusal, as SHOWN, is at most 30 words (too long: "
|
||||
f"{_long})", not _long)
|
||||
check(f"...and shows no parser text, file units or Python's None (found: "
|
||||
f"{_leaky})", not _leaky)
|
||||
|
||||
# The config panel files a reason beside every setting whose key it names, but
|
||||
# `inverters.max_w` names a setting of one inverter in the list, not the flat
|
||||
# `max_w` (UX-08 2d, from the 2c test).
|
||||
from app.configui import problems_by_field # noqa: E402
|
||||
_by, _un = problems_by_field(
|
||||
["“Inverter rating” for inverter-b (inverters.max_w) is 200 W.",
|
||||
"“Inverter rating” (max_w) is 0."], ["max_w", "inverters"])
|
||||
check("a roster refusal shows beside `inverters` only, and a flat one beside "
|
||||
f"`max_w` ({ {k: len(v) for k, v in _by.items()} })",
|
||||
_by.get("inverters") == [
|
||||
"“Inverter rating” for inverter-b (inverters.max_w) is 200 W."]
|
||||
and _by.get("max_w") == ["“Inverter rating” (max_w) is 0."]
|
||||
and not _un)
|
||||
|
||||
print(f"CI: UX-08 allowlist {len(allow)} entries "
|
||||
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
|
||||
for k in LIMITS))
|
||||
|
||||
Reference in New Issue
Block a user