Files
simplex-chat/scripts/ios/test_localizations.py
T
50c763df5d ui: translations (#8030)
* 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>
2026-11-04 12:07:17 +00:00

807 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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">&lt;font color="#0088ff"&gt;Blue&lt;/font&gt;</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 &lt; 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 &gt; 0</string>']
self.assertGreater(len(lines), 1000)
for line in lines:
skipped = line.count("<") + line.count(">") <= l10n.KOTLIN_STRING_TAG_BRACKETS and "&lt;" not in line and "&gt;" 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>]]> &amp; \\\"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()