Files
qelectrotech-source-mirror/misc/i18n/test_mirror_source_language.py
T
Beat Hangartner 287689d185 Serve the French UI from qet_fr.ts instead of the code text
The strings in the code are French and serve as both translation key
and displayed text. lang/qet_fr.ts was almost empty: 3425 messages and
22 plural messages had no translation, so lrelease compiled them as
null-string records (3469 in qet_fr.qm) and the French UI showed the
code text through QTranslator's null fallback. Correcting French
wording therefore meant changing the key, which orphans the translation
of that string in all 33 other language files; the "Élement" typo fix
needed a key rename in 34 files for one accent.

This copies every empty French entry's source text into its own
translation, so qet_fr.qm is complete (514 KB, 0 null records, like
the other languages) and French wording can be corrected in the .ts
alone. Translations that already had text (113 French renderings of
English-written sources, 16 identical, 54 plurals) are untouched, as
are the 10 vanished entries. The file is byte-identical outside the
rewritten <translation> elements.

misc/i18n/mirror_source_language.py does the copy and is meant to run
after update_translations; --check exits 1 when a run is needed.
Forgetting it breaks nothing: an empty entry still falls back to the
code text. INSTALL.md section 10 explains the rule and that
-removeidentical must never be added to lrelease. Tests:
misc/i18n/test_mirror_source_language.py with a before/after fixture
pair covering every message class.

No visible change in the application.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:51:30 +02:00

