#!/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' \n', f" {source}\n"] if target is not None: attributes = f' state="{state}"' if state else "" lines.append(f" {target}\n" if target else f" \n") lines += [f" {NOTE}\n", " \n"] return "".join(lines) def xliff_xml(language: str, units: list[str], original: str = LOCALIZABLE) -> str: return ( '\n' '\n' f' \n' " \n" + "".join(units) + " \n" " \n" "\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]() and ") 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", [' AB\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 = ' \n M\n \nzwei Zeilen\n \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, [" \n", "zwei Zeilen\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"), "\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"), "\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 "\n" + "".join(f" {string}\n" for string in strings) + "\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'{value}', 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 = { 'Plain text': 'Plain text', ' Be bold': ' bold]]>', 'Blue': 'Blue]]>', '<font color="#0088ff">Blue</font>': 'Blue]]>', } 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 = [ 'Open link', 'Broken bold', '1 < 2', ] 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 += ['a', 'a > b', '1 > 0'] 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("Don\\'t]]> & \\\"x\\\" \\u00e9"), 'Don\'t & "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('a]]>'), 'a') self.assertEqual(l10n.remove_cdata("a"), "a") 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( [ '%1$s sent %2$s', 'Hello', 'Read [docs](https://simplex.chat/docs)', 'bold]]>', ] ), ) 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( [ '%2$s von %1$s gesendet', '', 'Hallo', 'Lies die [Doku] (https://simplex.chat/docs)', "mutig]]> l'ami", 'Weg', ] ), ) self.write(l10n.KOTLIN_RESOURCES / "fr/strings.xml", kotlin_xml(['%1$s a envoyé % s'])) self.write(l10n.KOTLIN_RESOURCES / "uk/strings.xml", "\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(['%1$s gesendet'])) 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(' Send'), "send_file") def test_adjust_kotlin_normalizes_cdata(self) -> None: text = kotlin_xml( [ '', 'Be bold', 'Kept', ] ) adjusted = l10n.adjust_kotlin(text) self.assertTrue(adjusted.changed) self.assertEqual( adjusted.lines, [ "", ' No HTML here', ' bold]]>', ' Kept', "", ], ) 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 = '\n First line\nsecond %s line\n\n' adjusted = l10n.adjust_kotlin(text) self.assertFalse(adjusted.changed) self.assertEqual(adjusted.strings[0].name, "two") self.assertEqual(adjusted.strings[0].line, ' First linesecond %s line') self.assertEqual(adjusted.strings[0].raw_value, "First line\nsecond %s line") def test_adjust_kotlin_unclosed_string(self) -> None: adjusted = l10n.adjust_kotlin('\n Never closed\n\n') self.assertEqual(adjusted.unclosed, ' Never closed\n') 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(['%s', 'B'])) unchanged = kotlin_xml(['%s']) normalized = kotlin_xml(['']) 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([''])) 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([''])) 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(['A'])) self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml([''])) 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"]) 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(['%s'])) self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['%s'])) with contextlib.redirect_stdout(io.StringIO()): self.assertEqual(l10n.main(["check-kotlin"]), 0) self.write(l10n.KOTLIN_RESOURCES / "de/strings.xml", kotlin_xml(['kein'])) 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()