Files
goodwe-addon/goodwe_controller/test_ui_text.py
T
Claude dev agent 667b8e9145
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s
UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:32 +02:00

253 lines
10 KiB
Python

"""UX-13 P5: the page's words, English and Dutch, follow STYLE.md. `python3 test_ui_text.py`
app/ui_text/en.json and nl.json hold every word the Home, Batteries, Settings
and Setup screens show that is not an option label (those are
translations/*.yaml, test_translations.py) or a status row (app/messages.py,
test_messages.py). This check fails CI and names the offending string when:
1. the two files differ in keys, or a string's {placeholders} differ;
2. a string breaks STYLE.md: a name or label over 6 words, a description over
1 sentence of 25 words, any other line over 2 sentences, a semicolon, a
banned or jargon word (English and Dutch lists), an option key or entity id;
3. a string carries its own details (a link, a section number, a file name)
instead of leaving them to the page's DOCS link;
4. Home's sentences, rendered by app/homeview.py in each language, break
the Home rules of test_messages.py part 8 (checked there too);
5. a Dutch string is left in English, outside the short list of words that
are the same in both;
6. the English words that mirror app/messages.py drift from it.
Each rule is shown to catch a spoiled input (habit 1 of MUTATION.md).
"""
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
from style_rules import (PLACEHOLDER, sentences, text_problems, # noqa: E402
title_problems, words)
from app import homeview, messages, uitext # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def load(lang):
with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
encoding="utf-8") as f:
return json.load(f)
EN, NL = load("en"), load("nl")
def leaves(node, path=()):
"""(path, text) for every string, lists included (the month names)."""
if isinstance(node, dict):
for k, v in node.items():
yield from leaves(v, path + (k,))
elif isinstance(node, list):
for i, v in enumerate(node):
yield from leaves(v, path + (i,))
else:
yield path, node
def where(lang, path):
return f"{lang}.json:" + ".".join(str(p) for p in path)
# ============================================================================ #
print("1. English and Dutch have the same keys and placeholders")
_en, _nl = dict(leaves(EN)), dict(leaves(NL))
_only_en = sorted(where("en", p) for p in _en if p not in _nl)
_only_nl = sorted(where("nl", p) for p in _nl if p not in _en)
for x in _only_en + _only_nl:
print(f" MISSING in the other language: {x}")
check(f"every one of the {len(_en)} English strings has a Dutch one, and no "
"Dutch string is extra", not _only_en and not _only_nl and len(_en) > 300)
_holes = sorted(
f"{where('nl', p)}: {sorted(PLACEHOLDER.findall(str(_en[p])))} vs "
f"{sorted(PLACEHOLDER.findall(str(t)))}"
for p, t in _nl.items() if p in _en
and sorted(PLACEHOLDER.findall(str(t)))
!= sorted(PLACEHOLDER.findall(str(_en[p]))))
for x in _holes:
print(f" PLACEHOLDERS differ: {x}")
check("every Dutch string fills the same {placeholders} as its English one",
not _holes)
# ============================================================================ #
print("\n2. every string follows STYLE.md, in both languages")
# STYLE.md section 4, the "Not" column, plus the Dutch words for the same
# things (translations/nl.yaml keeps "setpoint" in two option names, which the
# page never shows). Matched as whole words, case-insensitive.
JARGON = {
"en": r"controllers?|control loop|setpoints?|SoC|state of charge|BMS|PV|"
r"failsafe|trip(?:ped)?|parked|discharg\w*|P1 feed|quarter-hour|"
r"payload|endpoint|JSON|API|timeout|boolean|config",
"nl": r"setpoints?|SoC|BMS|PV|failsafe|regelaar|regellus|"
r"latch|ontlad\w*|payload|endpoint|JSON|API|time-?out|boolean|"
r"config|kwartuur\w*",
}
JARGON = {k: re.compile(r"\b(?:" + v + r")\b", re.I) for k, v in JARGON.items()}
# Details belong behind the page's DOCS link, never in the string itself.
DETAILS = re.compile(r"https?:|www\.|DOCS|documentation, section|§|"
r"\bsee section\b|\bzie sectie\b", re.I)
NAME_WORDS, DESC_WORDS, LINE_SENTENCES = 6, 25, 2
def kind_of(path):
if path[0] == "home_words":
return "template" # part 4 renders and checks these
return "description" if path[-1] == "description" else "name"
def problems(lang, path, text):
kind = kind_of(path)
out = []
if not isinstance(text, str) or not text.strip():
return ["empty"]
if kind == "name" and len(words(text)) > NAME_WORDS:
out.append(f"name over {NAME_WORDS} words ({len(words(text))})")
if kind == "name" and sentences(text) > LINE_SENTENCES:
out.append(f"over {LINE_SENTENCES} sentences ({sentences(text)})")
if kind == "description":
if sentences(text) > 1:
out.append(f"description over 1 sentence ({sentences(text)})")
if len(words(text)) > DESC_WORDS:
out.append(f"description over {DESC_WORDS} words "
f"({len(words(text))})")
if ";" in text:
out.append("semicolon")
hit = JARGON[lang].search(PLACEHOLDER.sub("X", text))
if hit:
out.append(f"jargon: {hit.group(0)!r}")
if DETAILS.search(text):
out.append(f"details belong behind the DOCS link: "
f"{DETAILS.search(text).group(0)!r}")
# The picker's own help names "entity id": the one place it is the word.
ok_entity = path[:2] == ("settings", "picker")
return out + [p for p in text_problems(text, keys=False, entities=False)
if not (ok_entity and p.startswith("entity id"))]
_bad = []
for lang, data in (("en", EN), ("nl", NL)):
for path, text in leaves(data):
for p in problems(lang, path, text):
_bad.append(f"{where(lang, path)}: {p} :: {text!r}")
for x in _bad:
print(f" STYLE {x}")
check(f"every string in en.json and nl.json follows STYLE.md "
f"({len(_en) + len(_nl)} strings, {len(_bad)} breaking it)", not _bad)
# ============================================================================ #
print("\n3. the rule catches a spoiled input")
check("a 7-word name is caught",
problems("en", ("fields", "x", "name"),
"One two three four five six seven"))
check("a 2-sentence description is caught",
problems("nl", ("fields", "x", "description"), "Eén zin. Twee zinnen."))
check("jargon is caught in English and in Dutch",
problems("en", ("x",), "Setpoint sent") and
problems("nl", ("x",), "De SoC is laag"))
check("a link or section in a string is caught",
problems("en", ("x", "description"),
"See https://example.org for the rest."))
check("a snake_case option key is caught",
problems("nl", ("x",), "Zet peak_cap_w hoger"))
# ============================================================================ #
print("\n4. Home's sentences, rendered in each language (as test_messages 8)")
PLAN_WORDS, PLAN_SENTENCES = 12, 2
def home_problems(kind, text):
if "{" in text or "}" in text:
return ["a placeholder was not filled"]
if kind == "title":
return title_problems(text)
out = text_problems(text, keys=False, entities=False)
if kind == "line":
if sentences(text) > 1 or len(words(text)) > 25:
out.append("line over 1 sentence of 25 words")
return out
if sentences(text) > PLAN_SENTENCES:
out.append(f"plan line over {PLAN_SENTENCES} sentences")
out += [f"plan sentence over {PLAN_WORDS} words: {x!r}"
for x in re.split(r"(?<=[.!?])\s+", text)
if len(words(x)) > PLAN_WORDS]
return out
for lang in ("en", "nl"):
_samples = homeview.text_samples(lang)
_hb = [f"{lang} {k}: {p} :: {t!r}" for k, t in _samples
for p in home_problems(k, t)]
_hb += [f"{lang}: jargon {JARGON[lang].search(t).group(0)!r} :: {t!r}"
for _k, t in _samples if JARGON[lang].search(t)]
for x in _hb:
print(f" HOME {x}")
check(f"{lang}: every Home sentence follows its rule "
f"({len(_samples)} rendered, {len(_hb)} breaking it)",
len(_samples) > 80 and not _hb)
_en_s = [t for _k, t in homeview.text_samples("en")]
_nl_s = [t for _k, t in homeview.text_samples("nl")]
check("Home in Dutch is not Home in English: most sentences differ, and "
"numbers use a decimal comma",
sum(a != b for a, b in zip(_en_s, _nl_s)) > 0.9 * len(_en_s)
and "2,5 kW" in homeview.text_samples("nl").__repr__()
and "2.5 kW" in _en_s.__repr__())
check("the language resets after a Dutch build, so English callers stay "
"English", homeview.pill("running") == EN["home_words"]["pill"]["running"])
# ============================================================================ #
print("\n5. no Dutch string is left in English")
# Words that are the same in both, or names. Anything else equal is untranslated.
SAME = {"Meter", "Timing", "Support", "Details", "Alarm", "Info", "per kWh",
"{hour}:00 UTC", "Flanders, Fluvius Antwerpen", "Modus", "Status"}
_same = sorted(f"{where('nl', p)} = {t!r}" for p, t in _nl.items()
if p in _en and t == _en[p] and t not in SAME
and PLACEHOLDER.sub("", t).strip(" .:,"))
for x in _same:
print(f" ENGLISH {x}")
check(f"every Dutch string is Dutch ({len(_same)} still English)", not _same)
check("mutation: an English string in nl.json is caught",
"Save" != NL["settings"]["savebar"]["save"] and "Save" not in SAME)
# ============================================================================ #
print("\n6. the words that mirror app/messages.py")
check("the setup-only banner on Home is messages.banner_setup_only()",
EN["home"]["setup_only"]["name"] == messages.banner_setup_only())
_mirror = {k: (EN["home_words"]["attn"][k], v)
for k, v in homeview.ATTN_FROM_MESSAGES.items()}
_drift = {k: v for k, v in _mirror.items() if v[0] != v[1]}
check(f"five Home titles are the notification's own title ({_drift})",
len(_mirror) == 5 and not _drift)
check("app/uitext.py falls back to English per key and reads nl-BE as nl",
uitext.lang_of("nl-BE") == "nl" and uitext.lang_of("fr") == "en"
and uitext.words("nl")["home"]["tabs"]["home"]
== NL["home"]["tabs"]["home"]
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
== {"a": {"b": 3, "c": 2}})
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")