199 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Regression suite for mirror_source_language.py.
python3 misc/i18n/test_mirror_source_language.py
The fixtures are a hand-written pair: mirror_before.ts is what lupdate
leaves behind, mirror_after.ts is what the script must produce from it,
byte for byte. Every message class the script distinguishes appears
once in the pair, so a change in behaviour shows up as a diff in a
known place.
The last test looks at the repository's own lang/qet_fr.ts and fails
when it needs a mirror run: that is the maintenance rule from
INSTALL.md, kept honest.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import mirror_source_language as m # noqa: E402
SCRIPT = HERE / "mirror_source_language.py"
BEFORE = HERE / "fixtures" / "mirror_before.ts"
AFTER = HERE / "fixtures" / "mirror_after.ts"
REPO_FR = HERE.parent.parent / "lang" / "qet_fr.ts"
def read(path: Path) -> str:
with open(path, encoding="utf-8", newline="") as f:
return f.read()
def messages(text: str) -> dict[tuple[str, str], ET.Element]:
"""(context, source) -> <message> element."""
out = {}
for ctx in ET.fromstring(text.encode("utf-8")).iter("context"):
name = ctx.findtext("name")
for msg in ctx.iter("message"):
out[(name, msg.findtext("source"))] = msg
return out
TRANSLATION_RE = re.compile(r"<translation[^>]*>.*?</translation>", re.S)
def outside_translations(text: str) -> str:
"""The file with every <translation> element blanked out."""
return TRANSLATION_RE.sub("<translation/>", text)
class MirrorText(unittest.TestCase):
def test_nothing_to_do_is_byte_identical(self):
after = read(AFTER)
out, stats = m.mirror_text(after)
self.assertEqual(out, after)
self.assertEqual(stats.mirrored_messages, 0)
self.assertEqual(stats.mirrored_forms, 0)
self.assertEqual(stats.unfinished_cleared, 0)
def test_before_becomes_after(self):
out, stats = m.mirror_text(read(BEFORE))
self.assertEqual(out, read(AFTER))
self.assertEqual(stats.mirrored_messages, 8)
self.assertEqual(stats.mirrored_forms, 3)
self.assertEqual(stats.unfinished_cleared, 2)
self.assertEqual(stats.kept_translated, 1)
self.assertEqual(stats.kept_identical, 1)
self.assertEqual(stats.kept_vanished, 2)
self.assertEqual(stats.kept_unfinished_with_text, 1)
def test_nothing_outside_translation_elements_changes(self):
"""Header, locations, comments, sources and indentation are untouched."""
self.assertEqual(outside_translations(read(BEFORE)),
outside_translations(read(AFTER)))
def test_idempotent(self):
once, _ = m.mirror_text(read(BEFORE))
twice, stats = m.mirror_text(once)
self.assertEqual(once, twice)
self.assertFalse(stats.changed())
def test_untouched_classes(self):
before, after = messages(read(BEFORE)), messages(read(AFTER))
for key in [("Alpha", "Apply to Entire Project"), # English source, translated
("Alpha", "QElectroTech"), # identical
("Alpha", "Brouillon"), # unfinished with text
("Alpha", "Ancien texte"), # vanished with text
("Alpha", "Autre ancien texte")]: # vanished, empty
self.assertEqual(ET.tostring(before[key]), ET.tostring(after[key]), key)
self.assertEqual(after[("Alpha", "Brouillon")].find("translation").get("type"),
"unfinished")
def test_escaping_is_copied_verbatim(self):
"""&apos; must stay &apos;, never become &amp;apos; (a re-escape bug)."""
out, _ = m.mirror_text(read(BEFORE))
for raw in ("<translation>&lt;b&gt;%1&lt;/b&gt; : %2</translation>",
"<translation>&amp;Aide</translation>",
"<translation>l&apos;élément &quot;x&quot;</translation>"):
self.assertIn(raw, out)
for key, msg in messages(out).items():
tr = msg.find("translation")
if key[0] == "Beta":
self.assertEqual(tr.text, msg.findtext("source"), key)
def test_multiline_and_spaces_kept(self):
out, _ = m.mirror_text(read(BEFORE))
self.assertIn("<translation> Ligne 1\nLigne 2\n</translation>", out)
def test_plural_half_empty_fills_only_the_empty_form(self):
out, _ = m.mirror_text(read(BEFORE))
forms = [f.text for f in messages(out)[("Alpha", "%n élément(s)")]
.find("translation").iter("numerusform")]
self.assertEqual(forms, ["%n élément", "%n élément(s)"])
def test_plural_both_empty_fills_both_and_clears_unfinished(self):
out, _ = m.mirror_text(read(BEFORE))
tr = messages(out)[("Alpha", "%n conducteur(s)")].find("translation")
self.assertIsNone(tr.get("type"))
self.assertEqual([f.text for f in tr.iter("numerusform")],
["%n conducteur(s)", "%n conducteur(s)"])
def test_plural_without_forms_gets_default_forms(self):
text = ('<TS><context><name>C</name><message numerus="yes">\n'
' <source>%n x</source>\n'
' <translation type="unfinished"></translation>\n'
' </message></context></TS>')
out, stats = m.mirror_text(text, forms=3)
self.assertEqual(stats.mirrored_forms, 3)
self.assertEqual(out.count("<numerusform>%n x</numerusform>"), 3)
self.assertNotIn("unfinished", out)
ET.fromstring(out)
def test_source_with_child_element_is_refused(self):
text = ('<TS><context><name>C</name><message>\n'
' <source>a<byte value="7"/>b</source>\n'
' <translation></translation>\n'
' </message></context></TS>')
with self.assertRaises(m.SourceError):
m.mirror_text(text)
class CommandLine(unittest.TestCase):
def run_script(self, *args):
return subprocess.run([sys.executable, str(SCRIPT), *args],
capture_output=True, text=True)
def copy(self, src: Path) -> Path:
d = tempfile.mkdtemp()
dst = Path(d) / src.name
dst.write_bytes(src.read_bytes())
self.addCleanup(lambda: (dst.unlink(), os.rmdir(d)))
return dst
def test_check_exit_codes(self):
self.assertEqual(self.run_script("--check", str(BEFORE)).returncode, 1)
self.assertEqual(self.run_script("--check", str(AFTER)).returncode, 0)
self.assertEqual(read(BEFORE).count("<translation></translation>"), 5,
"--check must not write")
def test_rewrite_then_check_passes(self):
tmp = self.copy(BEFORE)
r = self.run_script(str(tmp))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("mirrored 8 message(s), 3 plural form(s)", r.stdout)
self.assertEqual(read(tmp), read(AFTER))
self.assertEqual(self.run_script("--check", str(tmp)).returncode, 0)
def test_child_element_exits_2_and_leaves_file(self):
tmp = self.copy(BEFORE)
bad = read(tmp).replace("<source>Ouvrir le projet</source>",
'<source>a<byte value="7"/>b</source>')
tmp.write_text(bad, encoding="utf-8", newline="")
r = self.run_script(str(tmp))
self.assertEqual(r.returncode, 2)
self.assertIn("child element", r.stderr)
self.assertEqual(read(tmp), bad)
@unittest.skipUnless(REPO_FR.is_file(), "lang/qet_fr.ts not found next to the tool")
def test_repository_french_file_is_mirrored(self):
"""The maintenance rule: run the mirror after update_translations."""
r = self.run_script("--check", str(REPO_FR))
self.assertEqual(r.returncode, 0,
f"lang/qet_fr.ts needs a mirror run:\n{r.stdout}")
if __name__ == "__main__":
unittest.main()