- test_ui_text.py: key parity, placeholder parity, English-left-in-Dutch, semicolon, 25-word description and 2-sentence line each now fail on a spoiled input (the English-left check was not a real mutation). - Jargon lists now cover the whole 'Not' column of STYLE.md section 4, in English, with the Dutch equivalents. - nl.json: 'gewijzigd worden' where 'wijzigen' read as intransitive, 'Bezig met bewaren' for the saving state, 'Metingen kloppen niet'. - test_uimodel.py: an overlap that changes partners is named piece by piece (a surviving mutant of _tiling's partner check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
12 KiB
Python
294 lines
12 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()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
sys.exit(1)
|
|
print("all passed")
|