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:
280
tests/unit/test_i18n_sync.py
Normal file
280
tests/unit/test_i18n_sync.py
Normal 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()
|
||||
Reference in New Issue
Block a user