server-up/tests/test_i18n.py
Ramon b6319e8fdd
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m4s
v0.8.82-beta - "Poort {port} is vrij" stond er letterlijk
- De poortcontrole werd aangesloten op vertaalsleutels die er al waren, en twee
  daarvan verwachten een plaatshouder die ik niet meegaf: port_free wil {port},
  port_in_use wil {port} en {by}
- Nieuwe test die elke t('sleutel', {...}) in de interface vergelijkt met de
  plaatshouders in de Nederlandse tekst, in beide richtingen en ook voor
  aanroepen zonder variabelen. Die laatste toevoeging was nodig: de eerste
  versie van de test liet juist de fout passeren waar hij voor gemaakt was
- De test kent nu ook de verkorte objectnotatie van JavaScript ({name} betekent
  {name: name}); zonder dat meldde hij vier bestaande aanroepen ten onrechte

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-02 12:57:14 +02:00

213 lines
8.2 KiB
Python

"""Vertalingen: nl en en moeten dezelfde sleutels hebben, en de UI mag geen
sleutels gebruiken die nergens gedefinieerd zijn.
Zonder deze test loopt de vertaling stilletjes scheef zodra iemand een scherm
toevoegt — precies wat er bij de auth-, netwerk- en update-schermen gebeurde.
"""
import json
import re
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent / "server-up"
VERTALINGEN = WORTEL / "translations"
TEMPLATE = WORTEL / "templates" / "index.html"
def _laad(code: str) -> dict:
data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
return {k: v for k, v in data.items() if not k.startswith("_")}
def test_nl_en_en_hebben_dezelfde_sleutels():
nl, en = _laad("nl"), _laad("en")
alleen_nl = sorted(set(nl) - set(en))
alleen_en = sorted(set(en) - set(nl))
assert not alleen_nl, f"Ontbreekt in en.json: {alleen_nl}"
assert not alleen_en, f"Ontbreekt in nl.json: {alleen_en}"
@pytest.mark.parametrize("code", ["nl", "en"])
def test_geen_lege_vertalingen(code):
leeg = [k for k, v in _laad(code).items() if not str(v).strip()]
assert not leeg, f"Lege waarden in {code}.json: {leeg}"
@pytest.mark.parametrize("code", ["nl", "en"])
def test_meta_is_compleet(code):
data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
meta = data.get("_meta", {})
assert meta.get("code") == code
assert meta.get("name")
def test_plaatshouders_komen_overeen():
"""`{user}` in het Nederlands moet ook in het Engels staan, anders valt er
bij het vertalen een variabele weg."""
nl, en = _laad("nl"), _laad("en")
for sleutel, tekst in nl.items():
vars_nl = set(re.findall(r"\{(\w+)\}", str(tekst)))
vars_en = set(re.findall(r"\{(\w+)\}", str(en.get(sleutel, ""))))
assert vars_nl == vars_en, f"{sleutel}: nl={vars_nl} en={vars_en}"
def test_ui_gebruikt_alleen_bestaande_sleutels():
regels = TEMPLATE.read_text(encoding="utf-8").splitlines()
# Commentaarregels overslaan: die noemen t('key') als voorbeeld.
html = "\n".join(r for r in regels if not r.lstrip().startswith(("//", "*", "/*")))
# Alleen letterlijke sleutels: `t('foo')` en `t('foo', {...})`. Dynamisch
# samengestelde sleutels zoals t('role_'+r) vallen hierbuiten en worden
# afzonderlijk gecontroleerd in test_dynamische_sleutels_bestaan.
gebruikt = set(re.findall(r"\bt\('([a-z0-9_]+)'\s*[,)]", html))
bekend = set(_laad("nl"))
onbekend = sorted(gebruikt - bekend)
assert not onbekend, f"t() verwijst naar niet-bestaande sleutels: {onbekend}"
def test_dynamische_sleutels_bestaan():
"""Sleutels die de UI opbouwt uit een voorvoegsel plus een waarde."""
nl = _laad("nl")
for rol in ("admin", "operator", "viewer"):
assert f"role_{rol}" in nl
for plan in ("off", "daily", "weekly"):
assert f"backup_schedule_{plan}" in nl
def test_nieuwe_schermen_zijn_vertaald():
"""Steekproef op de schermen die in v0.5.x zijn toegevoegd."""
nl = _laad("nl")
for sleutel in ("login", "setup_title", "security", "auth_mode",
"trusted_proxies", "networks", "own_ip_hint", "channel",
"update_now", "rollback", "port_taken", "loading"):
assert sleutel in nl, f"vertaalsleutel ontbreekt: {sleutel}"
def test_i18n_module_leest_beide_talen(env):
from core import i18n
i18n.load()
codes = {t["code"] for t in i18n.available()}
assert {"nl", "en"} <= codes
# strings() valt terug op het Engels voor ontbrekende sleutels.
assert i18n.strings("nl")["login"]
assert i18n.strings("en")["login"]
def test_elke_auditcategorie_heeft_een_nederlandse_naam():
"""Het auditlog toonde ruwe id's als 'store' en 'reconfigure'.
Die worden nu vertaald met `audit_cat_<x>` en `audit_act_<x>`. Voegt iemand
een nieuwe `audit.log(...)` toe zonder vertaling, dan staat er weer een ruwe
id in beeld — vandaar dat deze test de broncode afloopt.
"""
import re
nl = _laad("nl")
bron = ""
for pad in WORTEL.rglob("*.py"):
bron += pad.read_text(encoding="utf-8")
# audit.log("categorie", "actie", ...) — alleen de letterlijke waarden;
# aanroepen met een variabele (containers/stacks) staan hieronder apart.
paren = re.findall(r'audit\.log\(\s*"([a-z0-9_]+)"\s*,\s*"([a-z0-9_]+)"', bron)
assert paren, "geen audit.log-aanroepen gevonden — is de vorm veranderd?"
ontbreekt = []
for cat, act in set(paren):
if f"audit_cat_{cat}" not in nl:
ontbreekt.append(f"audit_cat_{cat}")
if f"audit_act_{act}" not in nl:
ontbreekt.append(f"audit_act_{act}")
# De acties die als variabele worden meegegeven: stack- en containerknoppen.
for act in ("start", "stop", "restart", "up", "down", "update", "backup"):
if f"audit_act_{act}" not in nl:
ontbreekt.append(f"audit_act_{act}")
assert not ontbreekt, f"geen vertaling voor: {sorted(set(ontbreekt))}"
def test_auditnamen_bestaan_in_beide_talen():
nl, en = _laad("nl"), _laad("en")
alleen_nl = sorted(k for k in nl if k.startswith("audit_") and k not in en)
assert not alleen_nl, f"ontbreken in en.json: {alleen_nl}"
def _meegegeven(blok: str) -> set[str]:
"""De sleutels uit een `{…}` in een t()-aanroep.
JavaScript kent twee vormen: `{port: c.port}` en de verkorte `{name}`, die
hetzelfde betekent als `{name: name}`. Alleen op een dubbele punt zoeken
mist die tweede, en dan meldt de test fouten die er niet zijn.
"""
uit, huidig, diepte = set(), "", 0
for teken in blok + ",":
if teken in "([{":
diepte += 1
elif teken in ")]}":
diepte -= 1
if teken == "," and diepte == 0:
deel = huidig.strip()
if deel:
naam = deel.split(":", 1)[0].strip()
if re.fullmatch(r"\w+", naam):
uit.add(naam)
huidig = ""
else:
huidig += teken
return uit
def _t_aanroepen(html: str):
"""Elke `t('sleutel', { … })` met de meegegeven sleutels."""
for m in re.finditer(r"\bt\(\s*'([a-z0-9_]+)'\s*,\s*\{([^{}]*)\}", html):
yield m.group(1), _meegegeven(m.group(2))
def test_elke_plaatshouder_wordt_ook_meegegeven():
"""`t('port_free')` op een tekst met `{port}` erin zet letterlijk "{port}"
in beeld.
Dat is precies wat er gebeurde toen de poortcontrole werd aangesloten op
sleutels die er al waren: `port_free` verwacht `{port}`, `port_in_use`
verwacht `{port}` én `{by}`, en die werden niet meegegeven.
"""
nl = _laad("nl")
html = (WORTEL / "templates" / "index.html").read_text(encoding="utf-8")
aanroepen = list(_t_aanroepen(html))
assert aanroepen, "geen t()-aanroepen met variabelen gevonden — vorm veranderd?"
fouten = []
for sleutel, gegeven in aanroepen:
tekst = nl.get(sleutel)
if tekst is None:
continue # andere test dekt onbekende sleutels
ontbreekt = set(re.findall(r"\{(\w+)\}", tekst)) - gegeven
if ontbreekt:
fouten.append(f"{sleutel}: mist {sorted(ontbreekt)}")
# En het geval dat de eerste versie van deze test miste: een aanroep zónder
# variabelen op een tekst die er wél verwacht. Dat was precies de fout —
# `t('port_free')` op "Poort {port} is vrij".
for m in re.finditer(r"\bt\(\s*'([a-z0-9_]+)'\s*\)", html):
sleutel = m.group(1)
tekst = nl.get(sleutel)
if tekst is None:
continue
nodig = sorted(set(re.findall(r"\{(\w+)\}", tekst)))
if nodig:
fouten.append(f"{sleutel}: aangeroepen zonder {nodig}")
assert not fouten, fouten
def test_geen_plaatshouder_zonder_tekst():
"""Andersom: een sleutel meegeven die niet in de tekst staat is meestal een
hernoeming die half is doorgevoerd."""
nl = _laad("nl")
html = (WORTEL / "templates" / "index.html").read_text(encoding="utf-8")
fouten = []
for sleutel, gegeven in _t_aanroepen(html):
tekst = nl.get(sleutel)
if tekst is None:
continue
over = gegeven - set(re.findall(r"\{(\w+)\}", tekst))
if over:
fouten.append(f"{sleutel}: geeft {sorted(over)} mee, staat niet in de tekst")
assert not fouten, fouten