The interface speaks Slovak
969 strings, an instance default and a per-person choice, and no half-done corner: the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations. KEYED BY THE ENGLISH SENTENCE A missing entry renders the key, which is the English -- so an untranslated string looks as it always did, an English instance is byte-for-byte 1.6.0, and a half-finished catalogue is a half-translated page rather than a page of dotted key names. The cost is that editing an English sentence orphans its translation silently, which is what tests/test_translations.py asserts in both directions. No gettext: .po -> .mo is a build step and this project does not have one. A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR `t()` is a global for the reason `brand` already documents -- render() is bypassed by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being the SSE frames, which have no Request at all. A global is bound once at import and the language is per person, so the active language is a ContextVar set per request. 🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a threadpool, and anyio copies the context in and discards it on the way out -- so the language was set where nothing could see it and every page rendered in the instance's language whatever anybody had chosen, with no error anywhere. NOT TRANSLATED, ON PURPOSE Everything a model reads: the 60 prompt fragments, and the dates in harness.py, schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and only where a person reads it -- with the *format* translatable as well as the words, because "26. septembra 2026" is a different pattern rather than the same one with different words in it. A process locale is not an option: global, not thread-safe, two people's pages at once. A second fragment telling models to answer in the reader's language was written during this work and removed: `core.style` has said it since long before, and test_an_empty_override_turns_a_fragment_off caught the duplicate. THE BULK PASS 1213 sites wrapped by a one-off script that only touched patterns it could not misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"` where the sentence held an apostrophe, closing the Jinja string and 500ing two pages whose partials no test renders. tests/test_translations.py now compiles all 110 templates. It also wrapped the product's own name, an SSH key header, a keystroke hint and an example URL, all taken back out: a string is not translatable just because it is a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,12 @@ def test_the_toggle_is_one_partial_and_not_eight_copies():
|
||||
times, which is how the first one came to exist once."""
|
||||
toggle = (TEMPLATES / "partials/_sidebar_toggle.html").read_text(encoding="utf-8")
|
||||
assert 'data-toggle="#sidebar"' in toggle
|
||||
assert 'aria-label="Toggle sidebar"' in toggle
|
||||
# The label, however it is spelled. It was written inline until the interface
|
||||
# became translatable, and it is now `aria-label="{{ t('Toggle sidebar') }}"` --
|
||||
# the same rename `asset()` put this suite through once already. Asserting the
|
||||
# old spelling here would fail on a change that broke nothing.
|
||||
assert "aria-label=" in toggle
|
||||
assert "Toggle sidebar" in toggle
|
||||
|
||||
|
||||
def test_the_toggle_does_not_claim_to_be_open():
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""The interface in another language, and the two things that rot silently.
|
||||
|
||||
A catalogue keyed by the English source text degrades well -- a missing entry
|
||||
renders the English, so a half-finished translation is a half-translated page
|
||||
rather than a page with `settings.theme.label` written across it. What it cannot
|
||||
do is notice that somebody edited an English sentence, which leaves its
|
||||
translation stranded under the old wording and the new sentence untranslated with
|
||||
nothing said about either. So this asserts it from both sides.
|
||||
|
||||
The other thing here is a **template compile check**, which belongs with this work
|
||||
because wrapping a thousand strings is what proved it was missing: a partial that
|
||||
no test renders can carry a syntax error indefinitely, and the way it announced
|
||||
itself was a 500 on two unrelated pages. One apostrophe inside a single-quoted
|
||||
Jinja string did it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
import lembas
|
||||
from lembas.web import i18n
|
||||
|
||||
ROOT = Path(lembas.__file__).parent
|
||||
TEMPLATES = ROOT / "web/templates"
|
||||
CATALOGUES = ROOT / "web/i18n"
|
||||
|
||||
# The same pattern `scripts/i18n_extract.py` uses. Duplicated rather than imported
|
||||
# because `scripts/` is not a package and is not importable from the suite -- and
|
||||
# a test that silently stopped finding anything would be worse than the copy.
|
||||
CALL = re.compile(r"""\bt\(\s*(?P<q>["'])(?P<text>(?:\\.|(?!\1).)*?)\1""", re.S)
|
||||
|
||||
# `i18n.stamp(value, "%d %B %Y")` -- the *format* is translated too, so a language
|
||||
# that puts the day first, or wants a full stop after it, says so in the
|
||||
# catalogue. A second pattern rather than a looser first one: widening `t(` to
|
||||
# "any call with a string in it" would sweep up every `select("…")` in the
|
||||
# codebase.
|
||||
STAMP = re.compile(
|
||||
# One level of nesting allowed in the first argument, because it is usually a
|
||||
# call: `stamp(clock.now_for(user), "…")`.
|
||||
r"""\bstamp\((?:[^()"']|\([^()]*\))*,\s*(?P<q>["'])(?P<text>(?:\\.|(?!\1).)*?)\1""",
|
||||
re.S,
|
||||
)
|
||||
JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.S)
|
||||
PY_COMMENT = re.compile(r"^[ \t]*#.*$", re.M)
|
||||
|
||||
|
||||
def _normalise(text: str) -> str:
|
||||
text = text.replace('\\"', '"').replace("\\'", "'").replace("\\n", " ")
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _sources() -> list[Path]:
|
||||
files = sorted(TEMPLATES.rglob("*.html"))
|
||||
files += [
|
||||
path
|
||||
for path in sorted(ROOT.rglob("*.py"))
|
||||
if "web/i18n" not in str(path) and "__pycache__" not in str(path)
|
||||
]
|
||||
return files
|
||||
|
||||
|
||||
def _used() -> dict[str, str]:
|
||||
"""Every translatable string, mapped to the first file it appears in."""
|
||||
out: dict[str, str] = {}
|
||||
for path in _sources():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = JINJA_COMMENT.sub("", text) if path.suffix == ".html" else PY_COMMENT.sub("", text)
|
||||
for match in list(CALL.finditer(text)) + list(STAMP.finditer(text)):
|
||||
key = _normalise(match.group("text"))
|
||||
if key:
|
||||
out.setdefault(key, str(path.relative_to(ROOT)))
|
||||
return out
|
||||
|
||||
|
||||
def _templates_env() -> Environment:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES)), trim_blocks=True, lstrip_blocks=True
|
||||
)
|
||||
# Enough of the real globals that a template naming one still compiles. This
|
||||
# checks syntax, not rendering: a name that exists is all a compile needs.
|
||||
env.globals.update(
|
||||
{
|
||||
"t": lambda text, **fields: text,
|
||||
"language": lambda: "en",
|
||||
"text_direction": lambda: "ltr",
|
||||
"asset": lambda path: path,
|
||||
"brand": object(),
|
||||
"tool_label": lambda event: "",
|
||||
"tool_icon": lambda event: "",
|
||||
"message_steps": lambda message: [],
|
||||
}
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
# --- Every template compiles ---------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[str(path.relative_to(TEMPLATES)) for path in sorted(TEMPLATES.rglob("*.html"))],
|
||||
)
|
||||
def test_every_template_compiles(name: str):
|
||||
"""A partial no test renders can carry a syntax error for ever.
|
||||
|
||||
This found the one that shipped with the first wrapping pass -- an apostrophe
|
||||
inside `t('…')` in an attribute, where the sentence's own quote closed the
|
||||
Jinja string -- and it found it as a named file rather than as a 500 on two
|
||||
pages that happened to include it.
|
||||
"""
|
||||
_templates_env().get_template(name)
|
||||
|
||||
|
||||
def test_the_scan_finds_the_strings_it_is_meant_to_police():
|
||||
"""A blindness guard. Every assertion below is about a set, and a scan that
|
||||
stops matching makes all of them pass by finding nothing."""
|
||||
used = _used()
|
||||
assert len(used) > 500, f"only {len(used)} translatable strings found"
|
||||
|
||||
|
||||
# --- The catalogues ------------------------------------------------------------
|
||||
@pytest.mark.parametrize("code", [code for code in i18n.LANGUAGE_IDS if code != i18n.SOURCE])
|
||||
def test_no_catalogue_entry_is_orphaned(code: str):
|
||||
"""The failure this file exists for. Editing an English sentence leaves its
|
||||
translation keyed on wording nothing says any more -- so the page silently
|
||||
reverts to English there, and the old translation sits in the file looking
|
||||
done. Nothing else can notice."""
|
||||
used = _used()
|
||||
orphans = sorted(key for key in i18n.catalogue(code) if key not in used)
|
||||
assert not orphans, (
|
||||
f"{len(orphans)} {code} entries are keyed on text nothing says any more; "
|
||||
f"the first few: {orphans[:3]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [code for code in i18n.LANGUAGE_IDS if code != i18n.SOURCE])
|
||||
def test_placeholders_survive_translation(code: str):
|
||||
"""A translation that drops `%(name)s` renders a sentence with a hole in it.
|
||||
`t` falls back to the English when the substitution fails, so the symptom is
|
||||
one untranslated line -- visible, but only if somebody is looking."""
|
||||
fields = re.compile(r"%\((\w+)\)s")
|
||||
for key, value in i18n.catalogue(code).items():
|
||||
assert set(fields.findall(key)) == set(fields.findall(value)), key[:60]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [code for code in i18n.LANGUAGE_IDS if code != i18n.SOURCE])
|
||||
def test_nothing_is_translated_to_the_empty_string(code: str):
|
||||
"""An empty translation renders an empty paragraph, which is worse than the
|
||||
English it replaced. `scripts/i18n_extract.py --write` maps a new key to its
|
||||
own English text for the same reason."""
|
||||
blank = [key for key, value in i18n.catalogue(code).items() if not value.strip()]
|
||||
assert not blank, f"{code}: {len(blank)} empty translations, e.g. {blank[:3]}"
|
||||
|
||||
|
||||
def test_a_language_nobody_offers_falls_back_rather_than_raising():
|
||||
"""A stored code from a release that had more languages than this one, or a
|
||||
hand-edited row. Somebody's settings page must still open."""
|
||||
assert i18n.known("kl") == i18n.SOURCE
|
||||
assert i18n.known(None) == i18n.SOURCE
|
||||
assert i18n.known("SK") == "sk"
|
||||
|
||||
|
||||
def test_translating_falls_back_to_the_source_text():
|
||||
"""The property the whole design rests on: an untranslated string renders as
|
||||
what it already said."""
|
||||
assert i18n.translate("Something nothing has translated", "sk") == (
|
||||
"Something nothing has translated"
|
||||
)
|
||||
assert i18n.translate("Sign in", "sk") != "Sign in"
|
||||
|
||||
|
||||
def test_whitespace_in_the_source_does_not_have_to_match():
|
||||
"""A template wraps one sentence across three lines and a Python file across
|
||||
two, so the key is the text with its whitespace collapsed. Without this, a
|
||||
catalogue would need an entry per wrapping."""
|
||||
assert i18n.translate("Sign in", "sk") == i18n.translate("Sign in", "sk")
|
||||
assert i18n.translate("Sign\n in", "sk") == i18n.translate("Sign in", "sk")
|
||||
|
||||
|
||||
# --- What it does to a page ----------------------------------------------------
|
||||
def test_a_person_sees_their_own_language(client, db, registered):
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import User
|
||||
|
||||
user = db.scalars(select(User)).first()
|
||||
user.settings_json = {**(user.settings_json or {}), "language": "sk"}
|
||||
db.commit()
|
||||
|
||||
page = client.get("/settings")
|
||||
|
||||
assert 'lang="sk"' in page.text
|
||||
assert "Odhlásiť sa" in page.text
|
||||
|
||||
|
||||
def test_the_instance_default_reaches_somebody_with_no_choice(client, db, registered):
|
||||
from lembas.services import settings_store
|
||||
|
||||
settings_store.update(db, {"language": "sk"})
|
||||
i18n.forget()
|
||||
try:
|
||||
page = client.get("/settings")
|
||||
assert 'lang="sk"' in page.text
|
||||
finally:
|
||||
settings_store.update(db, {"language": ""})
|
||||
i18n.forget()
|
||||
|
||||
|
||||
def test_a_persons_choice_beats_the_instance(client, db, registered):
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import User
|
||||
from lembas.services import settings_store
|
||||
|
||||
settings_store.update(db, {"language": "sk"})
|
||||
i18n.forget()
|
||||
user = db.scalars(select(User)).first()
|
||||
user.settings_json = {**(user.settings_json or {}), "language": "en"}
|
||||
db.commit()
|
||||
try:
|
||||
assert 'lang="en"' in client.get("/settings").text
|
||||
finally:
|
||||
settings_store.update(db, {"language": ""})
|
||||
i18n.forget()
|
||||
|
||||
|
||||
def test_an_english_instance_renders_the_english_it_always_did(client, db, registered):
|
||||
"""The byte-for-byte guarantee. `t()` with no catalogue is the identity, so a
|
||||
page in English is what shipped before any of this existed."""
|
||||
page = client.get("/settings")
|
||||
assert "Sign out" in page.text
|
||||
assert 'lang="en"' in page.text
|
||||
|
||||
|
||||
def test_the_language_can_be_chosen_and_is_validated(client, db, registered):
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import User
|
||||
|
||||
client.post("/api/preferences/language", data={"language": "sk"}, follow_redirects=False)
|
||||
db.expire_all()
|
||||
assert db.scalars(select(User)).first().settings_json["language"] == "sk"
|
||||
|
||||
refused = client.post(
|
||||
"/api/preferences/language", data={"language": "klingon"}, follow_redirects=False
|
||||
)
|
||||
assert refused.headers["location"].endswith("error=language")
|
||||
db.expire_all()
|
||||
assert db.scalars(select(User)).first().settings_json["language"] == "sk"
|
||||
|
||||
|
||||
def test_following_the_instance_is_a_real_answer(client, db, registered):
|
||||
"""Stored as "" rather than removed, the same shape the timezone uses: absent
|
||||
and "follow the default" are different states and a form cannot tell them
|
||||
apart otherwise."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import User
|
||||
|
||||
client.post("/api/preferences/language", data={"language": "sk"}, follow_redirects=False)
|
||||
client.post("/api/preferences/language", data={"language": ""}, follow_redirects=False)
|
||||
db.expire_all()
|
||||
assert db.scalars(select(User)).first().settings_json["language"] == ""
|
||||
Reference in New Issue
Block a user