- 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>
253 lines
10 KiB
Python
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")
|