Files
qelectrotech-source-mirror/misc/i18n/test_mirror_source_language.py
T
Beat Hangartner f32d9365e5 Finish unfinished French entries that equal their source
After a lupdate refresh, the same-text heuristic fills a new string with
the translation of an existing identical one and marks it unfinished.
In the source-language file that guess is the source text itself, which
is exactly what a mirror run writes, but the script left it alone as
"work in progress": 39 such entries stayed unfinished in qet_fr.ts after
refreshing it on 2026-10-07.

The script now finishes an unfinished translation (or plural with all
forms) that is identical to its source. Unfinished translations with
other text stay untouched. Two fixture messages and a test cover it.

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

211 lines
8.7 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.finished_identical, 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.finished_identical, 2)
self.assertEqual(stats.unfinished_cleared, 4)
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_unfinished_identical_to_source_is_finished(self):
"""lupdate's same-text heuristic leaves these behind after a refresh;
39 of them appeared in lang/qet_fr.ts on 2026-10-07."""
out, _ = m.mirror_text(read(BEFORE))
msgs = messages(out)
for key in [("Alpha", "Copier"), ("Alpha", "%n page(s)")]:
self.assertIsNone(msgs[key].find("translation").get("type"), key)
self.assertEqual(msgs[("Alpha", "Brouillon")].find("translation").get("type"),
"unfinished", "a different unfinished text stays unfinished")
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()