feat(i18n): serve every page in 30 languages

The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.

Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.

negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.

The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.

Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.

Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.

read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 00:02:35 +02:00
parent a42930a699
commit 2a35b2910a
46 changed files with 1915 additions and 37 deletions

View File

@@ -0,0 +1,195 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
app_module = None
flask_app = None
i18n = None
_origin = None
_workdir = None
def setUpModule():
"""Import the app with a disposable configuration as the working directory.
``app.app`` reads ``config.yaml`` relative to the process working directory
at import time, so the chdir has to happen before the import.
"""
global app_module, flask_app, i18n, _origin, _workdir
_origin = os.getcwd()
_workdir = tempfile.mkdtemp(prefix="portfolio-routes-")
shutil.copy(
REPO_ROOT / "app" / "config.sample.yaml", Path(_workdir) / "config.yaml"
)
os.chdir(_workdir)
from app import app as imported_module
from app.utils import i18n as imported_i18n
app_module = imported_module
flask_app = imported_module.app
i18n = imported_i18n
flask_app.config["NASA_API_KEY"] = None
def tearDownModule():
os.chdir(_origin)
shutil.rmtree(_workdir, ignore_errors=True)
class AppRouteMixin:
"""Shared setup. A mixin rather than a TestCase subclass, so that every
test class below still names ``unittest.TestCase`` as a direct base — the
lint guardrail in tests/lint/ does not resolve inherited aliases."""
def setUp(self):
self.client = flask_app.test_client()
self.addCleanup(i18n.clear_catalogs)
self.addCleanup(flask_app.config["TRANSLATED_CONFIG"].clear)
class TestRouting(AppRouteMixin, unittest.TestCase):
def test_unrelated_single_segment_paths_are_not_redirected(self):
for path in ("/robots.txt", "/favicon.ico", "/sitemap.xml"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 404)
def test_language_path_redirects_to_the_canonical_trailing_slash(self):
response = self.client.get("/de")
self.assertEqual(response.status_code, 308)
self.assertTrue(response.headers["Location"].endswith("/de/"))
def test_supported_language_renders(self):
response = self.client.get("/de/")
self.assertEqual(response.status_code, 200)
self.assertIn('<html lang="de"', response.get_data(as_text=True))
def test_unsupported_language_is_not_found(self):
self.assertEqual(self.client.get("/xx/").status_code, 404)
class TestNegotiation(AppRouteMixin, unittest.TestCase):
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
response = self.client.get("/", headers={"Accept-Language": "de-DE,en;q=0.8"})
self.assertIn('<html lang="de"', response.get_data(as_text=True))
def test_negotiated_route_declares_that_it_varies(self):
response = self.client.get("/", headers={"Accept-Language": "de-DE"})
self.assertEqual(response.headers.get("Vary"), "Accept-Language")
class TestEscaping(AppRouteMixin, unittest.TestCase):
def test_catalog_content_is_html_escaped(self):
i18n._catalogs["de"] = {"Imprint": "<script>alert(1)</script>"}
body = self.client.get("/de/").get_data(as_text=True)
self.assertNotIn("<script>alert(1)</script>", body)
self.assertIn("&lt;script&gt;alert(1)", body)
class TestExternalUrls(AppRouteMixin, unittest.TestCase):
def test_forwarded_scheme_reaches_the_canonical_and_alternates(self):
body = self.client.get(
"/en/",
headers={"Host": "portfolio.example.org", "X-Forwarded-Proto": "https"},
).get_data(as_text=True)
self.assertIn(
'<link rel="canonical" href="https://portfolio.example.org/en/">', body
)
self.assertIn('hreflang="ja" href="https://portfolio.example.org/ja/"', body)
self.assertNotIn("http://portfolio.example.org", body)
def test_trusted_hosts_are_parsed_from_a_comma_separated_list(self):
self.assertEqual(
app_module.trusted_hosts("a.test, b.test ,"), ["a.test", "b.test"]
)
def test_an_unset_trusted_hosts_value_disables_the_check(self):
self.assertIsNone(app_module.trusted_hosts(""))
self.assertIsNone(app_module.trusted_hosts(" , "))
def test_a_forged_host_is_rejected_once_trusted_hosts_are_named(self):
self.addCleanup(flask_app.config.__setitem__, "TRUSTED_HOSTS", None)
flask_app.config["TRUSTED_HOSTS"] = ["portfolio.example.org"]
forged = self.client.get("/de", headers={"Host": "evil.test"})
honest = self.client.get("/de/", headers={"Host": "portfolio.example.org"})
self.assertEqual(forged.status_code, 400)
self.assertEqual(honest.status_code, 200)
def test_forwarded_host_is_not_trusted(self):
response = self.client.get(
"/de",
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
)
self.assertNotIn("evil.test", response.headers["Location"])
body = self.client.get(
"/en/",
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
).get_data(as_text=True)
self.assertNotIn("evil.test", body)
class TestConfigurationReload(AppRouteMixin, unittest.TestCase):
def test_reloading_the_configuration_drops_the_catalog_memo(self):
i18n.catalog("de")
self.assertIn("de", i18n._catalogs)
app_module.load_config(flask_app)
self.assertEqual(i18n._catalogs, {})
def test_reloading_the_configuration_drops_the_translation_memo(self):
self.client.get("/de/")
self.assertIn("de", flask_app.config["TRANSLATED_CONFIG"])
app_module.load_config(flask_app)
self.assertEqual(flask_app.config["TRANSLATED_CONFIG"], {})
class TestTrustedHostsWiring(unittest.TestCase):
def test_the_environment_reaches_the_flask_configuration(self):
script = (
"import json, sys;"
f"sys.path.insert(0, {str(REPO_ROOT)!r});"
"from app.app import app;"
"print('TRUSTED=' + json.dumps(app.config['TRUSTED_HOSTS']))"
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=_workdir,
env={**os.environ, "TRUSTED_HOSTS": "a.test, b.test"},
capture_output=True,
text=True,
check=True,
)
reported = [
line for line in result.stdout.splitlines() if line.startswith("TRUSTED=")
]
self.assertEqual(
json.loads(reported[-1][len("TRUSTED=") :]), ["a.test", "b.test"]
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,8 +1,17 @@
import re
import tomllib
import unittest
from pathlib import Path
def distributions(requirements):
"""Return the distribution names of PEP 508 requirement strings."""
return {
re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0]
for requirement in requirements
}
class TestPythonPackaging(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
@@ -18,11 +27,20 @@ class TestPythonPackaging(unittest.TestCase):
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
self.assertIn("setuptools>=69", build_system["requires"])
self.assertGreaterEqual(
set(project["dependencies"]),
distributions(project["dependencies"]),
{"flask", "pyyaml", "requests"},
)
self.assertEqual(project["requires-python"], ">=3.12")
def test_flask_is_pinned_to_a_version_that_honours_trusted_hosts(self):
requirement = next(
item
for item in self.pyproject["project"]["dependencies"]
if item.startswith("flask")
)
self.assertIn(">=3.1", requirement)
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
project = self.pyproject["project"]
setuptools_config = self.pyproject["tool"]["setuptools"]
@@ -30,8 +48,8 @@ class TestPythonPackaging(unittest.TestCase):
package_data = setuptools_config["package-data"]["app"]
self.assertGreaterEqual(
set(project["optional-dependencies"]["dev"]),
{"bandit", "pip-audit", "ruff"},
distributions(project["optional-dependencies"]["dev"]),
{"bandit", "pip-audit", "ruff", "yamllint"},
)
self.assertEqual(setuptools_config["py-modules"], ["main"])
self.assertEqual(package_find["include"], ["app", "app.*"])

View File

@@ -0,0 +1,78 @@
"""Guards for configuration that no other test observes.
Each assertion here stands for a defect that was found by deleting the line it
checks: the deletion is invisible to every suite, and its effect only shows up
in production or in a fresh checkout.
"""
import re
import tomllib
import unittest
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
class TestYamllintConfiguration(unittest.TestCase):
def setUp(self):
self.config = yaml.safe_load(
(REPO_ROOT / ".yamllint").read_text(encoding="utf-8")
)
def test_duplicate_keys_are_an_error(self):
self.assertEqual(self.config["rules"]["key-duplicates"], "enable")
def test_the_directories_that_collect_foreign_yaml_are_ignored(self):
ignored = self.config["ignore"].split()
self.assertGreaterEqual(
set(ignored),
{".git/", ".venv/", "node_modules/", "app/node_modules/"},
)
class TestRunTargets(unittest.TestCase):
def setUp(self):
self.makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
self.recipes = {
name: body
for name, body in re.findall(
r"^(run-dev|run-prod):[^\n]*\n((?:\t[^\n]*\n)+)",
self.makefile,
re.MULTILINE,
)
}
def test_both_run_targets_exist(self):
self.assertEqual(set(self.recipes), {"run-dev", "run-prod"})
def test_the_container_is_told_which_hosts_are_trusted(self):
for name, body in self.recipes.items():
with self.subTest(target=name):
self.assertIn("TRUSTED_HOSTS", body)
def test_the_container_is_told_which_port_to_bind(self):
for name, body in self.recipes.items():
with self.subTest(target=name):
self.assertIn('-e PORT="$$PORT"', body)
def test_the_whole_env_file_is_not_handed_to_the_web_container(self):
for name, body in self.recipes.items():
with self.subTest(target=name):
self.assertNotIn("--env-file", body)
class TestPackagedCatalogs(unittest.TestCase):
def test_the_interface_catalogs_are_declared_as_package_data(self):
with (REPO_ROOT / "pyproject.toml").open("rb") as handle:
pyproject = tomllib.load(handle)
package_data = pyproject["tool"]["setuptools"]["package-data"]["app"]
self.assertIn("i18n/ui/*.yaml", package_data)
if __name__ == "__main__":
unittest.main()

212
tests/unit/test_i18n.py Normal file
View File

@@ -0,0 +1,212 @@
import re
import shutil
import tempfile
import unittest
from pathlib import Path
import yaml
from app.utils import i18n
class TestNegotiate(unittest.TestCase):
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
self.assertEqual(i18n.negotiate([("de-DE", 1.0), ("en", 0.8)]), "de")
def test_underscore_separated_tag_is_accepted(self):
self.assertEqual(i18n.negotiate([("pt_BR", 1.0)]), "pt")
def test_highest_quality_supported_tag_wins(self):
self.assertEqual(
i18n.negotiate([("xx", 1.0), ("fr", 0.9), ("es", 0.5)]),
"fr",
)
def test_unsupported_tags_fall_back_to_the_default(self):
self.assertEqual(i18n.negotiate([("xx", 1.0)]), "en")
self.assertEqual(i18n.negotiate([], default="de"), "de")
def test_a_refused_language_is_not_selected(self):
self.assertEqual(i18n.negotiate([("de", 0.0)]), "en")
def test_the_first_of_two_equal_tags_wins(self):
self.assertEqual(i18n.negotiate([("fr", 0.9), ("es", 0.9)]), "fr")
class TestDirection(unittest.TestCase):
def test_every_right_to_left_language_is_marked(self):
for code in ("ar", "fa", "he", "ur"):
with self.subTest(code=code):
self.assertEqual(i18n.direction(code), "rtl")
def test_other_languages_are_left_to_right(self):
for code in set(i18n.LANGUAGES) - {"ar", "fa", "he", "ur"}:
with self.subTest(code=code):
self.assertEqual(i18n.direction(code), "ltr")
class TestTranslateTree(unittest.TestCase):
def setUp(self):
self.addCleanup(i18n._catalogs.clear)
i18n._catalogs["xx"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
def test_only_translatable_keys_are_replaced(self):
tree = {
"cards": [
{
"title": "Pictures",
"text": "A card",
"url": "A card",
"icon": {"class": "Pictures"},
}
]
}
translated = i18n.translate_tree(tree, "xx")
card = translated["cards"][0]
self.assertEqual(card["title"], "Bilder")
self.assertEqual(card["text"], "Eine Karte")
self.assertEqual(card["url"], "A card")
self.assertEqual(card["icon"]["class"], "Pictures")
def test_unknown_strings_keep_their_source_value(self):
translated = i18n.translate_tree({"description": "Untranslated"}, "xx")
self.assertEqual(translated["description"], "Untranslated")
def test_source_tree_is_left_untouched(self):
tree = {"name": "Pictures"}
i18n.translate_tree(tree, "xx")
self.assertEqual(tree["name"], "Pictures")
def test_non_string_leaves_survive(self):
tree = {"name": 1, "text": None, "info": True}
self.assertEqual(i18n.translate_tree(tree, "xx"), tree)
class TestReadCatalog(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.path = self.directory / "de.yaml"
def _read(self, text):
self.path.write_text(text, encoding="utf-8")
with self.assertLogs(level="WARNING"):
return i18n.read_catalog(self.path)
def test_absent_file_is_an_empty_catalog(self):
self.assertEqual(i18n.read_catalog(self.directory / "missing.yaml"), {})
def test_unparsable_yaml_is_an_empty_catalog(self):
self.assertEqual(self._read("Close: [unclosed"), {})
def test_tab_indentation_is_an_empty_catalog(self):
self.assertEqual(self._read("a:\n\tb: 1"), {})
def test_non_mapping_document_is_an_empty_catalog(self):
self.assertEqual(self._read("- a\n- b"), {})
def test_a_non_utf8_catalog_is_an_empty_catalog(self):
self.path.write_bytes(b"\xffClose: Schliessen\n")
with self.assertLogs(level="WARNING"):
self.assertEqual(i18n.read_catalog(self.path), {})
def test_a_directory_at_the_catalog_path_is_an_empty_catalog(self):
(self.directory / "sub.yaml").mkdir()
with self.assertLogs(level="WARNING"):
self.assertEqual(i18n.read_catalog(self.directory / "sub.yaml"), {})
def test_non_string_entries_are_dropped(self):
self.path.write_text(
"Close: 42\nOpen:\nCopy: yes\nImprint: Impressum\n", encoding="utf-8"
)
self.assertEqual(i18n.read_catalog(self.path), {"Imprint": "Impressum"})
class TestCatalogMerge(unittest.TestCase):
def setUp(self):
directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, directory, True)
(directory / "ui").mkdir()
(directory / "content").mkdir()
self.addCleanup(setattr, i18n, "UI_DIR", i18n.UI_DIR)
self.addCleanup(setattr, i18n, "CONTENT_DIR", i18n.CONTENT_DIR)
self.addCleanup(i18n.clear_catalogs)
i18n.UI_DIR = directory / "ui"
i18n.CONTENT_DIR = directory / "content"
i18n.clear_catalogs()
def test_the_content_catalog_overrides_the_shipped_interface_string(self):
(i18n.UI_DIR / "de.yaml").write_text(
"Close: Schliessen\nImprint: Impressum\n", encoding="utf-8"
)
(i18n.CONTENT_DIR / "de.yaml").write_text("Close: Zumachen\n", encoding="utf-8")
self.assertEqual(
i18n.catalog("de"), {"Close": "Zumachen", "Imprint": "Impressum"}
)
class TestShippedCatalogs(unittest.TestCase):
def test_thirty_languages_are_offered(self):
self.assertEqual(len(i18n.LANGUAGES), 30)
self.assertIn(i18n.SOURCE_LANGUAGE, i18n.LANGUAGES)
def test_every_code_is_usable_in_the_route_converter(self):
offenders = [
code for code in i18n.LANGUAGES if not re.fullmatch(r"[a-z]+", code)
]
self.assertFalse(offenders, f"Not usable in the route converter: {offenders}")
def test_every_language_but_the_source_ships_a_ui_catalog(self):
missing = [
code
for code in i18n.LANGUAGES
if code != i18n.SOURCE_LANGUAGE
and not (i18n.UI_DIR / f"{code}.yaml").is_file()
]
self.assertFalse(missing, f"No UI catalogue for: {missing}")
def test_ui_catalogs_cover_exactly_the_interface_strings(self):
expected = set(i18n.UI_STRINGS)
mismatched = {}
for code in i18n.LANGUAGES:
if code == i18n.SOURCE_LANGUAGE:
continue
path = i18n.UI_DIR / f"{code}.yaml"
entries = yaml.safe_load(path.read_text(encoding="utf-8"))
if set(entries) != expected:
mismatched[code] = {
"missing": sorted(expected - set(entries)),
"unexpected": sorted(set(entries) - expected),
}
self.assertFalse(mismatched, f"UI catalogues out of sync: {mismatched}")
def test_ui_strings_of_the_source_language_are_the_source(self):
self.assertEqual(
i18n.ui_strings("en"),
{source: source for source in i18n.UI_STRINGS},
)
def test_ui_strings_are_translated_for_a_shipped_language(self):
strings = i18n.ui_strings("de")
self.assertEqual(strings["Close"], "Schließen")
self.assertEqual(strings["Imprint"], "Impressum")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,280 @@
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import yaml
from app.utils import i18n
from utils import i18n_sync
class TestCollectSources(unittest.TestCase):
def test_prose_keys_are_collected_from_nested_structures(self):
config = {
"cards": [
{"title": "Agile Coach", "text": "I lead transformations."},
{"text": "Another card."},
],
"navigation": {
"header": {
"children": [
{"name": "Apps", "description": "Application menu"},
]
}
},
"platform": {"titel": "Someone", "subtitel": "A tagline"},
}
self.assertEqual(
i18n_sync.collect_sources(config),
{
"I lead transformations.",
"Another card.",
"Application menu",
"A tagline",
},
)
def test_label_and_structural_keys_are_left_alone(self):
config = {
"name": "Mastodon",
"title": "Cybermaster",
"url": "https://example.test",
"link_text": "www.example.test",
}
self.assertEqual(i18n_sync.collect_sources(config), set())
def test_blank_values_are_ignored(self):
self.assertEqual(i18n_sync.collect_sources({"text": " "}), set())
class TestTranslate(unittest.TestCase):
def _session(self, ok, payload=None, status=200, text=""):
response = Mock(ok=ok, status_code=status, text=text)
response.json.return_value = payload or {}
return Mock(post=Mock(return_value=response))
def test_successful_response_returns_the_translation(self):
session = self._session(True, {"translatedText": "Hallo"})
result = i18n_sync.translate(session, "http://lt", "", "Hello", "de")
self.assertEqual(result, "Hallo")
def test_api_key_is_only_sent_when_configured(self):
session = self._session(True, {"translatedText": "Hallo"})
i18n_sync.translate(session, "http://lt", "secret", "Hello", "de")
self.assertEqual(session.post.call_args.kwargs["data"]["api_key"], "secret")
def test_no_api_key_is_sent_when_none_is_configured(self):
session = self._session(True, {"translatedText": "Hallo"})
i18n_sync.translate(session, "http://lt", "", "Hello", "de")
self.assertNotIn("api_key", session.post.call_args.kwargs["data"])
def test_failed_response_returns_none(self):
session = self._session(False, status=403, text="denied")
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
def test_a_non_json_success_response_returns_none(self):
response = Mock(ok=True)
response.json.side_effect = ValueError("no json")
session = Mock(post=Mock(return_value=response))
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
def test_a_transport_failure_returns_none(self):
session = Mock(
post=Mock(side_effect=i18n_sync.requests.ConnectionError("reset"))
)
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
def test_a_non_string_translation_is_refused(self):
session = self._session(True, {"translatedText": ["Hallo", "Welt"]})
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
class TestSupportedLanguages(unittest.TestCase):
def _session(self, payload=None, side_effect=None):
response = Mock(raise_for_status=Mock())
if side_effect is not None:
response.json.side_effect = side_effect
else:
response.json.return_value = payload
return Mock(get=Mock(return_value=response))
def test_the_offered_codes_are_returned(self):
session = self._session([{"code": "de"}, {"code": "fr"}])
self.assertEqual(
i18n_sync.supported_languages("http://lt", session), {"de", "fr"}
)
def test_an_unreachable_instance_is_reported(self):
session = Mock(get=Mock(side_effect=i18n_sync.requests.ConnectTimeout("slow")))
with self.assertRaises(i18n_sync.BackendError):
i18n_sync.supported_languages("http://lt", session)
def test_a_non_json_listing_is_reported(self):
session = self._session(side_effect=ValueError("no json"))
with self.assertRaises(i18n_sync.BackendError):
i18n_sync.supported_languages("http://lt", session)
def test_json_of_the_wrong_shape_is_reported(self):
for payload in ({"error": "Slow down"}, ["de", "fr"], None, [{"name": "de"}]):
with self.subTest(payload=payload):
with self.assertRaises(i18n_sync.BackendError):
i18n_sync.supported_languages("http://lt", self._session(payload))
class TestLoadExisting(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.path = self.directory / "de.yaml"
def test_a_missing_file_is_an_empty_catalog(self):
self.assertEqual(i18n_sync.load_existing(self.path), {})
def test_a_readable_catalog_is_returned(self):
self.path.write_text("Hi: Hallo\n", encoding="utf-8")
self.assertEqual(i18n_sync.load_existing(self.path), {"Hi": "Hallo"})
def test_an_unparsable_catalog_is_refused(self):
self.path.write_text('Hi: "unterminated\n', encoding="utf-8")
self.assertIsNone(i18n_sync.load_existing(self.path))
def test_a_non_mapping_catalog_is_refused(self):
self.path.write_text("- a\n- b\n", encoding="utf-8")
self.assertIsNone(i18n_sync.load_existing(self.path))
def test_a_non_utf8_catalog_is_refused(self):
self.path.write_bytes(b"\xffHi: Hallo\n")
self.assertIsNone(i18n_sync.load_existing(self.path))
def test_a_directory_at_the_catalog_path_is_refused(self):
(self.directory / "sub.yaml").mkdir()
self.assertIsNone(i18n_sync.load_existing(self.directory / "sub.yaml"))
def test_an_empty_or_comment_only_catalog_is_an_empty_catalog(self):
for text in ("", "\n\n", "# only a comment\n"):
with self.subTest(text=text):
self.path.write_text(text, encoding="utf-8")
self.assertEqual(i18n_sync.load_existing(self.path), {})
class TestWriteCatalog(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.path = self.directory / "de.yaml"
def test_the_catalog_is_written_and_no_scratch_file_is_left(self):
i18n_sync.write_catalog(self.path, {"Hi": "Hallo"})
self.assertEqual(
yaml.safe_load(self.path.read_text(encoding="utf-8")), {"Hi": "Hallo"}
)
self.assertEqual([p.name for p in self.directory.iterdir()], ["de.yaml"])
def test_a_write_that_dies_halfway_leaves_the_previous_catalog_intact(self):
self.path.write_text("Hi: HAND-EDITED\n", encoding="utf-8")
complete = Path.write_text
def dies_halfway(target, data, *args, **kwargs):
complete(target, data[: len(data) // 2], *args, **kwargs)
raise OSError("no space left on device")
with patch.object(Path, "write_text", dies_halfway):
with self.assertRaises(OSError):
i18n_sync.write_catalog(self.path, {"Hi": "machine", "Zebra": "Zebra"})
self.assertEqual(self.path.read_text(encoding="utf-8"), "Hi: HAND-EDITED\n")
class TestSync(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.addCleanup(setattr, i18n, "CONTENT_DIR", i18n.CONTENT_DIR)
i18n.CONTENT_DIR = self.directory
self.path = self.directory / "de.yaml"
def _run(self, sources, offered=("de",), translated="UEBERSETZT"):
listing = Mock(raise_for_status=Mock())
listing.json.return_value = [{"code": code} for code in offered]
answer = Mock(ok=True)
answer.json.return_value = {"translatedText": translated}
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
with patch.object(i18n_sync.requests, "Session", return_value=session):
i18n_sync.sync("http://lt", "", set(sources), ["de"])
return session
def test_missing_entries_are_written(self):
self._run(["Hello"])
self.assertEqual(
yaml.safe_load(self.path.read_text(encoding="utf-8")),
{"Hello": "UEBERSETZT"},
)
def test_existing_entries_are_never_overwritten(self):
self.path.write_text("Hello: HAND-EDITED\n", encoding="utf-8")
session = self._run(["Hello", "World"])
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
self.assertEqual(catalog["Hello"], "HAND-EDITED")
self.assertEqual(session.post.call_count, 1)
def test_an_unparsable_catalog_is_left_alone(self):
broken = 'Hello: "unterminated\n'
self.path.write_text(broken, encoding="utf-8")
session = self._run(["Hello"])
self.assertEqual(self.path.read_text(encoding="utf-8"), broken)
session.post.assert_not_called()
def test_a_language_the_instance_does_not_offer_is_skipped(self):
session = self._run(["Hello"], offered=("fr",))
self.assertFalse(self.path.exists())
session.post.assert_not_called()
def test_a_run_that_translated_nothing_leaves_the_file_untouched(self):
original = "# Reviewed by a native speaker, keep the order.\nZebra: Zebra\n"
self.path.write_text(original, encoding="utf-8")
self._run(["Hello"], translated=None)
self.assertEqual(self.path.read_text(encoding="utf-8"), original)
def test_the_content_directory_is_created(self):
nested = self.directory / "content"
i18n.CONTENT_DIR = nested
self._run(["Hello"])
self.assertTrue((nested / "de.yaml").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -2,7 +2,7 @@ import unittest
from html.parser import HTMLParser
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from jinja2 import Environment, FileSystemLoader
class AnchorCollector(HTMLParser):
@@ -20,10 +20,12 @@ class TestNavigationTemplate(unittest.TestCase):
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
environment = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
autoescape=True,
)
environment.globals["url_for"] = lambda _endpoint, filename: (
f"/static/{filename}"
environment.globals["url_for"] = lambda endpoint, **kwargs: (
f"/static/{kwargs['filename']}"
if endpoint == "static"
else f"/{kwargs['lang']}/"
)
environment.globals["asset_src"] = lambda asset: (
(asset or {}).get("external_url")
@@ -36,6 +38,9 @@ class TestNavigationTemplate(unittest.TestCase):
rendered = environment.get_template("moduls/navigation.html.j2").render(
menu_type="header",
lang="en",
languages={"en": "English", "de": "Deutsch"},
t=lambda source: source,
platform={
"titel": "Portfolio",
"logo": {"cache": "logo.png"},
@@ -70,8 +75,15 @@ class TestNavigationTemplate(unittest.TestCase):
and "dropdown-toggle" in anchor.get("class", "")
]
self.assertEqual(len(dropdown_toggles), 1)
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
self.assertEqual(len(dropdown_toggles), 2)
for toggle in dropdown_toggles:
self.assertEqual(toggle.get("data-bs-toggle"), "dropdown")
language_links = [
anchor for anchor in parser.anchors if anchor.get("hreflang") == "de"
]
self.assertEqual(len(language_links), 1)
self.assertEqual(language_links[0]["href"], "/de/")
if __name__ == "__main__":