Files
glenn schrooyenandClaude 1bd2ba1397
checks / unit (pull_request) Successful in 40m37s
infra-pin / fresh (pull_request) Successful in 2s
UX-44: contract label says every 15 minutes, not hourly
The settings page contract choice read "Dynamic, hourly" / "Dynamisch,
per uur". Belgian day-ahead prices are per 15 minutes since 2025-10-01.
Now "Dynamic, every 15 minutes" / "Dynamisch, per kwartier". One DOCS
line that called dynamic pricing "hourly pricing" now says dynamic.
test_ui_text.py part 7 fails if either label says hourly again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 05:26:32 +02:00

310 lines
13 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))
def missing(en, nl):
"""The keys only one language has."""
return (sorted(where("en", p) for p in en if p not in nl),
sorted(where("nl", p) for p in nl if p not in en))
def holes(en, nl):
"""The Dutch strings whose {placeholders} differ from the English."""
return 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]))))
_only_en, _only_nl = missing(_en, _nl)
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 = holes(_en, _nl)
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|commands?|"
r"charge level|ceiling|billed quarter|quarters?|banked|soft limit|"
r"open block|actuator|ESP32|master|meter source|circulating|"
r"sign disagreement|rail",
"nl": r"setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|"
r"ESP32|master",
}
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"))
check("a semicolon is caught",
problems("en", ("x",), "Saved; restart to apply"))
check("a 26-word description is caught, and 25 words pass",
problems("en", ("x", "description"), " ".join(["word"] * 26) + ".")
and not problems("en", ("x", "description"),
" ".join(["word"] * 25) + "."))
check("a 3-sentence line is caught",
problems("en", ("x",), "One. Two. Three."))
check("a key only one language has is caught, both ways",
missing({("a",): "x", ("b",): "y"}, {("a",): "x"}) == (["en.json:b"], [])
and missing({("a",): "x"}, {("a",): "x", ("c",): "z"})
== ([], ["nl.json:c"]))
check("a placeholder dropped or renamed in Dutch is caught",
holes({("a",): "Window {row}"}, {("a",): "Tijdvak"})
and holes({("a",): "{w} now"}, {("a",): "nu {d}"})
and not holes({("a",): "{a} {b}"}, {("a",): "{b} {a}"}))
# ============================================================================ #
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"}
def english(en, nl):
return 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(" .:,"))
_same = english(_en, _nl)
for x in _same:
print(f" ENGLISH {x}")
check(f"every Dutch string is Dutch ({len(_same)} still English)", not _same)
_spoiled = dict(_nl)
_spoiled[("settings", "savebar", "save")] = _en[("settings", "savebar",
"save")]
check("mutation: an English string left in nl.json is caught",
english(_en, _spoiled) == ["nl.json:settings.savebar.save = 'Save'"])
# ============================================================================ #
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("\n7. the dynamic contract choice says 15 minutes, never hourly (UX-44)")
# Belgian day-ahead prices are per 15 minutes since 2025-10-01.
HOURLY = re.compile(r"hourly|per hour|\bhour\b|per uur|\buur\b|uurprijs",
re.I)
_dyn = {"en": EN["choices"]["contract"]["dynamic"],
"nl": NL["choices"]["contract"]["dynamic"]}
check(f"the label reads 'Dynamic, every 15 minutes' / 'Dynamisch, per "
f"kwartier' ({_dyn})",
_dyn == {"en": "Dynamic, every 15 minutes",
"nl": "Dynamisch, per kwartier"})
check("neither language calls the dynamic contract hourly",
not any(HOURLY.search(t) for t in _dyn.values()))
check("mutation: the old labels are caught",
all(HOURLY.search(t) for t in ("Dynamic, hourly", "Dynamisch, per uur")))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")