mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 06:57:51 +00:00
* Translated using Weblate (Italian) Currently translated at 100.0% (2727 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Portuguese) Currently translated at 6.8% (188 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pt/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2727 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (3070 of 3070 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (3070 of 3070 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Portuguese) Currently translated at 100.0% (3070 of 3070 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt/ * Translated using Weblate (Portuguese) Currently translated at 12.3% (337 of 2727 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pt/ * translations: fix hu ios placeholders * translations: import/export ios * android: translate badges, names, what's new * ios: translate badges, names, what's new * android: remove stale v7_1_invest_learn_more * translations: import/export ios * translations: fix fr ios file size string * translations: fix errors in ca, de, es, fr, in, it, nl, pl, pt, ro, vi, zh translations (#8031) Corrects mistranslations, typos, broken grammar and inconsistent terms found in the review of #8030: badge names inserted mid-sentence in es, ledger labels in ca/fr/in/vi, corrupted pt iOS strings, pt "dístico" vs the locale's "distintivo", and zh-rCN strings that do not read with the countdown. * android: fix stale supporter badges title * android: fix flagged tr, uk, zh, lv strings * ios: fix zh-Hans months interval * ios: warn about outdated and mismatched strings * android: align es, tr, zh strings with ios * ios: align uk invested string with android --------- Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org> Co-authored-by: Fábio Pereira (Serzedo) <fpereira-84@users.noreply.hosted.weblate.org> Co-authored-by: summoner001 <summoner@disroot.org> Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
807 lines
45 KiB
Python
807 lines
45 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
import localizations as l10n # noqa: E402
|
||
|
||
LOCALIZABLE = "en.lproj/Localizable.strings"
|
||
NOTE = "No comment provided by engineer."
|
||
|
||
|
||
def unit_xml(unit_id: str, source: str, target: str | None = None, state: str | None = None) -> str:
|
||
lines = [f' <trans-unit id="{unit_id}" xml:space="preserve">\n', f" <source>{source}</source>\n"]
|
||
if target is not None:
|
||
attributes = f' state="{state}"' if state else ""
|
||
lines.append(f" <target{attributes}>{target}</target>\n" if target else f" <target{attributes}></target>\n")
|
||
lines += [f" <note>{NOTE}</note>\n", " </trans-unit>\n"]
|
||
return "".join(lines)
|
||
|
||
|
||
def xliff_xml(language: str, units: list[str], original: str = LOCALIZABLE) -> str:
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">\n'
|
||
f' <file original="{original}" source-language="en" target-language="{language}" datatype="plaintext">\n'
|
||
" <body>\n" + "".join(units) + " </body>\n"
|
||
" </file>\n"
|
||
"</xliff>\n"
|
||
)
|
||
|
||
|
||
class RepositoryTestCase(unittest.TestCase):
|
||
"""Runs each test in a temporary directory laid out like the repository root."""
|
||
|
||
def setUp(self) -> None:
|
||
self.previous_cwd = os.getcwd()
|
||
self.directory = tempfile.TemporaryDirectory()
|
||
os.chdir(self.directory.name)
|
||
for language in ("en", "de", "uk"):
|
||
(l10n.IOS_DIR / f"{language}.lproj").mkdir(parents=True)
|
||
l10n.xliff_path(language).parent.mkdir(parents=True)
|
||
|
||
def tearDown(self) -> None:
|
||
os.chdir(self.previous_cwd)
|
||
self.directory.cleanup()
|
||
|
||
def write(self, path: Path, text: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
l10n.write_file(path, text)
|
||
|
||
|
||
class PlaceholderTest(unittest.TestCase):
|
||
def test_parse_sequential_positional_and_spelling(self) -> None:
|
||
parsed = l10n.parse_placeholders("%@ sent %lld files, %02d left, %.1f%%")
|
||
self.assertEqual(parsed.arguments, {1: "@", 2: "lld", 3: "02d", 4: ".1f"})
|
||
self.assertTrue(parsed.escaped_percent)
|
||
self.assertEqual(parsed.invalid, [])
|
||
self.assertFalse(parsed.mixed)
|
||
self.assertEqual(l10n.parse_placeholders("%2$@ %1$lld").arguments, {1: "lld", 2: "@"})
|
||
|
||
def test_parse_invalid_mixed_and_conflicting(self) -> None:
|
||
self.assertEqual(l10n.parse_placeholders("%их файл").invalid, ["%и"])
|
||
self.assertEqual(l10n.parse_placeholders("%LLD groups").invalid, ["%L"])
|
||
self.assertEqual(l10n.parse_placeholders("% % off").invalid, ["% %"])
|
||
self.assertTrue(l10n.parse_placeholders("%1$@ and %@").mixed)
|
||
self.assertEqual(l10n.parse_placeholders("%1$@ %1$d").invalid, ["%1$d"])
|
||
self.assertFalse(l10n.parse_placeholders("100 percent").is_format)
|
||
|
||
def test_problems_found_in_weblate(self) -> None:
|
||
cases = {
|
||
("%lld messages moderated by %@", "%@ %lld üzenetet moderált"): "expected %1$lld, %2$@, found %1$@, %2$lld",
|
||
("%@ at %@:", "1$@, %2$@'de:"): "expected %1$@, %2$@, found %2$@",
|
||
("%d file(s) were deleted.", "%их файл(ів) було видалено."): "invalid placeholders '%и'",
|
||
("Desktop app version %@ is not compatible.", 'versión %" no es compatible.'): "invalid placeholders '%\"'",
|
||
("Error: %@", "错误: %@"): "expected %1$@, found none",
|
||
("You are already connecting!", "Ви вже підключаєтеся до %@."): "placeholders not in the source: %1$@",
|
||
("%lld new interface languages", "%d nové jazyky rozhraní"): "expected %1$lld, found %1$d",
|
||
("changed your role to %@", "你修改了 %1$@ 你的身份為 %@"): "positional and non-positional placeholders mixed",
|
||
("Sent at: %@", "Posláno v: % @"): "expected %1$@, found %1$ @",
|
||
("%1$@ changed to %2$@", "Role %1$@ byla změněna na %2$ @"): "expected %1$@, %2$@, found %1$@, %2$ @",
|
||
("%1$02d hrs", "%1$2d h"): "expected %1$02d, found %1$2d",
|
||
}
|
||
for (source, target), expected in cases.items():
|
||
with self.subTest(target=target):
|
||
self.assertEqual(l10n.placeholder_problem(source, target), expected)
|
||
|
||
def test_valid_translations_pass(self) -> None:
|
||
cases = [
|
||
("%lld messages moderated by %@", "%2$@ %1$lld üzenetet moderált"),
|
||
("%1$@ at %2$@:", "%1$@, %2$@'de:"),
|
||
("%lld%% off", "%lld%% Rabatt"),
|
||
("Battery at 100%", "Akku bei 100 %"),
|
||
("No placeholders", "Keine Platzhalter"),
|
||
("Join [group](simplex:/g#a%3Db%2F) by %@", "%@ tritt [Gruppe](simplex:/g#a%3Db%2F) bei"),
|
||
]
|
||
for source, target in cases:
|
||
with self.subTest(target=target):
|
||
self.assertIsNone(l10n.placeholder_problem(source, target))
|
||
|
||
def test_describe_arguments(self) -> None:
|
||
self.assertEqual(l10n.describe_arguments({2: "@", 1: "lld"}), "%1$lld, %2$@")
|
||
self.assertEqual(l10n.describe_arguments({}), "none")
|
||
|
||
|
||
class LinkTest(unittest.TestCase):
|
||
SOURCE = "Improved [directory bot](simplex:/contact#/?v=1-4%26dh%3DMCow) and https://simplex.chat/docs."
|
||
|
||
def test_links_and_masking(self) -> None:
|
||
self.assertEqual(l10n.links(self.SOURCE), (("simplex:/contact#/?v=1-4%26dh%3DMCow",), ("https://simplex.chat/docs.",)))
|
||
self.assertEqual(l10n.links("见 https://github.com/simplex-chat)!"), ((), ("https://github.com/simplex-chat",)))
|
||
self.assertEqual(l10n.mask_links(self.SOURCE), "Improved [directory bot](<link>) and <link>")
|
||
|
||
def test_link_problems(self) -> None:
|
||
intact = "Подобрен [бот](simplex:/contact#/?v=1-4%26dh%3DMCow) и https://simplex.chat/docs."
|
||
broken = "Подобрен [бот](simplex:/contact#/?v=1-4% 26dh%3DMCow) и https://simplex.chat/docs."
|
||
self.assertIsNone(l10n.link_problem(self.SOURCE, intact))
|
||
self.assertEqual(l10n.link_problem(self.SOURCE, broken), "markdown link changed or missing: 'simplex:/contact#/?v=1-4%26dh%3DMCow'")
|
||
self.assertEqual(l10n.link_problem("No link", "See https://evil.example"), "URL not in the source: 'https://evil.example'")
|
||
full_width = "Подобрен [бот](simplex:/contact#/?v=1-4%26dh%3DMCow) и https://simplex.chat/docs."
|
||
self.assertEqual(l10n.link_problem(self.SOURCE, full_width), "markdown link changed or missing: 'simplex:/contact#/?v=1-4%26dh%3DMCow'")
|
||
self.assertEqual(l10n.link_problem("[a](https://x.example) [b](https://x.example)", "[a](https://x.example)"), "markdown link not in the source: 'https://x.example'")
|
||
self.assertIsNone(l10n.link_problem("No link", "Kein Link"))
|
||
|
||
def test_broken_link_is_not_reported_as_placeholder(self) -> None:
|
||
broken = "Подобрен [бот](simplex:/contact#/?v=1-4% 26dh%3DMCow) и https://simplex.chat/docs."
|
||
self.assertIsNone(l10n.placeholder_problem(self.SOURCE, broken))
|
||
problems = l10n.translation_problems(Path("bg.xliff"), "key", self.SOURCE, broken)
|
||
self.assertEqual([p.kind for p in problems], [l10n.LINK])
|
||
|
||
|
||
class StringsParserTest(unittest.TestCase):
|
||
def test_parses_escapes_and_comments(self) -> None:
|
||
text = '/* note */\n"a\\"b" = "line\\nnext \\U00e9";\n// comment\n"_italic_" = "\\\\_kursiv_";\n'
|
||
self.assertEqual(l10n.parse_strings(text), [('a"b', "line\nnext é"), ("_italic_", "\\_kursiv_")])
|
||
|
||
def test_empty_file(self) -> None:
|
||
self.assertEqual(l10n.parse_strings("/* only a comment */\n"), [])
|
||
|
||
def test_syntax_errors(self) -> None:
|
||
for text in ['"key" = "value"', '"key" "value";', '"unterminated = "x";\n', "/* open"]:
|
||
with self.subTest(text=text), self.assertRaises(l10n.StringsSyntaxError):
|
||
l10n.parse_strings(text)
|
||
|
||
|
||
class XliffTransformTest(unittest.TestCase):
|
||
def test_split_and_join_keep_the_text_byte_for_byte(self) -> None:
|
||
text = xliff_xml("de", [unit_xml("A", "A", "Ä", "translated"), unit_xml("B", "B")])
|
||
segments = l10n.split_xliff(text)
|
||
self.assertEqual(l10n.join_xliff(segments), text)
|
||
blocks = list(l10n.unit_blocks(segments))
|
||
self.assertEqual([b.key for b in blocks], [(LOCALIZABLE, "A"), (LOCALIZABLE, "B")])
|
||
self.assertEqual(blocks[0].unit, l10n.Unit("A", "Ä"))
|
||
self.assertIsNone(blocks[1].unit.target)
|
||
|
||
def test_split_rejects_units_sharing_lines(self) -> None:
|
||
text = xliff_xml("de", [' <trans-unit id="A"><source>A</source></trans-unit><trans-unit id="B"><source>B</source></trans-unit>\n'])
|
||
with self.assertRaises(ValueError):
|
||
l10n.split_xliff(text)
|
||
|
||
def test_strip_empty_targets(self) -> None:
|
||
segments = l10n.split_xliff(xliff_xml("de", [unit_xml("A", "A", "", "needs-translation"), unit_xml("B", "B", "Bee")]))
|
||
self.assertEqual(l10n.strip_empty_targets(segments), 1)
|
||
self.assertEqual(l10n.join_xliff(segments), xliff_xml("de", [unit_xml("A", "A"), unit_xml("B", "B", "Bee")]))
|
||
self.assertEqual(l10n.strip_empty_targets(segments), 0)
|
||
|
||
def test_drop_untranslated_units(self) -> None:
|
||
segments = l10n.split_xliff(xliff_xml("de", [unit_xml("A", "A"), unit_xml("B", "B", "Bee"), unit_xml("C", "C", "")]))
|
||
self.assertEqual(l10n.join_xliff(l10n.drop_untranslated_units(segments)), xliff_xml("de", [unit_xml("B", "B", "Bee")]))
|
||
|
||
def test_multiline_target_counts_as_translated(self) -> None:
|
||
unit = ' <trans-unit id="M" xml:space="preserve">\n <source>M</source>\n <target>\nzwei Zeilen</target>\n </trans-unit>\n'
|
||
text = xliff_xml("de", [unit])
|
||
segments = l10n.split_xliff(text)
|
||
self.assertEqual(l10n.join_xliff(l10n.drop_untranslated_units(segments)), text)
|
||
self.assertEqual(l10n.saved_translations(segments)[(LOCALIZABLE, "M")].lines, [" <target>\n", "zwei Zeilen</target>\n"])
|
||
|
||
def test_restore_translations_dropped_by_xcode(self) -> None:
|
||
weblate = xliff_xml("de", [unit_xml("A", "A", "Entwurf", "needs-translation"), unit_xml("B", "B", "Bee"), unit_xml("C", "Old C", "Zeh")])
|
||
exported = l10n.split_xliff(xliff_xml("de", [unit_xml("A", "A"), unit_xml("B", "B", "Bee"), unit_xml("C", "New C")]))
|
||
self.assertEqual(l10n.restore_translations(exported, l10n.saved_translations(l10n.split_xliff(weblate))), 1)
|
||
expected = xliff_xml("de", [unit_xml("A", "A", "Entwurf", "needs-translation"), unit_xml("B", "B", "Bee"), unit_xml("C", "New C")])
|
||
self.assertEqual(l10n.join_xliff(exported), expected)
|
||
|
||
def test_restore_ignores_duplicate_ids_already_translated(self) -> None:
|
||
text = xliff_xml("ar", [unit_xml("Invite", "Invite"), unit_xml("Invite", "Invite", "دعوة")])
|
||
segments = l10n.split_xliff(text)
|
||
self.assertEqual(l10n.restore_translations(segments, l10n.saved_translations(segments)), 0)
|
||
self.assertEqual(l10n.join_xliff(segments), text)
|
||
|
||
def test_translation_changes(self) -> None:
|
||
path = Path("de.xliff")
|
||
before = xliff_xml("de", [unit_xml("A", "A", "Ah"), unit_xml("B", "B", "Bee"), unit_xml("Gone", "Gone", "Weg"), unit_xml("U", "U")])
|
||
after = l10n.split_xliff(xliff_xml("de", [unit_xml("B", "B", "Beh")]))
|
||
source_keys = {(LOCALIZABLE, "A"), (LOCALIZABLE, "B"), (LOCALIZABLE, "U")}
|
||
problems, removed = l10n.translation_changes(path, l10n.saved_translations(l10n.split_xliff(before)), after, source_keys, {})
|
||
self.assertEqual(removed, 1)
|
||
self.assertEqual([(p.kind, p.key, p.text) for p in problems], [(l10n.LOST, "A", "Ah"), (l10n.CHANGED, "B", "Beh")])
|
||
|
||
|
||
class FilesTest(RepositoryTestCase):
|
||
def test_app_languages_put_the_source_first(self) -> None:
|
||
self.assertEqual(l10n.app_languages(), ["en", "de", "uk"])
|
||
|
||
def test_language_of(self) -> None:
|
||
self.assertEqual(l10n.language_of(Path("apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings")), "zh-Hans")
|
||
self.assertEqual(l10n.language_of(l10n.xliff_path("pt-BR")), "pt-BR")
|
||
|
||
def test_check_files_reports_each_kind(self) -> None:
|
||
self.write(l10n.xliff_path("en"), xliff_xml("en", [unit_xml("%@ server", "%@ server", "%@ server")]))
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("%@ server", "%@ server", "Server"), unit_xml("Ok", "Ok", "")]))
|
||
self.write(l10n.IOS_DIR / "de.lproj/Localizable.strings", '"Blank" = "";\n"%@ server" = "%@ Server";\n')
|
||
self.write(l10n.IOS_DIR / "uk.lproj/Localizable.strings", '"Broken" = "x"\n')
|
||
self.write(l10n.xliff_path("uk"), "<xliff><file>\n")
|
||
self.write(l10n.IOS_DIR / "en.lproj/Localizable.strings", '"Ignored" = "";\n')
|
||
self.write(l10n.xcloc_path("de") / "Source Contents/en.lproj/Localizable.strings", '"Ignored" = "";\n')
|
||
stats: dict[str, l10n.LanguageStats] = {}
|
||
with mock.patch.object(l10n.shutil, "which", return_value=None):
|
||
problems = l10n.check_files(stats, {})
|
||
self.assertEqual(
|
||
[(p.kind, p.language, p.key) for p in problems],
|
||
[
|
||
(l10n.STALE, "de", "Ok"),
|
||
(l10n.PLACEHOLDER, "de", "%@ server"),
|
||
(l10n.BLANK, "de", "Ok"),
|
||
(l10n.SYNTAX, "uk", ""),
|
||
(l10n.BLANK, "de", "Blank"),
|
||
(l10n.SYNTAX, "uk", ""),
|
||
],
|
||
)
|
||
self.assertEqual(stats["de"].translated, 1)
|
||
|
||
def test_strings_problems_already_in_the_xliff_are_listed_once(self) -> None:
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("%@ server", "%@ server", "Server")]))
|
||
self.write(l10n.IOS_DIR / "de.lproj/Localizable.strings", '"%@ server" = "Server";\n"%@ file" = "Datei";\n')
|
||
with mock.patch.object(l10n.shutil, "which", return_value=None):
|
||
problems = l10n.check_files({}, {})
|
||
self.assertEqual([(p.path.suffix, p.key) for p in problems], [(".xliff", "%@ server"), (".strings", "%@ file")])
|
||
|
||
def test_check_xliff_reports_duplicates_and_stale_units(self) -> None:
|
||
path = l10n.xliff_path("de")
|
||
self.write(path, xliff_xml("de", [unit_xml("A", "A"), unit_xml("A", "A", "Ah"), unit_xml("Old", "Old", "Alt")]))
|
||
problems, translated = l10n.check_xliff(path, {(LOCALIZABLE, "A")}, {})
|
||
self.assertEqual(translated, 2)
|
||
self.assertEqual(
|
||
[(p.kind, p.key, p.detail) for p in problems],
|
||
[
|
||
(l10n.DUPLICATE, "A", f"2 units with this id in {LOCALIZABLE}"),
|
||
(l10n.STALE, "Old", "not in en.xliff, Xcode's export removes it"),
|
||
],
|
||
)
|
||
self.assertEqual(l10n.check_xliff(path, set(), {})[0][0].kind, l10n.DUPLICATE)
|
||
self.assertEqual(len(l10n.check_xliff(path, set(), {})[0]), 1)
|
||
|
||
def test_lint_with_plutil_reports_failures(self) -> None:
|
||
output = "a.strings: OK\nb.strings: Unexpected character \" at line 2\n"
|
||
completed = mock.Mock(stdout=output, stderr="")
|
||
with mock.patch.object(l10n.shutil, "which", return_value="/usr/bin/plutil"), mock.patch.object(l10n.subprocess, "run", return_value=completed) as run:
|
||
problems = l10n.lint_with_plutil([Path("a.strings"), Path("b.strings")])
|
||
run.assert_called_once()
|
||
self.assertEqual([(p.kind, str(p.path), p.detail) for p in problems], [(l10n.SYNTAX, "b.strings", 'plutil: Unexpected character " at line 2')])
|
||
with mock.patch.object(l10n.shutil, "which", return_value=None):
|
||
self.assertEqual(l10n.lint_with_plutil([Path("a.strings")]), [])
|
||
|
||
|
||
class ReportTest(unittest.TestCase):
|
||
def test_report_exit_codes_and_blank_grouping(self) -> None:
|
||
path = Path("apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff")
|
||
problems = [l10n.Problem(l10n.BLANK, path, f"Key {i}", "empty target") for i in range(5)]
|
||
problems.append(l10n.Problem(l10n.PLACEHOLDER, path, "%@ server", "expected %1 object, found none", "Server"))
|
||
output = io.StringIO()
|
||
with contextlib.redirect_stdout(output), mock.patch.object(l10n, "app_languages", return_value=["en", "de"]):
|
||
self.assertEqual(l10n.report({"de": l10n.LanguageStats(translated=7)}, problems, {"en", "de"}), 1)
|
||
text = output.getvalue()
|
||
self.assertIn("5 x empty target", text)
|
||
self.assertIn("'Key 0', 'Key 1', 'Key 2' and 2 more", text)
|
||
self.assertIn("'%@ server'", text)
|
||
self.assertIn("6 problem(s): 1 placeholder, 5 blank", text)
|
||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(l10n, "app_languages", return_value=["en"]):
|
||
self.assertEqual(l10n.report({}, [], {"en"}), 0)
|
||
|
||
def test_warnings_do_not_fail(self) -> None:
|
||
path = Path("apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff")
|
||
problems = [l10n.Problem(l10n.STALE, path, f"Old {i}", "not in en.xliff") for i in range(4)]
|
||
problems.append(l10n.Problem(l10n.DUPLICATE, path, "A", "2 units with this id"))
|
||
output = io.StringIO()
|
||
with contextlib.redirect_stdout(output), mock.patch.object(l10n, "app_languages", return_value=["en"]):
|
||
self.assertEqual(l10n.report({}, problems, {"en"}), 0)
|
||
text = output.getvalue()
|
||
self.assertIn("4 x not in en.xliff", text)
|
||
self.assertIn("'Old 0', 'Old 1', 'Old 2' and 1 more", text)
|
||
self.assertIn("5 warning(s): 1 duplicate, 4 stale", text)
|
||
self.assertIn("No problems found.", text)
|
||
|
||
def test_preview_shortens_long_keys(self) -> None:
|
||
self.assertEqual(l10n.preview("short"), "short")
|
||
self.assertEqual(l10n.preview("x" * 150), "x" * l10n.KEY_PREVIEW_LENGTH + "…")
|
||
|
||
def test_format_duration(self) -> None:
|
||
self.assertEqual(l10n.format_duration(4.4), "4s")
|
||
self.assertEqual(l10n.format_duration(125), "2m 05s")
|
||
|
||
def test_paint_respects_color_setting(self) -> None:
|
||
with mock.patch.object(l10n, "USE_COLOR", False):
|
||
self.assertEqual(l10n.paint("x", l10n.RED), "x")
|
||
with mock.patch.object(l10n, "USE_COLOR", True):
|
||
self.assertEqual(l10n.paint("x", l10n.BOLD, l10n.RED), "\033[1;31mx\033[0m")
|
||
|
||
|
||
class UpdateTest(RepositoryTestCase):
|
||
def setUp(self) -> None:
|
||
super().setUp()
|
||
self.pbxproj = b"// original project\n"
|
||
self.write(l10n.PBXPROJ, self.pbxproj.decode())
|
||
self.weblate_de = xliff_xml("de", [unit_xml("A", "A", "Entwurf", "needs-translation"), unit_xml("B", "B", ""), unit_xml("%@ x", "%@ x", "%@ y")])
|
||
self.write(l10n.xliff_path("de"), self.weblate_de)
|
||
self.write(l10n.xliff_path("uk"), xliff_xml("uk", [unit_xml("A", "A", "А")]))
|
||
self.write(l10n.xliff_path("en"), xliff_xml("en", [unit_xml("A", "A", "A"), unit_xml("B", "B", "B"), unit_xml("%@ x", "%@ x", "%@ x")]))
|
||
self.imported: dict[str, str] = {}
|
||
|
||
def fake_xcodebuild(self, arguments: list[str], log: Path) -> None:
|
||
l10n.write_file(l10n.PBXPROJ, "// rewritten by Xcode\n")
|
||
if arguments[0] == "-importLocalizations":
|
||
language = Path(arguments[arguments.index("-localizationPath") + 1]).stem
|
||
self.imported[language] = l10n.read_file(l10n.xliff_path(language))
|
||
return
|
||
languages = [arguments[i + 1] for i, argument in enumerate(arguments) if argument == "-exportLanguage"]
|
||
self.assertEqual(languages, ["en", "de", "uk"])
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("A", "A"), unit_xml("B", "B"), unit_xml("%@ x", "%@ x", "%@ y"), unit_xml("New", "New")]))
|
||
|
||
def run_update(self) -> tuple[int, str]:
|
||
output = io.StringIO()
|
||
with contextlib.ExitStack() as stack:
|
||
stack.enter_context(mock.patch.object(l10n, "require_update_environment"))
|
||
stack.enter_context(mock.patch.object(l10n, "update_pbxproj_libraries"))
|
||
stack.enter_context(mock.patch.object(l10n, "xcodebuild", side_effect=self.fake_xcodebuild))
|
||
stack.enter_context(mock.patch.object(l10n.shutil, "which", return_value=None))
|
||
stack.enter_context(contextlib.redirect_stdout(output))
|
||
code = l10n.update()
|
||
return code, output.getvalue()
|
||
|
||
def test_update_round_trip(self) -> None:
|
||
code, output = self.run_update()
|
||
self.assertEqual(code, 0, output)
|
||
self.assertEqual(self.imported["de"], xliff_xml("de", [unit_xml("A", "A", "Entwurf", "needs-translation"), unit_xml("B", "B"), unit_xml("%@ x", "%@ x", "%@ y")]))
|
||
self.assertEqual(
|
||
l10n.read_file(l10n.xliff_path("de")),
|
||
xliff_xml("de", [unit_xml("A", "A", "Entwurf", "needs-translation"), unit_xml("%@ x", "%@ x", "%@ y")]),
|
||
)
|
||
self.assertEqual(l10n.PBXPROJ.read_bytes(), self.pbxproj)
|
||
self.assertIn("removed 1 empty targets", output)
|
||
self.assertIn("No problems found.", output)
|
||
|
||
def test_update_restores_the_project_when_xcode_fails(self) -> None:
|
||
def failing_xcodebuild(arguments: list[str], log: Path) -> None:
|
||
l10n.write_file(l10n.PBXPROJ, "// rewritten by Xcode\n")
|
||
raise l10n.UpdateError("xcodebuild -importLocalizations failed")
|
||
|
||
with mock.patch.object(l10n, "require_update_environment"), mock.patch.object(l10n, "update_pbxproj_libraries"), mock.patch.object(
|
||
l10n, "xcodebuild", side_effect=failing_xcodebuild
|
||
), contextlib.redirect_stdout(io.StringIO()):
|
||
with self.assertRaisesRegex(l10n.UpdateError, "git checkout -- apps/ios"):
|
||
l10n.update()
|
||
self.assertEqual(l10n.PBXPROJ.read_bytes(), self.pbxproj)
|
||
|
||
def test_update_reports_a_malformed_xliff(self) -> None:
|
||
self.write(l10n.xliff_path("uk"), "<xliff><file>\n")
|
||
with mock.patch.object(l10n, "require_update_environment"), contextlib.redirect_stdout(io.StringIO()):
|
||
with self.assertRaisesRegex(l10n.UpdateError, "uk.xliff: "):
|
||
l10n.update()
|
||
|
||
def test_require_update_environment_outside_macos(self) -> None:
|
||
with mock.patch.object(l10n.platform, "system", return_value="Linux"):
|
||
with self.assertRaisesRegex(l10n.UpdateError, "macOS with Xcode"):
|
||
l10n.require_update_environment()
|
||
|
||
|
||
class SwiftUsageTest(RepositoryTestCase):
|
||
def test_swift_string_literals(self) -> None:
|
||
swift = 'let a = "\\n"\nText("Hello, \\"you\\"")\nlet b = "\\(count) items"\nalert("Error\\u{2026}")\n'
|
||
self.write(l10n.IOS_DIR / "Shared/View.swift", swift)
|
||
self.assertEqual(
|
||
l10n.swift_string_literals(),
|
||
{'Hello, "you"': "apps/ios/Shared/View.swift:2", "Error\u2026": "apps/ios/Shared/View.swift:4"},
|
||
)
|
||
|
||
def test_unescape_swift(self) -> None:
|
||
self.assertEqual(l10n.unescape_swift('a\\"b\\\\ \\u{1F600}\\t'), 'a"b\\ \U0001F600\t')
|
||
|
||
def test_stale_units_still_used_in_code(self) -> None:
|
||
path = l10n.xliff_path("de")
|
||
self.write(path, xliff_xml("de", [unit_xml("Gone", "Gone", "Weg"), unit_xml("Used", "Used", "Benutzt")]))
|
||
problems, _ = l10n.check_xliff(path, {(LOCALIZABLE, "Other")}, {"Used": "apps/ios/A.swift:3"})
|
||
self.assertEqual(
|
||
[(p.kind, p.key, p.detail) for p in problems],
|
||
[
|
||
(l10n.STALE, "Gone", "not in en.xliff, Xcode's export removes it"),
|
||
(l10n.IN_CODE, "Used", "not in en.xliff, but the Swift code has this string at apps/ios/A.swift:3"),
|
||
],
|
||
)
|
||
|
||
def test_removed_translation_still_used_in_code(self) -> None:
|
||
saved = l10n.saved_translations(l10n.split_xliff(xliff_xml("de", [unit_xml("Used", "Used", "Benutzt"), unit_xml("Gone", "Gone", "Weg")])))
|
||
problems, removed = l10n.translation_changes(Path("de.xliff"), saved, [], set(), {"Used": "apps/ios/A.swift:3"})
|
||
self.assertEqual(removed, 2)
|
||
self.assertEqual([(p.kind, p.key, p.text) for p in problems], [(l10n.IN_CODE, "Used", "Benutzt")])
|
||
|
||
|
||
BUILD_SCRIPT = """ val possibleFormat = listOf("s", "d", "1\\$s", "2\\$s", "1\\$d", "1\\$02d", "f")\n"""
|
||
|
||
|
||
def kotlin_xml(strings: list[str]) -> str:
|
||
return "<resources>\n" + "".join(f" {string}\n" for string in strings) + "</resources>\n"
|
||
|
||
|
||
class KotlinRulesTest(unittest.TestCase):
|
||
POSSIBLE = ["s", "d", "1$s", "2$s", "3$s", "4$s", "1$d", "2$d", "3$d", "4$d", "1$02d", "2$02d", "3$02d", "2s", "f"]
|
||
|
||
def formats(self, value: str) -> list[str]:
|
||
return l10n.kotlin_formats(f'<string name="x">{value}</string>', self.POSSIBLE)
|
||
|
||
def test_kotlin_formats(self) -> None:
|
||
self.assertEqual(self.formats("No placeholders"), [])
|
||
self.assertEqual(self.formats("%s sent %d"), ["s", "d"])
|
||
self.assertEqual(self.formats("%2$s by %1$s"), ["1$s", "2$s"])
|
||
self.assertEqual(self.formats("%1$02d:%2$02d"), ["1$02d", "2$02d"])
|
||
|
||
def test_kotlin_formats_rejected_by_adjust_formatting(self) -> None:
|
||
cases = {
|
||
"% s": "unknown placeholder '% s', adjustFormatting fails the build",
|
||
"50%% off": "unknown placeholder '%% o', adjustFormatting fails the build",
|
||
"%1$s and %s": "positional and non-positional placeholders mixed, adjustFormatting fails the build",
|
||
"%5$s": "unknown placeholder '%5$s', adjustFormatting fails the build",
|
||
}
|
||
for value, expected in cases.items():
|
||
with self.subTest(value=value), self.assertRaisesRegex(l10n.KotlinFormatError, re.escape(expected)):
|
||
self.formats(value)
|
||
|
||
def test_kotlin_add_cdata(self) -> None:
|
||
cases = {
|
||
'<string name="x">Plain text</string>': '<string name="x">Plain text</string>',
|
||
' <string name="x">Be <b>bold</b></string>': ' <string name="x"><![CDATA[Be <b>bold</b>]]></string>',
|
||
'<string name="x"><font color=#0088ff>Blue</font></string>': '<string name="x"><![CDATA[<font color="#0088ff">Blue</font>]]></string>',
|
||
'<string name="x"><font color="#0088ff">Blue</font></string>': '<string name="x"><![CDATA[<font color="#0088ff">Blue</font>]]></string>',
|
||
}
|
||
for line, expected in cases.items():
|
||
with self.subTest(line=line):
|
||
self.assertEqual(l10n.kotlin_add_cdata(line), expected)
|
||
|
||
def test_kotlin_add_cdata_rejects_what_adjust_formatting_rejects(self) -> None:
|
||
invalid = [
|
||
'<string name="x">Open <a href="https://x.example">link</a></string>',
|
||
'<string name="x">Broken <b>bold</string>',
|
||
'<string name="x">1 < 2</string>',
|
||
]
|
||
for line in invalid:
|
||
with self.subTest(line=line), self.assertRaisesRegex(l10n.KotlinFormatError, "adjustFormatting fails the build"):
|
||
l10n.kotlin_add_cdata(line)
|
||
|
||
def test_kotlin_add_cdata_fast_path_skips_only_lines_any_html_rejects(self) -> None:
|
||
repository = Path(__file__).resolve().parents[2]
|
||
lines = [line for path in (repository / l10n.KOTLIN_RESOURCES).glob("*/strings.xml") for line in path.read_text(encoding="utf-8").splitlines()]
|
||
lines += ['<string name="x">a</string>', '<string name="x">a > b</string>', '<string name="x">1 > 0</string>']
|
||
self.assertGreater(len(lines), 1000)
|
||
for line in lines:
|
||
skipped = line.count("<") + line.count(">") <= l10n.KOTLIN_STRING_TAG_BRACKETS and "<" not in line and ">" not in line
|
||
if skipped:
|
||
self.assertIsNone(l10n.ANY_HTML.fullmatch(line), line)
|
||
|
||
def test_kotlin_text(self) -> None:
|
||
self.assertEqual(l10n.kotlin_text("<![CDATA[<b>Don\\'t</b>]]> & \\\"x\\\" \\u00e9"), '<b>Don\'t</b> & "x" é')
|
||
|
||
def test_substring_after(self) -> None:
|
||
self.assertEqual(l10n.substring_after("a%b%c", "%"), "b%c")
|
||
self.assertEqual(l10n.substring_after("abc", "%"), "abc")
|
||
|
||
def test_remove_cdata(self) -> None:
|
||
self.assertEqual(l10n.remove_cdata('<string name="x"><![CDATA[<b>a</b>]]></string>'), '<string name="x"><b>a</b></string>')
|
||
self.assertEqual(l10n.remove_cdata("<string>a</string>"), "<string>a</string>")
|
||
|
||
|
||
class KotlinFilesTest(RepositoryTestCase):
|
||
def setUp(self) -> None:
|
||
super().setUp()
|
||
self.write(l10n.KOTLIN_BUILD_SCRIPT, BUILD_SCRIPT)
|
||
self.write(
|
||
l10n.KOTLIN_RESOURCES / "base/strings.xml",
|
||
kotlin_xml(
|
||
[
|
||
'<string name="sent">%1$s sent %2$s</string>',
|
||
'<string name="hello">Hello</string>',
|
||
'<string name="docs">Read [docs](https://simplex.chat/docs)</string>',
|
||
'<string name="bold"><![CDATA[Be <b>bold</b>]]></string>',
|
||
]
|
||
),
|
||
)
|
||
|
||
def test_possible_formats_come_from_the_build_script(self) -> None:
|
||
self.assertEqual(l10n.kotlin_possible_formats(), ["s", "d", "1$s", "2$s", "1$d", "1$02d", "f"])
|
||
self.write(l10n.KOTLIN_BUILD_SCRIPT, "// nothing\n")
|
||
with self.assertRaisesRegex(l10n.UpdateError, "possibleFormat not found"):
|
||
l10n.kotlin_possible_formats()
|
||
|
||
def test_check_kotlin_files_reports_each_kind(self) -> None:
|
||
self.write(
|
||
l10n.KOTLIN_RESOURCES / "de/strings.xml",
|
||
kotlin_xml(
|
||
[
|
||
'<string name="sent">%2$s von %1$s gesendet</string>',
|
||
'<string name="hello"></string>',
|
||
'<string name="hello">Hallo</string>',
|
||
'<string name="docs">Lies die [Doku] (https://simplex.chat/docs)</string>',
|
||
"<string name=\"bold\"><![CDATA[Sei <b>mutig</b>]]> l'ami</string>",
|
||
'<string name="removed">Weg</string>',
|
||
]
|
||
),
|
||
)
|
||
self.write(l10n.KOTLIN_RESOURCES / "fr/strings.xml", kotlin_xml(['<string name="sent">%1$s a envoyé % s</string>']))
|
||
self.write(l10n.KOTLIN_RESOURCES / "uk/strings.xml", "<resources><string name='a'>\n")
|
||
stats: dict[str, l10n.LanguageStats] = {}
|
||
problems, rewritten = l10n.check_kotlin_files(stats)
|
||
self.assertEqual(rewritten, [])
|
||
self.assertEqual(
|
||
[(p.language, p.kind, p.key) for p in problems],
|
||
[
|
||
("de", l10n.BLANK, "hello"),
|
||
("de", l10n.DUPLICATE, "hello"),
|
||
("de", l10n.LINK, "docs"),
|
||
("de", l10n.HTML, "bold"),
|
||
("de", l10n.SYNTAX, "bold"),
|
||
("de", l10n.UNKNOWN, "removed"),
|
||
("fr", l10n.PLACEHOLDER, "sent"),
|
||
("uk", l10n.SYNTAX, ""),
|
||
],
|
||
)
|
||
self.assertEqual(problems[6].detail, "unknown placeholder '% s', adjustFormatting fails the build")
|
||
self.assertEqual((stats["de"].translated, stats["fr"].translated, stats["uk"].translated), (6, 1, 0))
|
||
|
||
def test_placeholder_mismatch_against_the_base(self) -> None:
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="sent">%1$s gesendet</string>']))
|
||
problems, _ = l10n.check_kotlin_files({})
|
||
self.assertEqual([(p.kind, p.detail) for p in problems], [(l10n.PLACEHOLDER, "expected %1$s, %2$s, found %1$s")])
|
||
|
||
def test_missing_base_file(self) -> None:
|
||
(l10n.KOTLIN_RESOURCES / "base/strings.xml").unlink()
|
||
with self.assertRaisesRegex(l10n.UpdateError, "base/strings.xml not found"):
|
||
l10n.check_kotlin_files({})
|
||
|
||
def test_language_of_strings_xml(self) -> None:
|
||
self.assertEqual(l10n.language_of(l10n.KOTLIN_RESOURCES / "pt-rBR/strings.xml"), "pt-rBR")
|
||
|
||
|
||
class KotlinAdjustTest(RepositoryTestCase):
|
||
def test_kotlin_lines_split_like_read_line(self) -> None:
|
||
self.assertEqual(l10n.kotlin_lines("a\nb\n"), ["a", "b"])
|
||
self.assertEqual(l10n.kotlin_lines("a\r\nb"), ["a", "b"])
|
||
self.assertEqual(l10n.kotlin_lines("a\n\n"), ["a", ""])
|
||
self.assertEqual(l10n.kotlin_lines("a\u2028b"), ["a\u2028b"])
|
||
self.assertEqual(l10n.kotlin_lines(""), [])
|
||
|
||
def test_gradle_string_id(self) -> None:
|
||
self.assertEqual(l10n.gradle_string_id(' <string name="send_file">Send</string>'), "send_file")
|
||
|
||
def test_adjust_kotlin_normalizes_cdata(self) -> None:
|
||
text = kotlin_xml(
|
||
[
|
||
'<string name="plain"><![CDATA[No HTML here]]></string>',
|
||
'<string name="bold">Be <b>bold</b></string>',
|
||
'<string name="kept">Kept</string>',
|
||
]
|
||
)
|
||
adjusted = l10n.adjust_kotlin(text)
|
||
self.assertTrue(adjusted.changed)
|
||
self.assertEqual(
|
||
adjusted.lines,
|
||
[
|
||
"<resources>",
|
||
' <string name="plain">No HTML here</string>',
|
||
' <string name="bold"><![CDATA[Be <b>bold</b>]]></string>',
|
||
' <string name="kept">Kept</string>',
|
||
"</resources>",
|
||
],
|
||
)
|
||
self.assertEqual([string.name for string in adjusted.strings], ["plain", "bold", "kept"])
|
||
self.assertFalse(l10n.adjust_kotlin("\n".join(adjusted.lines)).changed)
|
||
|
||
def test_adjust_kotlin_multiline_strings(self) -> None:
|
||
text = '<resources>\n <string name="two">First line\nsecond %s line</string>\n</resources>\n'
|
||
adjusted = l10n.adjust_kotlin(text)
|
||
self.assertFalse(adjusted.changed)
|
||
self.assertEqual(adjusted.strings[0].name, "two")
|
||
self.assertEqual(adjusted.strings[0].line, ' <string name="two">First linesecond %s line</string>')
|
||
self.assertEqual(adjusted.strings[0].raw_value, "First line\nsecond %s line")
|
||
|
||
def test_adjust_kotlin_unclosed_string(self) -> None:
|
||
adjusted = l10n.adjust_kotlin('<resources>\n <string name="open">Never closed\n</resources>\n')
|
||
self.assertEqual(adjusted.unclosed, ' <string name="open">Never closed\n</resources>')
|
||
|
||
def test_update_writes_like_adjust_formatting(self) -> None:
|
||
self.write(l10n.KOTLIN_BUILD_SCRIPT, BUILD_SCRIPT)
|
||
self.write(l10n.KOTLIN_RESOURCES / "base/strings.xml", kotlin_xml(['<string name="a">%s</string>', '<string name="b">B</string>']))
|
||
unchanged = kotlin_xml(['<string name="a">%s</string>'])
|
||
normalized = kotlin_xml(['<string name="a"><![CDATA[%s]]></string>'])
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", normalized)
|
||
self.write(l10n.KOTLIN_RESOURCES / "fr/strings.xml", unchanged)
|
||
self.write(l10n.KOTLIN_RESOURCES / "lv/strings.xml", normalized)
|
||
self.write(l10n.KOTLIN_RESOURCES / "uk/strings.xml", kotlin_xml(['<string name="a"><![CDATA[kein]]></string>']))
|
||
problems, rewritten = l10n.check_kotlin_files({}, write=True)
|
||
self.assertEqual([path.parent.name for path in rewritten], ["de"])
|
||
self.assertEqual(l10n.read_file(l10n.KOTLIN_RESOURCES / "de/strings.xml"), unchanged.rstrip("\n"))
|
||
self.assertEqual(l10n.read_file(l10n.KOTLIN_RESOURCES / "fr/strings.xml"), unchanged)
|
||
self.assertEqual(l10n.read_file(l10n.KOTLIN_RESOURCES / "lv/strings.xml"), normalized)
|
||
self.assertEqual(l10n.read_file(l10n.KOTLIN_RESOURCES / "uk/strings.xml"), kotlin_xml(['<string name="a"><![CDATA[kein]]></string>']))
|
||
self.assertEqual([(p.language, p.kind) for p in problems], [("uk", l10n.PLACEHOLDER)])
|
||
|
||
def test_update_kotlin_command(self) -> None:
|
||
self.write(l10n.KOTLIN_BUILD_SCRIPT, BUILD_SCRIPT)
|
||
self.write(l10n.KOTLIN_RESOURCES / "base/strings.xml", kotlin_xml(['<string name="a">A</string>']))
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="a"><![CDATA[Ä]]></string>']))
|
||
output = io.StringIO()
|
||
with contextlib.redirect_stdout(output):
|
||
self.assertEqual(l10n.main(["update-kotlin"]), 0)
|
||
self.assertIn("rewrote 1 files as adjustFormatting does: de", output.getvalue())
|
||
|
||
|
||
class SummaryTest(unittest.TestCase):
|
||
def test_empty_columns_are_hidden(self) -> None:
|
||
path = Path("apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff")
|
||
output = io.StringIO()
|
||
with contextlib.redirect_stdout(output):
|
||
l10n.print_summary({"de": l10n.LanguageStats(translated=3)}, [l10n.Problem(l10n.LINK, path, "k", "d")], {"de"})
|
||
header = output.getvalue().strip().splitlines()[0].split()
|
||
self.assertEqual(header, ["language", "in", "app", "translated", "link"])
|
||
|
||
|
||
BLAME_PORCELAIN = (
|
||
"1111111111111111111111111111111111111111 1 1 2\n"
|
||
"author A\n"
|
||
"filename de/strings.xml\n"
|
||
"\t<resources>\n"
|
||
"1111111111111111111111111111111111111111 2 2\n"
|
||
'\t <string name="hello">Hallo</string>\n'
|
||
"0000000000000000000000000000000000000000 3 3 1\n"
|
||
"author Not Committed Yet\n"
|
||
"filename de/strings.xml\n"
|
||
'\t <string name="bye">Tschüss</string>\n'
|
||
)
|
||
|
||
|
||
class CrossPlatformRulesTest(unittest.TestCase):
|
||
def test_normalized_placeholders_match_across_platforms(self) -> None:
|
||
self.assertEqual(l10n.normalized_placeholders("%2$s von %1$s"), "{2} von {1}")
|
||
self.assertEqual(l10n.normalized_placeholders("%@ sent %lld files"), "{1} sent {2} files")
|
||
self.assertEqual(l10n.normalized_placeholders("%s sent %d files"), "{1} sent {2} files")
|
||
self.assertEqual(l10n.normalized_placeholders("%1$02d hrs, 100%%"), "{1} hrs, 100%%")
|
||
|
||
def test_android_locale(self) -> None:
|
||
self.assertEqual(
|
||
[l10n.android_locale(name) for name in ("de", "pt-BR", "zh-Hans", "zh-Hant", "he", "id")],
|
||
["de", "pt-rBR", "zh-rCN", "zh-rTW", "iw", "in"],
|
||
)
|
||
|
||
def test_blamed_commits_reads_the_commit_of_each_string_line(self) -> None:
|
||
self.assertEqual(l10n.blamed_commits(BLAME_PORCELAIN), {"hello": "1" * 40, "bye": l10n.UNCOMMITTED})
|
||
|
||
def mismatches(self, android_then: dict[str, str], ios_then: dict[l10n.UnitKey, l10n.Unit]) -> list[tuple[str, str]]:
|
||
english = {"sent": "%1$s sent", "hello": "Hello", "same": "Same %s", "menu": "Menu"}
|
||
android_now = {"sent": "%1$s envoyé", "hello": "Salut", "same": "Même %s", "menu": "Menu"}
|
||
ios_now = {
|
||
(LOCALIZABLE, "%@ sent"): l10n.Unit("%@ sent", "%@ a envoyé"),
|
||
(LOCALIZABLE, "Hello"): l10n.Unit("Hello", "Bonjour"),
|
||
(LOCALIZABLE, "Same %@"): l10n.Unit("Same %@", "Même %@"),
|
||
(LOCALIZABLE, "Menu"): l10n.Unit("Menu", "Menu principal"),
|
||
("SimpleX SE/en.lproj/Localizable.strings", "Menu"): l10n.Unit("Menu", "Carte"),
|
||
}
|
||
found = l10n.mismatched_translations(english, android_now, android_then, ios_now, ios_then)
|
||
return [(m.android_key, m.ios_text) for m in found]
|
||
|
||
def test_mismatch_needs_a_change_on_either_platform(self) -> None:
|
||
unchanged_android = {"sent": "%1$s envoyé", "hello": "Salut", "same": "Même %s", "menu": "Menu"}
|
||
unchanged_ios = {
|
||
(LOCALIZABLE, "%@ sent"): l10n.Unit("%@ sent", "%@ a envoyé"),
|
||
(LOCALIZABLE, "Hello"): l10n.Unit("Hello", "Bonjour"),
|
||
}
|
||
self.assertEqual(self.mismatches(unchanged_android, unchanged_ios), [])
|
||
self.assertEqual(self.mismatches({**unchanged_android, "hello": "Allô"}, unchanged_ios), [("hello", "Bonjour")])
|
||
self.assertEqual(self.mismatches(unchanged_android, {}), [("sent", "%@ a envoyé"), ("hello", "Bonjour")])
|
||
|
||
def test_mismatch_skips_equal_and_context_dependent_translations(self) -> None:
|
||
# "same" differs only in placeholder spelling; "menu" has two iOS translations, one per context.
|
||
keys = [key for key, _ in self.mismatches({}, {})]
|
||
self.assertNotIn("same", keys)
|
||
self.assertNotIn("menu", keys)
|
||
|
||
|
||
class HistoryTest(RepositoryTestCase):
|
||
def setUp(self) -> None:
|
||
super().setUp()
|
||
self.git("init", "-q")
|
||
self.write(l10n.KOTLIN_BUILD_SCRIPT, BUILD_SCRIPT)
|
||
l10n.kotlin_values_at.cache_clear()
|
||
|
||
def git(self, *arguments: str) -> None:
|
||
identity = ["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"]
|
||
l10n.subprocess.run(["git", *identity, *arguments], check=True, capture_output=True)
|
||
|
||
def commit(self, message: str) -> None:
|
||
self.git("add", "-A")
|
||
self.git("commit", "-q", "-m", message)
|
||
|
||
def outdated(self) -> list[tuple[str, str, str]]:
|
||
problems, _ = l10n.check_kotlin_files({})
|
||
return [(p.language, p.key, p.detail) for p in problems if p.kind == l10n.OUTDATED]
|
||
|
||
def test_translation_older_than_its_english_text_is_outdated(self) -> None:
|
||
base = l10n.KOTLIN_RESOURCES / "base/strings.xml"
|
||
self.write(base, kotlin_xml(['<string name="title">Badge ❤️</string>', '<string name="ok">OK</string>']))
|
||
self.write(
|
||
l10n.KOTLIN_RESOURCES / "de/strings.xml",
|
||
kotlin_xml(['<string name="title">Abzeichen ❤️</string>', '<string name="ok">OK</string>']),
|
||
)
|
||
self.commit("translate")
|
||
self.assertEqual(self.outdated(), [])
|
||
self.write(base, kotlin_xml(['<string name="title">Badges</string>', '<string name="ok">OK</string>']))
|
||
self.commit("change english")
|
||
self.assertEqual(self.outdated(), [("de", "title", "English changed after this translation, from 'Badge ❤️' to 'Badges'")])
|
||
self.write(
|
||
l10n.KOTLIN_RESOURCES / "de/strings.xml",
|
||
kotlin_xml(['<string name="title">Abzeichen</string>', '<string name="ok">OK</string>']),
|
||
)
|
||
self.assertEqual(self.outdated(), [], "an uncommitted fix is not outdated")
|
||
|
||
def test_outside_a_git_work_tree_nothing_is_outdated(self) -> None:
|
||
l10n.shutil.rmtree(".git")
|
||
self.write(l10n.KOTLIN_RESOURCES / "base/strings.xml", kotlin_xml(['<string name="a">A</string>']))
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="a">B</string>']))
|
||
self.assertEqual(self.outdated(), [])
|
||
|
||
def test_cross_platform_mismatches_since_a_revision(self) -> None:
|
||
self.write(l10n.KOTLIN_RESOURCES / "base/strings.xml", kotlin_xml(['<string name="sent">%1$s sent</string>']))
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="sent">%1$s gesendet</string>']))
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("%@ sent", "%@ sent", "%@ gesendet")]))
|
||
self.commit("translate")
|
||
base = l10n.commit_of("HEAD")
|
||
self.assertEqual(l10n.cross_platform_mismatches(base), [])
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("%@ sent", "%@ sent", "%@ verschickt")]))
|
||
found = l10n.cross_platform_mismatches(base)
|
||
self.assertEqual(
|
||
[(android.parent.name, xliff.stem, m.android_key, m.android_text, m.ios_text) for android, xliff, m in found],
|
||
[("de", "de", "sent", "%1$s gesendet", "%@ verschickt")],
|
||
)
|
||
|
||
def test_since_options_of_the_commands(self) -> None:
|
||
self.write(l10n.KOTLIN_RESOURCES / "base/strings.xml", kotlin_xml(['<string name="hello">Hello</string>']))
|
||
self.commit("english")
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="hello">Hallo</string>']))
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("Hello", "Hello", "Servus")]))
|
||
self.commit("translate")
|
||
for command in ("check-kotlin", "check"):
|
||
output = io.StringIO()
|
||
with contextlib.redirect_stdout(output), mock.patch.object(l10n.shutil, "which", return_value=None):
|
||
self.assertEqual(l10n.main([command, "--since", "HEAD~1"]), 0, "mismatches are warnings")
|
||
self.assertIn("1 mismatch", output.getvalue(), command)
|
||
unchanged = io.StringIO()
|
||
with contextlib.redirect_stdout(unchanged):
|
||
l10n.main(["check-kotlin", "--since", "HEAD"])
|
||
self.assertNotIn("mismatch", unchanged.getvalue(), "nothing changed since HEAD")
|
||
stderr = io.StringIO()
|
||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr):
|
||
self.assertEqual(l10n.main(["check-kotlin", "--since", "no-such-branch"]), 2)
|
||
self.assertIn("'no-such-branch' is not a git commit", stderr.getvalue())
|
||
|
||
|
||
class MainTest(RepositoryTestCase):
|
||
def test_check_command_exit_code(self) -> None:
|
||
self.write(l10n.xliff_path("de"), xliff_xml("de", [unit_xml("%@ x", "%@ x", "y")]))
|
||
with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(l10n.shutil, "which", return_value=None):
|
||
self.assertEqual(l10n.main(["check"]), 1)
|
||
|
||
def test_check_kotlin_command_exit_code(self) -> None:
|
||
self.write(l10n.KOTLIN_BUILD_SCRIPT, BUILD_SCRIPT)
|
||
self.write(l10n.KOTLIN_RESOURCES / "base/strings.xml", kotlin_xml(['<string name="a">%s</string>']))
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="a">%s</string>']))
|
||
with contextlib.redirect_stdout(io.StringIO()):
|
||
self.assertEqual(l10n.main(["check-kotlin"]), 0)
|
||
self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['<string name="a">kein</string>']))
|
||
with contextlib.redirect_stdout(io.StringIO()):
|
||
self.assertEqual(l10n.main(["check-kotlin"]), 1)
|
||
|
||
def test_update_error_exit_code(self) -> None:
|
||
stderr = io.StringIO()
|
||
with mock.patch.object(l10n.platform, "system", return_value="Linux"), contextlib.redirect_stderr(stderr):
|
||
self.assertEqual(l10n.main(["update"]), 2)
|
||
self.assertIn("update needs macOS", stderr.getvalue())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|