mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-08 13:04:13 +02:00
Merge pull request #1379 from bhangart/feature/fr-translation-mirror
Serve the French UI from qet_fr.ts instead of the code text
This commit is contained in:
+17
@@ -363,3 +363,20 @@ and running it concurrently with the normal `lrelease` compilation step (as
|
||||
would happen under `-j`/parallel builds) can corrupt a `.ts` file mid-write,
|
||||
causing `lrelease` to fail with "Premature end of document". Run it on its
|
||||
own, review the diff, and commit the updated `.ts` files separately.
|
||||
|
||||
The strings in the code are French, so `lang/qet_fr.ts` is the source
|
||||
language's own file. After `update_translations`, run
|
||||
|
||||
```sh
|
||||
python3 misc/i18n/mirror_source_language.py lang/qet_fr.ts
|
||||
```
|
||||
|
||||
before committing: it copies every new French source string into its own
|
||||
translation, so `qet_fr.qm` stays complete and French wording can later be
|
||||
corrected in the `.ts` alone, without changing the key that the other
|
||||
languages are attached to. Forgetting it breaks nothing (an empty entry
|
||||
falls back to the code text); `--check` reports whether a run is needed.
|
||||
A French correction typed into `qet_fr.ts` is lost like any other
|
||||
translation when the code string is reworded; the vanished entry keeps the
|
||||
old text for recovery. Never add `-removeidentical` to the `lrelease`
|
||||
options: it would strip these entries again.
|
||||
|
||||
+6304
-5146
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="fr_FR">
|
||||
<context>
|
||||
<name>Alpha</name>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="10"/>
|
||||
<source>Ouvrir le projet</source>
|
||||
<translation>Ouvrir le projet</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="11"/>
|
||||
<source>Fermer le projet</source>
|
||||
<translation>Fermer le projet</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="12"/>
|
||||
<source>Apply to Entire Project</source>
|
||||
<translation>Appliquer au projet entier</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="13"/>
|
||||
<source>QElectroTech</source>
|
||||
<translation>QElectroTech</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="14"/>
|
||||
<source>Brouillon</source>
|
||||
<translation type="unfinished">Brouillon (à revoir)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="15"/>
|
||||
<source>Copier</source>
|
||||
<translation>Copier</translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<location filename="../sources/alpha.cpp" line="16"/>
|
||||
<source>%n page(s)</source>
|
||||
<translation>
|
||||
<numerusform>%n page(s)</numerusform>
|
||||
<numerusform>%n page(s)</numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<location filename="../sources/alpha.cpp" line="20"/>
|
||||
<source>%n élément(s)</source>
|
||||
<translation>
|
||||
<numerusform>%n élément</numerusform>
|
||||
<numerusform>%n élément(s)</numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<location filename="../sources/alpha.cpp" line="21"/>
|
||||
<source>%n conducteur(s)</source>
|
||||
<translation>
|
||||
<numerusform>%n conducteur(s)</numerusform>
|
||||
<numerusform>%n conducteur(s)</numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Ancien texte</source>
|
||||
<translation type="vanished">Ancien texte</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Autre ancien texte</source>
|
||||
<translation type="vanished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>Beta</name>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="30"/>
|
||||
<source><b>%1</b> : %2</source>
|
||||
<translation><b>%1</b> : %2</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="31"/>
|
||||
<source>&Aide</source>
|
||||
<comment>menu</comment>
|
||||
<translation>&Aide</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="32"/>
|
||||
<source>l'élément "x"</source>
|
||||
<translation>l'élément "x"</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="33"/>
|
||||
<source> Ligne 1
|
||||
Ligne 2
|
||||
</source>
|
||||
<translation> Ligne 1
|
||||
Ligne 2
|
||||
</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="fr_FR">
|
||||
<context>
|
||||
<name>Alpha</name>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="10"/>
|
||||
<source>Ouvrir le projet</source>
|
||||
<translation></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="11"/>
|
||||
<source>Fermer le projet</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="12"/>
|
||||
<source>Apply to Entire Project</source>
|
||||
<translation>Appliquer au projet entier</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="13"/>
|
||||
<source>QElectroTech</source>
|
||||
<translation>QElectroTech</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="14"/>
|
||||
<source>Brouillon</source>
|
||||
<translation type="unfinished">Brouillon (à revoir)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/alpha.cpp" line="15"/>
|
||||
<source>Copier</source>
|
||||
<translation type="unfinished">Copier</translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<location filename="../sources/alpha.cpp" line="16"/>
|
||||
<source>%n page(s)</source>
|
||||
<translation type="unfinished">
|
||||
<numerusform>%n page(s)</numerusform>
|
||||
<numerusform>%n page(s)</numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<location filename="../sources/alpha.cpp" line="20"/>
|
||||
<source>%n élément(s)</source>
|
||||
<translation>
|
||||
<numerusform>%n élément</numerusform>
|
||||
<numerusform></numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<location filename="../sources/alpha.cpp" line="21"/>
|
||||
<source>%n conducteur(s)</source>
|
||||
<translation type="unfinished">
|
||||
<numerusform></numerusform>
|
||||
<numerusform></numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Ancien texte</source>
|
||||
<translation type="vanished">Ancien texte</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Autre ancien texte</source>
|
||||
<translation type="vanished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>Beta</name>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="30"/>
|
||||
<source><b>%1</b> : %2</source>
|
||||
<translation></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="31"/>
|
||||
<source>&Aide</source>
|
||||
<comment>menu</comment>
|
||||
<translation></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="32"/>
|
||||
<source>l'élément "x"</source>
|
||||
<translation></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../sources/beta.cpp" line="33"/>
|
||||
<source> Ligne 1
|
||||
Ligne 2
|
||||
</source>
|
||||
<translation></translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2006-2026 The QElectroTech Team
|
||||
# This file is part of QElectroTech.
|
||||
#
|
||||
# QElectroTech is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# QElectroTech is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""
|
||||
mirror_source_language.py — give every source-language string its own
|
||||
translation in the source language's .ts file.
|
||||
|
||||
python3 misc/i18n/mirror_source_language.py lang/qet_fr.ts
|
||||
python3 misc/i18n/mirror_source_language.py --check lang/qet_fr.ts
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
The strings in the code are French, and they are both the translation
|
||||
key and the text the French UI shows. lang/qet_fr.ts was almost empty:
|
||||
an empty entry falls back to the code text at run time, so French never
|
||||
needed a translation. The price is that a French wording fix is a key
|
||||
change, which orphans the translation of that string in every other
|
||||
language file.
|
||||
|
||||
With every French string mirrored into qet_fr.ts, the French UI is
|
||||
served from qet_fr.qm like any other language, and French wording can
|
||||
be corrected in the .ts alone. The key in the code then only has to
|
||||
stay stable.
|
||||
|
||||
WHAT IT DOES
|
||||
|
||||
For every message whose translation is empty, the source text is copied
|
||||
into the translation and the entry is finished. Nothing else changes:
|
||||
|
||||
- a translation with text is never touched, whether it is a French
|
||||
rendering of an English source, identical to the source, or marked
|
||||
unfinished (a translator's work in progress), with one exception:
|
||||
an unfinished translation identical to its source is finished.
|
||||
That is what lupdate's same-text heuristic leaves behind when a new
|
||||
string repeats an existing one, and it is what a mirror run would
|
||||
have written anyway;
|
||||
- vanished and obsolete entries are left to lupdate;
|
||||
- in a plural message only the empty <numerusform>s are filled;
|
||||
- the file stays byte-identical outside the rewritten <translation>
|
||||
elements: header, locations, comments, indentation, escaping.
|
||||
|
||||
The source text is copied as lupdate wrote it, escaping included, so
|
||||
' stays '. A <source> with a child element (lupdate's <byte/>)
|
||||
is refused rather than guessed at.
|
||||
|
||||
Run it after `cmake --build . --target update_translations`, before
|
||||
committing. Forgetting it breaks nothing: an empty entry still falls
|
||||
back to the code text, and the next run fills it. `--check` exits 1
|
||||
when a run would change the file, so it can gate a commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
MESSAGE_RE = re.compile(r"<message(?P<attrs>[^>]*)>(?P<body>.*?)</message>", re.S)
|
||||
SOURCE_RE = re.compile(r"<source>(?P<text>.*?)</source>", re.S)
|
||||
TRANSLATION_RE = re.compile(
|
||||
r"<translation(?P<attrs>[^>]*)>(?P<body>.*?)</translation>", re.S)
|
||||
FORM_RE = re.compile(r"<numerusform>(?P<text>.*?)</numerusform>", re.S)
|
||||
TYPE_RE = re.compile(r'\s*type="(?P<type>[^"]*)"')
|
||||
|
||||
# lupdate's own layout: <translation> at 8 spaces, <numerusform> at 12.
|
||||
FORM_INDENT = " " * 12
|
||||
CLOSE_INDENT = " " * 8
|
||||
|
||||
|
||||
class SourceError(ValueError):
|
||||
"""A <source> that cannot be copied verbatim."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
mirrored_messages: int = 0
|
||||
mirrored_forms: int = 0
|
||||
finished_identical: int = 0
|
||||
unfinished_cleared: int = 0
|
||||
kept_translated: int = 0
|
||||
kept_identical: int = 0
|
||||
kept_vanished: int = 0
|
||||
kept_unfinished_with_text: int = 0
|
||||
|
||||
def changed(self) -> bool:
|
||||
return self.mirrored_messages > 0 or self.finished_identical > 0
|
||||
|
||||
def summary(self) -> str:
|
||||
return (
|
||||
f"mirrored {self.mirrored_messages} message(s), "
|
||||
f"{self.mirrored_forms} plural form(s), "
|
||||
f"finished {self.finished_identical} identical unfinished, "
|
||||
f"cleared {self.unfinished_cleared} unfinished flag(s); kept "
|
||||
f"{self.kept_translated} translated, {self.kept_identical} identical, "
|
||||
f"{self.kept_vanished} vanished, "
|
||||
f"{self.kept_unfinished_with_text} unfinished with text")
|
||||
|
||||
|
||||
def _translation_type(attrs: str) -> str:
|
||||
m = TYPE_RE.search(attrs)
|
||||
return m.group("type") if m else ""
|
||||
|
||||
|
||||
def _finish(whole: str, body: str, tm: re.Match, tattrs: str,
|
||||
stats: Stats) -> str:
|
||||
"""Drop the unfinished flag of a translation equal to its source."""
|
||||
stats.finished_identical += 1
|
||||
stats.unfinished_cleared += 1
|
||||
start = whole.index(body) + tm.start()
|
||||
old = tm.group(0)
|
||||
new = old.replace(f"<translation{tattrs}>",
|
||||
f"<translation{TYPE_RE.sub('', tattrs)}>", 1)
|
||||
return whole[:start] + new + whole[start + len(old):]
|
||||
|
||||
|
||||
def _mirror_message(match: re.Match, forms: int, stats: Stats) -> str:
|
||||
whole = match.group(0)
|
||||
attrs, body = match.group("attrs"), match.group("body")
|
||||
numerus = 'numerus="yes"' in attrs
|
||||
sm = SOURCE_RE.search(body)
|
||||
tm = TRANSLATION_RE.search(body)
|
||||
if sm is None or tm is None:
|
||||
return whole
|
||||
source = sm.group("text")
|
||||
if "<" in source:
|
||||
raise SourceError(
|
||||
"a <source> contains a child element and cannot be copied: "
|
||||
+ source[:60])
|
||||
tattrs, tbody = tm.group("attrs"), tm.group("body")
|
||||
ttype = _translation_type(tattrs)
|
||||
if ttype in ("vanished", "obsolete"):
|
||||
stats.kept_vanished += 1
|
||||
return whole
|
||||
|
||||
if numerus:
|
||||
found = FORM_RE.findall(tbody)
|
||||
empty = [f for f in found if f == ""]
|
||||
if found and not empty:
|
||||
if ttype == "unfinished" and all(f == source for f in found):
|
||||
return _finish(whole, body, tm, tattrs, stats)
|
||||
if ttype == "unfinished":
|
||||
stats.kept_unfinished_with_text += 1
|
||||
elif all(f == source for f in found):
|
||||
stats.kept_identical += 1
|
||||
else:
|
||||
stats.kept_translated += 1
|
||||
return whole
|
||||
if found:
|
||||
new_body = FORM_RE.sub(
|
||||
lambda f: f.group(0) if f.group("text") != ""
|
||||
else f"<numerusform>{source}</numerusform>", tbody)
|
||||
stats.mirrored_forms += len(empty)
|
||||
else:
|
||||
new_body = "\n" + "".join(
|
||||
f"{FORM_INDENT}<numerusform>{source}</numerusform>\n"
|
||||
for _ in range(forms)) + CLOSE_INDENT
|
||||
stats.mirrored_forms += forms
|
||||
else:
|
||||
if tbody != "":
|
||||
if ttype == "unfinished" and tbody == source:
|
||||
return _finish(whole, body, tm, tattrs, stats)
|
||||
if ttype == "unfinished":
|
||||
stats.kept_unfinished_with_text += 1
|
||||
elif tbody == source:
|
||||
stats.kept_identical += 1
|
||||
else:
|
||||
stats.kept_translated += 1
|
||||
return whole
|
||||
new_body = source
|
||||
|
||||
stats.mirrored_messages += 1
|
||||
if ttype == "unfinished":
|
||||
stats.unfinished_cleared += 1
|
||||
kept_attrs = TYPE_RE.sub("", tattrs)
|
||||
new_translation = f"<translation{kept_attrs}>{new_body}</translation>"
|
||||
new_message_body = body[:tm.start()] + new_translation + body[tm.end():]
|
||||
return f"<message{attrs}>{new_message_body}</message>"
|
||||
|
||||
|
||||
def mirror_text(text: str, forms: int = 2) -> tuple[str, Stats]:
|
||||
"""Return the mirrored .ts text and what was done to it."""
|
||||
stats = Stats()
|
||||
new = MESSAGE_RE.sub(lambda m: _mirror_message(m, forms, stats), text)
|
||||
return new, stats
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Copy each empty translation's source text into the "
|
||||
"translation of a source-language .ts file.")
|
||||
parser.add_argument("ts", type=Path, help="the .ts file, e.g. lang/qet_fr.ts")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="change nothing; exit 1 if a run would change the file")
|
||||
parser.add_argument("--forms", type=int, default=2,
|
||||
help="plural forms to write when a plural message has "
|
||||
"none (default 2, French)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with open(args.ts, encoding="utf-8", newline="") as f:
|
||||
text = f.read()
|
||||
try:
|
||||
new, stats = mirror_text(text, args.forms)
|
||||
ET.fromstring(new.encode("utf-8"))
|
||||
except (SourceError, ET.ParseError) as e:
|
||||
print(f"{args.ts}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"{args.ts}: {stats.summary()}")
|
||||
if args.check:
|
||||
return 1 if stats.changed() else 0
|
||||
if new != text:
|
||||
with open(args.ts, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(new)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/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):
|
||||
"""' must stay ', never become &apos; (a re-escape bug)."""
|
||||
out, _ = m.mirror_text(read(BEFORE))
|
||||
for raw in ("<translation><b>%1</b> : %2</translation>",
|
||||
"<translation>&Aide</translation>",
|
||||
"<translation>l'élément "x"</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()
|
||||
Reference in New Issue
Block a user