chore: prune redundant tests and maintenance scripts
Remove low-value rendering, dependency-owned and script tests together with orphaned test scaffolding and obsolete wrappers. Keep state-driven visual coverage and release screenshot workflows. Preserve incoming message origins on preparation failures, refresh tool presentation fixtures, and move shared SSH import coverage out of the iOS host test bundle to avoid duplicate static types. Relocate the host icon input manifest, remove the heuristic AppStrings checker, and simplify localization CI.
This commit is contained in:
1 parent
5fcbba38aa
commit
11e6c7ca1c
135 files changed
+81
-13418
No files matched your search
@@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
PROJECT = SCRIPTS.parent / "Termind.xcodeproj" / "project.pbxproj"
|
||||
|
||||
|
||||
def load_script():
|
||||
path = SCRIPTS / "bump-version.py"
|
||||
spec = importlib.util.spec_from_file_location("bump_version", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
bump_version = load_script()
|
||||
|
||||
|
||||
class VersionCalculationTests(unittest.TestCase):
|
||||
def test_semantic_bumps(self) -> None:
|
||||
self.assertEqual(bump_version.next_version("1.10.3", "patch"), "1.10.4")
|
||||
self.assertEqual(bump_version.next_version("1.10.3", "minor"), "1.11.0")
|
||||
self.assertEqual(bump_version.next_version("1.10.3", "major"), "2.0.0")
|
||||
self.assertEqual(bump_version.next_version("1.10.3", "2.1.0"), "2.1.0")
|
||||
|
||||
def test_invalid_version_is_rejected(self) -> None:
|
||||
for value in ("1.2", "v1.2.3", "1.02.3", "one.two.three"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValueError):
|
||||
bump_version.next_version("1.10.3", value)
|
||||
|
||||
|
||||
class ProjectUpdateTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.project = Path(self.temporary_directory.name) / "project.pbxproj"
|
||||
shutil.copy2(PROJECT, self.project)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_updates_all_scopes_and_aligns_widgets_with_tfi(self) -> None:
|
||||
original = self.project.read_text(encoding="utf-8")
|
||||
widgets = next(scope for scope in bump_version.SCOPES if scope.name == "Widgets")
|
||||
drifted = bump_version._update_scope(
|
||||
original, widgets, bump_version.VersionState("8.0.0", 99)
|
||||
)
|
||||
self.project.write_text(drifted, encoding="utf-8")
|
||||
before, expected = bump_version.bump_project(self.project, "9.8.7")
|
||||
updated = self.project.read_text(encoding="utf-8")
|
||||
|
||||
self.assertNotEqual(updated, drifted)
|
||||
for scope in bump_version.SCOPES:
|
||||
self.assertEqual(bump_version.read_state(updated, scope), expected[scope.name])
|
||||
self.assertEqual(expected["Project"].build, before["Project"].build + 1)
|
||||
self.assertEqual(expected["TFM"].build, before["TFM"].build + 1)
|
||||
self.assertEqual(expected["TFI"].build, before["TFI"].build + 1)
|
||||
self.assertEqual(expected["Widgets"], expected["TFI"])
|
||||
self.assertTrue(all(state.marketing_version == "9.8.7" for state in expected.values()))
|
||||
|
||||
def test_dry_run_leaves_project_unchanged(self) -> None:
|
||||
original = self.project.read_bytes()
|
||||
_, expected = bump_version.bump_project(self.project, "patch", write=False)
|
||||
|
||||
self.assertEqual(self.project.read_bytes(), original)
|
||||
self.assertEqual(
|
||||
expected["Widgets"],
|
||||
expected["TFI"],
|
||||
)
|
||||
|
||||
def test_build_only_preserves_versions_and_aligns_widgets(self) -> None:
|
||||
original = self.project.read_text(encoding="utf-8")
|
||||
before, expected = bump_version.bump_project(self.project, "build")
|
||||
updated = self.project.read_text(encoding="utf-8")
|
||||
|
||||
for name in ("Project", "TFM", "TFI"):
|
||||
self.assertEqual(expected[name].marketing_version, before[name].marketing_version)
|
||||
self.assertEqual(expected[name].build, before[name].build + 1)
|
||||
self.assertEqual(expected["Widgets"], expected["TFI"])
|
||||
for scope in bump_version.SCOPES:
|
||||
self.assertEqual(bump_version.read_state(updated, scope), expected[scope.name])
|
||||
self.assertNotEqual(updated, original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,246 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"check_localization", Path(__file__).resolve().parents[1] / "check-localization.py"
|
||||
)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
CHECK = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(CHECK)
|
||||
|
||||
|
||||
def catalog(key: str, value: str, state: str = "translated") -> dict:
|
||||
return {
|
||||
"sourceLanguage": "en",
|
||||
"strings": {key: {"localizations": {"ja": {"stringUnit": {"state": state, "value": value}}}}},
|
||||
}
|
||||
|
||||
|
||||
def japanese_problems(data):
|
||||
return CHECK.problems(data, languages=("ja",))
|
||||
|
||||
|
||||
class JapaneseLocalizationTests(unittest.TestCase):
|
||||
def test_rejects_spaces_in_both_directions_for_all_japanese_scripts(self):
|
||||
for value in ["日本語 API", "API 日本語", "ひらがな SSH", "SSH ひらがな", "サーバ API", "API サーバ",
|
||||
"サーバ\tSSH", "SSH\u00a0サーバ", "サーバ\u3000SSH", "3 台", "サーバ 3"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertTrue(any("spacing" in p for p in japanese_problems(catalog("Label", value))))
|
||||
|
||||
def test_retains_english_protocol_spaces_and_japanese_label_spacing(self):
|
||||
for key, value in [("Label", "ssh user@hostで接続"), ("Label", "種類 不明"), ("Label", "SSHサーバ"),
|
||||
("Type %@", "種類 %@"), ("Type %@", "種類 %1$@")]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(japanese_problems(catalog(key, value)), [])
|
||||
|
||||
def test_type_exception_is_narrow(self):
|
||||
self.assertTrue(japanese_problems(catalog("Label %@", "種類 %@")))
|
||||
self.assertTrue(japanese_problems(catalog("Type %@", "種類 %@")))
|
||||
self.assertTrue(japanese_problems(catalog("Type %@", "種類 %@の SSH鍵")))
|
||||
|
||||
def test_reordered_arguments_keep_their_original_types(self):
|
||||
self.assertEqual(japanese_problems(catalog("%lld on %@", "%2$@で%1$lld件")), [])
|
||||
self.assertTrue(japanese_problems(catalog("%lld on %@", "%1$@で%2$lld件")))
|
||||
self.assertTrue(japanese_problems(catalog("%lld on %@", "%@で%lld件")))
|
||||
|
||||
def test_rejects_lost_arguments_and_literal_percent(self):
|
||||
self.assertTrue(japanese_problems(catalog("%lld%% complete", "%lld完了")))
|
||||
self.assertTrue(japanese_problems(catalog("Host %@", "ホスト")))
|
||||
self.assertEqual(japanese_problems(catalog("%lld%% complete", "%lld%%完了")), [])
|
||||
|
||||
def test_uses_localized_english_value_for_metadata_keys(self):
|
||||
data = catalog("NSCameraUsageDescription", "カメラを使用します。")
|
||||
data["strings"]["NSCameraUsageDescription"]["localizations"]["en"] = {
|
||||
"stringUnit": {"state": "translated", "value": "Use the camera."}
|
||||
}
|
||||
self.assertEqual(japanese_problems(data), [])
|
||||
|
||||
def test_rejects_missing_unfinished_and_empty_translations(self):
|
||||
data = catalog("Settings", "設定")
|
||||
data["strings"]["Settings"]["localizations"] = {}
|
||||
self.assertTrue(japanese_problems(data))
|
||||
self.assertTrue(japanese_problems(catalog("Settings", "設定", "needs_review")))
|
||||
self.assertTrue(japanese_problems(catalog("Settings", "")))
|
||||
self.assertEqual(japanese_problems(catalog("", "")), [])
|
||||
|
||||
def test_checks_plural_units_and_rejects_non_japanese_categories(self):
|
||||
data = catalog("%lld files", "%lld個のファイル")
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
unit = localizations["ja"]
|
||||
localizations["en"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld file"}},
|
||||
"other": {"stringUnit": {"state": "translated", "value": "%lld files"}},
|
||||
}}}
|
||||
self.assertTrue(japanese_problems(data))
|
||||
localizations["ja"] = {"variations": {"plural": {"other": unit}}}
|
||||
self.assertEqual(japanese_problems(data), [])
|
||||
unit["stringUnit"]["value"] = "%lld ファイル"
|
||||
self.assertTrue(japanese_problems(data))
|
||||
unit["stringUnit"]["value"] = "%lld個のファイル"
|
||||
localizations["ja"]["variations"]["plural"]["one"] = unit
|
||||
self.assertTrue(japanese_problems(data))
|
||||
|
||||
def test_respects_nontranslatable_entries(self):
|
||||
data = catalog("Identifier", "")
|
||||
data["strings"]["Identifier"]["shouldTranslate"] = False
|
||||
self.assertEqual(japanese_problems(data), [])
|
||||
|
||||
|
||||
class ChineseLocalizationTests(unittest.TestCase):
|
||||
def check(self, key, value, language):
|
||||
data = catalog(key, value)
|
||||
localizations = data["strings"][key]["localizations"]
|
||||
localizations[language] = localizations.pop("ja")
|
||||
return CHECK.problems(data, languages=(language,))
|
||||
|
||||
def test_preserves_chinese_spacing_and_punctuation_checks(self):
|
||||
for language in ["zh-Hans", "zh-Hant"]:
|
||||
for value in ["中文 SSH", "SSH 中文", "中文,", "中文: SSH", "SSH :中文"]:
|
||||
with self.subTest(language=language, value=value):
|
||||
self.assertTrue(self.check("Label", value, language))
|
||||
self.assertEqual(self.check("Label", "中文SSH", language), [])
|
||||
|
||||
def test_preserves_type_subtitle_exception(self):
|
||||
for language, label in [("zh-Hans", "类型"), ("zh-Hant", "類型")]:
|
||||
self.assertEqual(self.check("Type %@", label + " %@", language), [])
|
||||
self.assertTrue(self.check("Other %@", label + " %@", language))
|
||||
|
||||
def test_shared_validation_requires_every_supported_language(self):
|
||||
data = catalog("Settings", "設定")
|
||||
failures = CHECK.problems(data)
|
||||
self.assertEqual(len(failures), len(CHECK.SUPPORTED_LANGUAGES) - 1)
|
||||
self.assertTrue(any("zh-Hans" in failure for failure in failures))
|
||||
self.assertTrue(any("zh-Hant" in failure for failure in failures))
|
||||
|
||||
|
||||
class KoreanLocalizationTests(unittest.TestCase):
|
||||
def make_catalog(self, key, value):
|
||||
data = catalog(key, value)
|
||||
localizations = data["strings"][key]["localizations"]
|
||||
localizations["ko"] = localizations.pop("ja")
|
||||
return data
|
||||
|
||||
def test_allows_korean_word_spacing_and_protocol_names(self):
|
||||
for value in ["SSH 인증 실패", "파일 3개", "사용자 확인 요청 안 함", "암호 구문"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(CHECK.problems(self.make_catalog("Label", value), languages=("ko",)), [])
|
||||
|
||||
def test_reordered_arguments_preserve_positions_and_types(self):
|
||||
data = self.make_catalog("Command failed with exit code %lld after %@.",
|
||||
"명령이 %2$@ 후 종료 코드 %1$lld로 실패했습니다.")
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), [])
|
||||
unit = data["strings"]["Command failed with exit code %lld after %@."]["localizations"]["ko"]["stringUnit"]
|
||||
unit["value"] = "명령이 %@ 후 종료 코드 %lld로 실패했습니다."
|
||||
self.assertTrue(any("format arguments" in p for p in CHECK.problems(data, languages=("ko",))))
|
||||
|
||||
def test_requires_only_other_for_counted_resources(self):
|
||||
data = self.make_catalog("%lld files", "파일 %lld개")
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
unit = localizations["ko"]
|
||||
localizations["en"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld file"}},
|
||||
"other": {"stringUnit": {"state": "translated", "value": "%lld files"}},
|
||||
}}}
|
||||
self.assertTrue(CHECK.problems(data, languages=("ko",)))
|
||||
localizations["ko"] = {"variations": {"plural": {"other": unit}}}
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), [])
|
||||
localizations["ko"]["variations"]["plural"]["one"] = unit
|
||||
self.assertTrue(CHECK.problems(data, languages=("ko",)))
|
||||
|
||||
def test_rejects_missing_and_unfinished_korean(self):
|
||||
data = self.make_catalog("Settings", "설정")
|
||||
unit = data["strings"]["Settings"]["localizations"]["ko"]["stringUnit"]
|
||||
unit["state"] = "needs_review"
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), ["'Settings': unfinished ko translation"])
|
||||
del data["strings"]["Settings"]["localizations"]["ko"]
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), ["'Settings': missing ko translation"])
|
||||
|
||||
|
||||
class EuropeanLocalizationTests(unittest.TestCase):
|
||||
def make_catalog(self, key, value, language):
|
||||
return {
|
||||
"sourceLanguage": "en",
|
||||
"strings": {key: {"localizations": {
|
||||
language: {"stringUnit": {"state": "translated", "value": value}},
|
||||
}}},
|
||||
}
|
||||
|
||||
def test_checks_coverage_state_and_positional_arguments(self):
|
||||
for language in ("de", "fr"):
|
||||
with self.subTest(language=language):
|
||||
data = self.make_catalog("%lld on %@", "%2$@ : %1$lld", language)
|
||||
self.assertEqual(CHECK.problems(data, languages=(language,)), [])
|
||||
unit = data["strings"]["%lld on %@"]["localizations"][language]["stringUnit"]
|
||||
unit["value"] = "%1$@ : %2$lld"
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
unit["value"] = "%lld on %@"
|
||||
unit["state"] = "needs_review"
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
del data["strings"]["%lld on %@"]["localizations"][language]
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
|
||||
def test_requires_singular_and_plural_even_for_target_only_variations(self):
|
||||
for language in ("de", "fr"):
|
||||
data = self.make_catalog("%lld files", "%lld fichiers", language)
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
unit = localizations[language]
|
||||
localizations[language] = {"variations": {"plural": {"other": unit}}}
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
forms = localizations[language]["variations"]["plural"]
|
||||
forms["one"] = unit
|
||||
self.assertEqual(CHECK.problems(data, languages=(language,)), [])
|
||||
forms["few"] = unit
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
del forms["few"]
|
||||
forms["many"] = unit
|
||||
failures = CHECK.problems(data, languages=(language,))
|
||||
self.assertEqual(bool(failures), language == "de")
|
||||
|
||||
def test_english_plural_requires_translated_plural_variations(self):
|
||||
for language in ("de", "fr"):
|
||||
data = self.make_catalog("%lld files", "%lld fichiers", language)
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
localizations["en"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld file"}},
|
||||
"other": {"stringUnit": {"state": "translated", "value": "%lld files"}},
|
||||
}}}
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
|
||||
def test_each_plural_branch_preserves_format_arguments(self):
|
||||
data = self.make_catalog("%lld files", "%lld fichiers", "fr")
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
localizations["fr"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "un fichier"}},
|
||||
"other": localizations["fr"],
|
||||
}}}
|
||||
self.assertTrue(any("format arguments" in p for p in CHECK.problems(data, languages=("fr",))))
|
||||
|
||||
def test_preserves_european_typography_without_cjk_spacing_rules(self):
|
||||
for language, value in [("fr", "Échec\u00a0: «\u00a0clé SSH\u00a0»\u202f?"), ("de", "Schlüssel „SSH“")]:
|
||||
data = self.make_catalog("Label", value, language)
|
||||
self.assertEqual(CHECK.problems(data, languages=(language,)), [])
|
||||
|
||||
def test_explicit_scope_can_limit_checks_to_german_and_french(self):
|
||||
data = self.make_catalog("Settings", "Einstellungen", "de")
|
||||
self.assertEqual(CHECK.problems(data, languages=("de", "fr")), ["'Settings': missing fr translation"])
|
||||
data["strings"]["Settings"]["localizations"]["fr"] = {
|
||||
"stringUnit": {"state": "translated", "value": "Réglages"}
|
||||
}
|
||||
self.assertEqual(CHECK.problems(data, languages=("de", "fr")), [])
|
||||
|
||||
def test_uses_english_metadata_value_and_skips_nontranslatable_entries(self):
|
||||
data = self.make_catalog("BundleKey", "Datei %@", "de")
|
||||
data["strings"]["BundleKey"]["localizations"]["en"] = {
|
||||
"stringUnit": {"state": "translated", "value": "File %@"}
|
||||
}
|
||||
self.assertEqual(CHECK.problems(data, languages=("de",)), [])
|
||||
data["strings"]["Identifier"] = {"shouldTranslate": False}
|
||||
self.assertEqual(CHECK.problems(data, languages=("de",)), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,383 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
NORMALIZER = SCRIPTS / "normalize-string-catalogs.py"
|
||||
|
||||
|
||||
class StringCatalogNormalizationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.directory = Path(self.temporary_directory.name)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def run_normalizer(
|
||||
self, mode: str, *paths: Path, script: Path = NORMALIZER
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), mode, *(str(path) for path in paths)],
|
||||
cwd=self.directory,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def assert_success(self, result: subprocess.CompletedProcess[str]) -> None:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def write_catalog(self, name: str, content: object) -> Path:
|
||||
path = self.directory / name
|
||||
path.write_text(json.dumps(content, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_check_reports_drift_without_writing(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"Localizable.xcstrings",
|
||||
{
|
||||
"version": "1.0",
|
||||
"strings": {"Hello": {"extractionState": "stale"}},
|
||||
"sourceLanguage": "en",
|
||||
},
|
||||
)
|
||||
original = path.read_bytes()
|
||||
original_mtime = path.stat().st_mtime_ns
|
||||
|
||||
result = self.run_normalizer("--check", path)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
self.assertEqual(path.stat().st_mtime_ns, original_mtime)
|
||||
|
||||
def test_check_detects_formatting_drift_when_entries_are_already_manual(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"Formatting.xcstrings",
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {"Hello": {"extractionState": "manual"}},
|
||||
"version": "1.0",
|
||||
},
|
||||
)
|
||||
original = path.read_bytes()
|
||||
|
||||
result = self.run_normalizer("--check", path)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
|
||||
def test_write_uses_xcode_spacing_unicode_and_no_final_newline(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"Localizable.xcstrings",
|
||||
{
|
||||
"version": "1.0",
|
||||
"strings": {
|
||||
"你好 / Hello": {
|
||||
"extractionState": "stale",
|
||||
"comment": "中文 / slash, \"quote\", newline\nnext",
|
||||
}
|
||||
},
|
||||
"sourceLanguage": "en",
|
||||
},
|
||||
)
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
|
||||
self.assertEqual(
|
||||
path.read_text(encoding="utf-8"),
|
||||
'{\n'
|
||||
' "sourceLanguage" : "en",\n'
|
||||
' "strings" : {\n'
|
||||
' "你好 / Hello" : {\n'
|
||||
' "comment" : "中文 / slash, \\"quote\\", newline\\nnext",\n'
|
||||
' "extractionState" : "manual"\n'
|
||||
' }\n'
|
||||
' },\n'
|
||||
' "version" : "1.0"\n'
|
||||
'}',
|
||||
)
|
||||
self.assert_success(self.run_normalizer("--check", path))
|
||||
|
||||
def test_empty_objects_use_xcode_blank_line_format(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"Empty.xcstrings",
|
||||
{"sourceLanguage": "en", "strings": {}, "version": "1.0"},
|
||||
)
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
|
||||
self.assertEqual(
|
||||
path.read_text(encoding="utf-8"),
|
||||
'{\n'
|
||||
' "sourceLanguage" : "en",\n'
|
||||
' "strings" : {\n'
|
||||
'\n'
|
||||
' },\n'
|
||||
' "version" : "1.0"\n'
|
||||
'}',
|
||||
)
|
||||
|
||||
def test_nested_empty_arrays_and_objects_keep_xcode_indentation(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"EmptyMetadata.xcstrings",
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {"Hello": {"metadata": {"emptyArray": [], "emptyObject": {}}}},
|
||||
"version": "1.0",
|
||||
},
|
||||
)
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
|
||||
self.assertIn(
|
||||
' "metadata" : {\n'
|
||||
' "emptyArray" : [\n'
|
||||
'\n'
|
||||
' ],\n'
|
||||
' "emptyObject" : {\n'
|
||||
'\n'
|
||||
' }\n'
|
||||
' }',
|
||||
path.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_normalization_preserves_translations_variations_and_unknown_metadata(self) -> None:
|
||||
catalog = {
|
||||
"version": "1.2",
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"Deleted key": {"extractionState": "stale", "comment": "Keep this entry."},
|
||||
"Items: %#@items@": {
|
||||
"extractionState": "extracted_with_value",
|
||||
"comment": "A count with plural forms.",
|
||||
"shouldTranslate": True,
|
||||
"localizations": {
|
||||
"zh-Hant": {
|
||||
"substitutions": {
|
||||
"items": {
|
||||
"argNum": 1,
|
||||
"formatSpecifier": "lld",
|
||||
"variations": {
|
||||
"plural": {
|
||||
"other": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld 個項目",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"stringUnit": {"state": "needs_review", "value": "項目:%#@items@"},
|
||||
},
|
||||
"en": {
|
||||
"variations": {
|
||||
"plural": {
|
||||
"other": {"stringUnit": {"state": "new", "value": "%lld items"}},
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld item"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"extractionState": "stale",
|
||||
"ordered": [3, "三", {"z": False, "a": None}, ["b", "a"]],
|
||||
"fraction": 1.25,
|
||||
"empty": {},
|
||||
},
|
||||
},
|
||||
"Implicit": {},
|
||||
"Already manual": {"extractionState": "manual", "shouldTranslate": False},
|
||||
},
|
||||
"metadata": {"extractionState": "root_metadata", "futureField": ["z", "a"]},
|
||||
}
|
||||
expected = copy.deepcopy(catalog)
|
||||
for entry in expected["strings"].values():
|
||||
entry["extractionState"] = "manual"
|
||||
path = self.write_catalog("Complex.xcstrings", catalog)
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
|
||||
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), expected)
|
||||
|
||||
def test_every_object_uses_unicode_code_point_order(self) -> None:
|
||||
keys = ["😀", "\ue000", "中", "é", "a", "Z", "A", "", "a/b"]
|
||||
path = self.write_catalog(
|
||||
"Ordering.xcstrings",
|
||||
{
|
||||
"version": "1.0",
|
||||
"strings": {key: {"z": 1, "a": 2} for key in keys},
|
||||
"metadata": [{key: index for index, key in enumerate(keys)}],
|
||||
"sourceLanguage": "en",
|
||||
},
|
||||
)
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
|
||||
def check_order(value: object) -> None:
|
||||
if isinstance(value, dict):
|
||||
self.assertEqual(list(value), sorted(value))
|
||||
for child in value.values():
|
||||
check_order(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
check_order(child)
|
||||
|
||||
check_order(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
def test_second_write_does_not_touch_content_or_mtime(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"Stable.xcstrings",
|
||||
{"sourceLanguage": "en", "strings": {"Hello": {}}, "version": "1.0"},
|
||||
)
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
original = path.read_bytes()
|
||||
sentinel_mtime = 1_600_000_000_123_456_789
|
||||
os.utime(path, ns=(sentinel_mtime, sentinel_mtime))
|
||||
original_mtime = path.stat().st_mtime_ns
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", path))
|
||||
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
self.assertEqual(path.stat().st_mtime_ns, original_mtime)
|
||||
|
||||
def test_batch_validation_finishes_before_any_file_is_written(self) -> None:
|
||||
valid_path = self.write_catalog(
|
||||
"First.xcstrings",
|
||||
{"sourceLanguage": "en", "strings": {"Hello": {}}, "version": "1.0"},
|
||||
)
|
||||
original = valid_path.read_bytes()
|
||||
original_mtime = valid_path.stat().st_mtime_ns
|
||||
invalid_path = self.directory / "Last.xcstrings"
|
||||
invalid_path.write_text('{"sourceLanguage": "en",', encoding="utf-8")
|
||||
invalid_original = invalid_path.read_bytes()
|
||||
|
||||
result = self.run_normalizer("--write", valid_path, invalid_path)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(valid_path.read_bytes(), original)
|
||||
self.assertEqual(valid_path.stat().st_mtime_ns, original_mtime)
|
||||
self.assertEqual(invalid_path.read_bytes(), invalid_original)
|
||||
|
||||
def test_missing_batch_file_does_not_partially_write_or_create_files(self) -> None:
|
||||
path = self.write_catalog(
|
||||
"First.xcstrings",
|
||||
{"sourceLanguage": "en", "strings": {"Hello": {}}, "version": "1.0"},
|
||||
)
|
||||
original = path.read_bytes()
|
||||
missing = self.directory / "Missing.xcstrings"
|
||||
|
||||
result = self.run_normalizer("--write", path, missing)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
self.assertFalse(missing.exists())
|
||||
|
||||
def test_duplicate_keys_are_rejected_at_every_depth(self) -> None:
|
||||
fixtures = [
|
||||
'{"sourceLanguage":"en","sourceLanguage":"zh-Hans","strings":{},"version":"1.0"}',
|
||||
'{"sourceLanguage":"en","strings":{"Hello":{},"Hello":{}},"version":"1.0"}',
|
||||
'{"sourceLanguage":"en","strings":{"Hello":{"comment":"a","comment":"b"}},"version":"1.0"}',
|
||||
'{"sourceLanguage":"en","strings":{},"version":"1.0","metadata":[{"a":1,"a":2}]}',
|
||||
'{"sourceLanguage":"en","strings":{},"version":"1.0","metadata":{"a":1,"\\u0061":2}}',
|
||||
]
|
||||
for index, fixture in enumerate(fixtures):
|
||||
with self.subTest(fixture=fixture):
|
||||
path = self.directory / f"Duplicate{index}.xcstrings"
|
||||
path.write_text(fixture, encoding="utf-8")
|
||||
original = path.read_bytes()
|
||||
|
||||
result = self.run_normalizer("--write", path)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
|
||||
def test_nonstandard_numeric_constants_are_rejected(self) -> None:
|
||||
for value in ("NaN", "Infinity", "-Infinity"):
|
||||
with self.subTest(value=value):
|
||||
path = self.directory / "Nonstandard.xcstrings"
|
||||
path.write_text(
|
||||
'{"sourceLanguage":"en","strings":{},"version":"1.0","metadata":'
|
||||
+ value
|
||||
+ '}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
original = path.read_bytes()
|
||||
|
||||
result = self.run_normalizer("--write", path)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
|
||||
def test_invalid_catalog_structure_is_rejected_without_writing(self) -> None:
|
||||
valid = {"sourceLanguage": "en", "strings": {}, "version": "1.0"}
|
||||
fixtures: list[object] = [[], None, "catalog", 1]
|
||||
for field, values in {
|
||||
"sourceLanguage": [None, "", 1, []],
|
||||
"strings": [None, "", 1, []],
|
||||
"version": [None, "", 1, []],
|
||||
}.items():
|
||||
missing = valid.copy()
|
||||
del missing[field]
|
||||
fixtures.append(missing)
|
||||
for value in values:
|
||||
fixtures.append({**valid, field: value})
|
||||
for entry in (None, "translation", 1, []):
|
||||
fixtures.append({**valid, "strings": {"Hello": entry}})
|
||||
|
||||
for fixture in fixtures:
|
||||
with self.subTest(fixture=fixture):
|
||||
path = self.write_catalog("Invalid.xcstrings", fixture)
|
||||
original = path.read_bytes()
|
||||
|
||||
result = self.run_normalizer("--write", path)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
|
||||
def test_default_paths_are_anchored_to_script_repository_and_limited_to_owned_catalogs(self) -> None:
|
||||
repository = self.directory / "Repository"
|
||||
scripts = repository / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
script = scripts / NORMALIZER.name
|
||||
shutil.copy2(NORMALIZER, script)
|
||||
resources = repository / "Frameworks" / "Resources"
|
||||
resources.mkdir(parents=True)
|
||||
fixture = '{"version":"1.0","strings":{"Hello":{}},"sourceLanguage":"en"}'
|
||||
default_paths = [resources / "Localizable.xcstrings", resources / "InfoPlist.xcstrings"]
|
||||
untouched_paths = [
|
||||
resources / "Other.xcstrings",
|
||||
self.directory / "AgentKit" / "Localizations" / "Localizable.xcstrings",
|
||||
self.directory / "Localizable.xcstrings",
|
||||
]
|
||||
for path in default_paths + untouched_paths:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(fixture, encoding="utf-8")
|
||||
|
||||
self.assert_success(self.run_normalizer("--write", script=script))
|
||||
|
||||
for path in default_paths:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(
|
||||
json.loads(path.read_text(encoding="utf-8"))["strings"]["Hello"]["extractionState"],
|
||||
"manual",
|
||||
)
|
||||
for path in untouched_paths:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(path.read_text(encoding="utf-8"), fixture)
|
||||
self.assert_success(self.run_normalizer("--check", script=script))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,119 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from port_forwarding_benchmark_common import endpoint_measurement, summarize
|
||||
|
||||
|
||||
def load_script(name: str):
|
||||
path = SCRIPTS / name
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
termind_benchmark = load_script("benchmark-port-forwarding.py")
|
||||
external_benchmark = load_script("benchmark-external-port-forwarding.py")
|
||||
|
||||
|
||||
class TermindArgumentsTests(unittest.TestCase):
|
||||
def test_default_and_unique_connection_counts(self) -> None:
|
||||
arguments = termind_benchmark.make_parser().parse_args(
|
||||
["--metadata", "fixture.json", "--output", "result.json"]
|
||||
)
|
||||
self.assertEqual(arguments.termind_connections, [1])
|
||||
self.assertEqual(termind_benchmark.unique_counts([1, 64, 1, 4]), [1, 64, 4])
|
||||
|
||||
def test_connection_count_accepts_boundaries(self) -> None:
|
||||
self.assertEqual(termind_benchmark.connection_count("1"), 1)
|
||||
self.assertEqual(termind_benchmark.connection_count("64"), 64)
|
||||
|
||||
def test_connection_count_rejects_invalid_values(self) -> None:
|
||||
for value in ("0", "65", "not-a-number"):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(argparse.ArgumentTypeError):
|
||||
termind_benchmark.connection_count(value)
|
||||
|
||||
|
||||
class ExternalArgumentsTests(unittest.TestCase):
|
||||
def test_system_and_manual_modes_are_distinct(self) -> None:
|
||||
parser = external_benchmark.make_parser()
|
||||
system = parser.parse_args(
|
||||
["--metadata", "fixture.json", "--output", "result.json", "system-ssh"]
|
||||
)
|
||||
manual = parser.parse_args(
|
||||
[
|
||||
"--metadata",
|
||||
"fixture.json",
|
||||
"--output",
|
||||
"result.json",
|
||||
"external",
|
||||
"--name",
|
||||
"Example",
|
||||
"--kind",
|
||||
"dynamic",
|
||||
"--port",
|
||||
"1080",
|
||||
]
|
||||
)
|
||||
self.assertEqual(system.mode, "system-ssh")
|
||||
self.assertEqual((manual.mode, manual.kind, manual.port), ("external", "dynamic", 1080))
|
||||
|
||||
|
||||
class WorkloadTests(unittest.TestCase):
|
||||
def test_manual_endpoint_reports_network_metrics_only(self) -> None:
|
||||
listener = socket.socket()
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen()
|
||||
stopped = threading.Event()
|
||||
|
||||
def serve() -> None:
|
||||
while not stopped.is_set():
|
||||
try:
|
||||
connection, _ = listener.accept()
|
||||
except OSError:
|
||||
return
|
||||
threading.Thread(target=echo, args=(connection,), daemon=True).start()
|
||||
|
||||
def echo(connection: socket.socket) -> None:
|
||||
with connection:
|
||||
while data := connection.recv(65_536):
|
||||
connection.sendall(data)
|
||||
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
record = endpoint_measurement(
|
||||
"external:Example",
|
||||
listener.getsockname()[1],
|
||||
"local",
|
||||
streams=2,
|
||||
target_port=0,
|
||||
rounds=1,
|
||||
)
|
||||
finally:
|
||||
stopped.set()
|
||||
listener.close()
|
||||
self.assertEqual(record["ssh_connections"], 1)
|
||||
self.assertEqual(record["jumps"], 0)
|
||||
self.assertNotIn("cpu_s", record)
|
||||
self.assertNotIn("forwarder_peak_rss_mib", record)
|
||||
summary = summarize([record])
|
||||
self.assertEqual(summary[0]["samples"], 1)
|
||||
self.assertNotIn("median_cpu_s", summary[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,546 +0,0 @@
|
||||
require "minitest/autorun"
|
||||
require "tmpdir"
|
||||
require_relative "../../fastlane/lib/screenshots"
|
||||
|
||||
class ScreenshotsTests < Minitest::Test
|
||||
def setup
|
||||
@root = Dir.mktmpdir("termind-screenshots-")
|
||||
FileUtils.mkdir_p(File.join(@root, "com.akinokaede.termind/assets"))
|
||||
File.write(File.join(@root, "com.akinokaede.termind/.git"), "gitdir: fixture")
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@root)
|
||||
end
|
||||
|
||||
def runner(platform = :ios, options = {}, environment = {})
|
||||
Screenshots.new(platform, options, environment: environment, root: @root)
|
||||
end
|
||||
|
||||
def test_default_capture_fails_before_build_when_submodule_is_missing
|
||||
FileUtils.remove_entry(File.join(@root, "com.akinokaede.termind"))
|
||||
error = assert_raises(RuntimeError) { runner.run { flunk "scan must not start" } }
|
||||
assert_includes error.message, "git submodule update --init com.akinokaede.termind"
|
||||
refute Dir.exist?(File.join(@root, ".build"))
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind"))
|
||||
end
|
||||
|
||||
def test_explicit_capture_output_can_publish_without_submodule
|
||||
FileUtils.remove_entry(File.join(@root, "com.akinokaede.termind"))
|
||||
source = File.join(@root, "capture.png")
|
||||
File.binwrite(source, "review capture")
|
||||
capture = runner(:ios, output: "review")
|
||||
capture.promote([[source, "en/ipad-15/vault.png"]])
|
||||
assert_equal "review capture", File.binread(File.join(@root, "review/en/ipad-15/vault.png"))
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind"))
|
||||
end
|
||||
|
||||
def test_lane_options_override_legacy_selectors_and_ipad_only_needs_no_iphone
|
||||
capture = runner(:ios, { devices: "ipad", locales: "zh-Hant", scenes: "ssh,sftp" },
|
||||
{ "TERMIND_SCREENSHOT_DEVICES" => "iphone", "TERMIND_SCREENSHOT_LOCALES" => "en" })
|
||||
capture.define_singleton_method(:command) do |*args|
|
||||
JSON.generate("devices" => { "com.apple.CoreSimulator.SimRuntime.iOS-27-0" => [
|
||||
{ "name" => "iPad Pro 13-inch (M5)", "udid" => "ipad-id",
|
||||
"deviceTypeIdentifier" => "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-12GB" }
|
||||
] })
|
||||
end
|
||||
assert_equal ["ipad"], capture.find_devices.keys
|
||||
assert_equal 2, capture.plan[:captures]
|
||||
assert_equal ["zh-Hant"], capture.plan[:locales]
|
||||
end
|
||||
|
||||
def test_runtime_defaults_follow_device_compatibility_and_allow_explicit_overrides
|
||||
assert_equal "com.apple.CoreSimulator.SimRuntime.iOS-27-0", runner.plan[:runtime]
|
||||
assert_equal "com.apple.CoreSimulator.SimRuntime.iOS-27-1", runner(:ios, devices: "iphone-duo-inner").plan[:runtime]
|
||||
assert_equal "com.apple.CoreSimulator.SimRuntime.iOS-27-0",
|
||||
runner(:ios, { devices: "iphone-duo-inner", runtime: "com.apple.CoreSimulator.SimRuntime.iOS-27-0" }).plan[:runtime]
|
||||
end
|
||||
|
||||
def test_a_renamed_pro_max_cannot_be_used_as_the_pro_capture_device
|
||||
capture = runner(:ios, devices: "iphone")
|
||||
capture.define_singleton_method(:command) do |*args|
|
||||
JSON.generate("devices" => { "com.apple.CoreSimulator.SimRuntime.iOS-27-0" => [
|
||||
{ "name" => "iPhone 18 Pro", "udid" => "wrong-model",
|
||||
"deviceTypeIdentifier" => "com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro-Max" }
|
||||
] })
|
||||
end
|
||||
assert_raises(RuntimeError) { capture.find_devices }
|
||||
capture.define_singleton_method(:dimensions) { |_| [1320, 2868] }
|
||||
assert_raises(RuntimeError) do
|
||||
capture.validate_dimensions("pro-max.png", Screenshots::DEVICES.fetch("iphone").fetch(:dimensions))
|
||||
end
|
||||
end
|
||||
|
||||
def test_rejects_invalid_duplicate_and_empty_members_before_build
|
||||
["ssh,ssh", "ssh,", "bogus"].each do |scenes|
|
||||
assert_raises(ArgumentError) { runner(:ios, scenes: scenes) }
|
||||
end
|
||||
assert_raises(ArgumentError) { runner(:mac, scenes: "metrics-detail") }
|
||||
assert_raises(ArgumentError) { runner(:ios, locales: "zh") }
|
||||
assert_raises(ArgumentError) { runner(:ios, devices: "watch") }
|
||||
assert_raises(ArgumentError) { runner(:ios, runtime: "com.apple.CoreSimulator.SimRuntime.iOS-26-0") }
|
||||
end
|
||||
|
||||
def test_generated_xctestrun_preserves_host_settings_and_other_targets
|
||||
capture = runner
|
||||
fixture = { "EnvironmentVariables" => { "EXISTING" => "preserved" }, "TestHostPath" => "__TESTROOT__/App.app" }
|
||||
document = { "TFITests" => fixture, "FrameworkTests" => { "CommandLineArguments" => ["unchanged"] } }
|
||||
capture.configure_xctestrun(document, "zh-Hans", { "TERMIND_SCREENSHOTS" => "1" }, [])
|
||||
assert_equal "preserved", fixture["EnvironmentVariables"]["EXISTING"]
|
||||
assert_equal "__TESTROOT__/App.app", fixture["TestHostPath"]
|
||||
assert_equal "CN", fixture["TestRegion"]
|
||||
assert_equal ["-AppleLanguages", "(zh-Hans)", "-AppleLocale", "zh_CN"], fixture["CommandLineArguments"]
|
||||
assert_equal ["unchanged"], document["FrameworkTests"]["CommandLineArguments"]
|
||||
end
|
||||
|
||||
def test_japanese_capture_configures_japan_region_and_locale
|
||||
capture = runner(:ios, locales: "ja")
|
||||
target = {}
|
||||
capture.configure_xctestrun({ "TFITests" => target }, "ja", {}, [])
|
||||
assert_equal "ja", target["TestLanguage"]
|
||||
assert_equal "JP", target["TestRegion"]
|
||||
assert_equal ["-AppleLanguages", "(ja)", "-AppleLocale", "ja_JP"], target["CommandLineArguments"]
|
||||
assert ScreenshotFrames.new({ locales: "ja" }, root: @root).plan.all? { |job| job[:output].include?("/ja/") }
|
||||
end
|
||||
|
||||
def test_additional_marketing_locales_configure_native_capture_and_frame_paths
|
||||
{ "ko" => ["KR", "ko_KR"], "de" => ["DE", "de_DE"], "fr" => ["FR", "fr_FR"] }.each do |language, (region, locale)|
|
||||
%i[mac ios].each do |platform|
|
||||
capture = runner(platform, locales: language)
|
||||
target = {}
|
||||
key = platform == :mac ? "TFMTests" : "TFITests"
|
||||
capture.configure_xctestrun({ key => target }, language, {}, [])
|
||||
assert_equal language, target["TestLanguage"]
|
||||
assert_equal region, target["TestRegion"]
|
||||
assert_equal "C", target["EnvironmentVariables"]["LC_NUMERIC"]
|
||||
assert_equal ["-AppleLanguages", "(#{language})", "-AppleLocale", locale], target["CommandLineArguments"]
|
||||
end
|
||||
jobs = ScreenshotFrames.new({ locales: language }, root: @root).plan
|
||||
assert jobs.all? { |job| job[:input].include?("/#{language}/") && job[:output].include?("/#{language}/") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_test_plan_format_updates_only_the_capture_target
|
||||
capture = runner(:mac)
|
||||
target = { "BlueprintName" => "TFMTests" }
|
||||
other = { "BlueprintName" => "FrameworkTests" }
|
||||
document = { "TestConfigurations" => [{ "TestTargets" => [target, other] }] }
|
||||
capture.configure_xctestrun(document, "zh-Hant", {}, ["--termind-demo=hosts:zh-Hant"])
|
||||
assert_equal "TW", target["TestRegion"]
|
||||
assert_equal "--termind-demo=hosts:zh-Hant", target["CommandLineArguments"].first
|
||||
refute other.key?("TestLanguage")
|
||||
end
|
||||
|
||||
def test_scan_configuration_pins_dependencies_and_serializes_hosted_tests
|
||||
options = runner.scan_options("id=ipad", "vault-en")
|
||||
assert_includes options[:xcargs], "-onlyUsePackageVersionsFromResolvedFile"
|
||||
assert options[:skip_package_dependencies_resolution]
|
||||
assert options[:disable_package_automatic_updates]
|
||||
refute options[:parallel_testing]
|
||||
assert options[:disable_concurrent_testing]
|
||||
assert_equal 0, options[:number_of_retries]
|
||||
end
|
||||
|
||||
def test_successful_capture_backs_up_original_bytes_and_manifest
|
||||
capture = runner
|
||||
original = File.join(@root, "com.akinokaede.termind/assets/captures/en/ipad-15/vault.png")
|
||||
FileUtils.mkdir_p(File.dirname(original))
|
||||
File.binwrite(original, "old image bytes")
|
||||
source = File.join(@root, "new.png")
|
||||
File.binwrite(source, "new image bytes")
|
||||
capture.promote([[source, "en/ipad-15/vault.png"]])
|
||||
assert_equal "new image bytes", File.binread(original)
|
||||
backup = Dir.glob(File.join(@root, ".build/screenshot-backups/*/en/ipad-15/vault.png")).fetch(0)
|
||||
assert_equal "old image bytes", File.binread(backup)
|
||||
manifest = File.read(File.join(File.dirname(File.dirname(File.dirname(backup))), "SHA256SUMS"))
|
||||
assert_equal "#{Digest::SHA256.hexdigest('old image bytes')} en/ipad-15/vault.png\n", manifest
|
||||
end
|
||||
|
||||
def test_mac_captures_publish_localized_underscore_filenames
|
||||
{ "command" => "command_generator", "metrics" => "metrics_overview" }.each do |scene, filename|
|
||||
capture = runner(:mac, scenes: scene, locales: "zh-Hant")
|
||||
capture.define_singleton_method(:test_configuration) { |*| File.join(@root, "test.xctestrun") }
|
||||
capture.define_singleton_method(:capture_during_test) do |options, &block|
|
||||
log = File.join(options.fetch(:buildlog_path), "Termind-TFM.log")
|
||||
FileUtils.mkdir_p(File.dirname(log))
|
||||
File.write(log, "TERMIND_MAC_SCREENSHOT_READY #{scene}\tzh-Hant\t123\ttoken\tactive\tkey\t2360\t1480\n")
|
||||
block.call
|
||||
end
|
||||
capture.define_singleton_method(:command) do |*args|
|
||||
raise "Unexpected command" unless args.first == "screencapture"
|
||||
FileUtils.mkdir_p(File.dirname(args.last))
|
||||
File.binwrite(args.last, "captured #{scene}")
|
||||
end
|
||||
capture.define_singleton_method(:dimensions) { |_| [2360, 1480] }
|
||||
capture.capture_mac(scene, "zh-Hant", "platform=macOS")
|
||||
destination = File.join(@root, "com.akinokaede.termind/assets/captures/zh-hant/mac-14/#{filename}.png")
|
||||
assert_equal "captured #{scene}", File.binread(destination)
|
||||
end
|
||||
end
|
||||
|
||||
def test_mac_camera_strip_is_padded_without_stretching_full_screen_capture
|
||||
capture = runner(:mac, scenes: "workspace", locales: "en")
|
||||
capture.define_singleton_method(:test_configuration) { |*| File.join(@root, "test.xctestrun") }
|
||||
capture.define_singleton_method(:capture_during_test) do |options, &block|
|
||||
log = File.join(options.fetch(:buildlog_path), "Termind-TFM.log")
|
||||
FileUtils.mkdir_p(File.dirname(log))
|
||||
File.write(log, "TERMIND_MAC_SCREENSHOT_READY workspace\ten\t123\ttoken\tactive\tkey\t3024\t1964\n")
|
||||
block.call
|
||||
end
|
||||
calls = []
|
||||
capture.define_singleton_method(:command) do |*args|
|
||||
calls << args
|
||||
if args.first == "screencapture"
|
||||
FileUtils.mkdir_p(File.dirname(args.last))
|
||||
File.binwrite(args.last, "full-screen window")
|
||||
end
|
||||
end
|
||||
sizes = [[3024, 1890], [3024, 1964]]
|
||||
capture.define_singleton_method(:dimensions) { |_| sizes.shift }
|
||||
capture.capture_mac("workspace", "en", "platform=macOS")
|
||||
padding = calls.find { |args| args.first == "magick" }
|
||||
assert_includes padding, "3024x1964"
|
||||
assert_includes padding, "south"
|
||||
refute_includes padding, "-resize"
|
||||
assert_equal ["screencapture", "-l", "123", "-o"], calls.first.first(4)
|
||||
end
|
||||
|
||||
def test_mobile_capture_rediscovers_container_after_host_reinstallation
|
||||
%w[iphone ipad iphone-duo-outer iphone-duo-inner].each do |device|
|
||||
assert_mobile_capture_rediscovers_container(device)
|
||||
end
|
||||
end
|
||||
|
||||
def assert_mobile_capture_rediscovers_container(device)
|
||||
capture = runner(:ios, devices: device, scenes: "vault", locales: "zh-Hans")
|
||||
old_container = File.join(@root, "old-container")
|
||||
new_container = File.join(@root, "new-container")
|
||||
control = File.join(new_container, "Documents", "screenshot-capture-control")
|
||||
token = "11111111-1111-1111-1111-111111111111"
|
||||
capture.define_singleton_method(:test_configuration) do |_, _, environment|
|
||||
FileUtils.mkdir_p(control)
|
||||
File.write(File.join(control, "ready"), ["vault", environment.fetch("TERMIND_SCREENSHOT_DEVICE"), "zh-hans", token,
|
||||
environment.fetch("TERMIND_SCREENSHOT_CAPTURE_ID")].join("\t"))
|
||||
File.join(@root, "fixture.xctestrun")
|
||||
end
|
||||
capture.define_singleton_method(:capture_during_test) { |_, &block| 2.times { block.call } }
|
||||
commands = []
|
||||
capture.define_singleton_method(:sleep) { |_| }
|
||||
capture.define_singleton_method(:command) do |*args|
|
||||
commands << args
|
||||
if args.include?("displays")
|
||||
next JSON.generate("result" => { "displays" => [
|
||||
{ "active" => true, "nativeSize" => Screenshots::DEVICES.fetch(device).fetch(:dimensions).sort,
|
||||
"uniqueId" => "display-#{device}" }
|
||||
] })
|
||||
end
|
||||
if args.include?("screenshot")
|
||||
FileUtils.mkdir_p(File.dirname(args.last))
|
||||
File.binwrite(args.last, "new container capture")
|
||||
end
|
||||
""
|
||||
end
|
||||
capture.define_singleton_method(:dimensions) { |_| Screenshots::DEVICES.fetch(device).fetch(:dimensions) }
|
||||
containers = [old_container, new_container]
|
||||
status = Object.new
|
||||
status.define_singleton_method(:success?) { true }
|
||||
responses = lambda do |*args|
|
||||
[args.include?("get_app_container") ? containers.shift : "", status]
|
||||
end
|
||||
Open3.stub(:capture2e, responses) do
|
||||
capture.capture_mobile(device, { "udid" => "fixture", "state" => "Booted" }, "zh-Hans")
|
||||
end
|
||||
folder = { "iphone" => "iphone-6.3", "ipad" => "ipad-15" }.fetch(device, device)
|
||||
destination = File.join(@root, "com.akinokaede.termind/assets/captures/zh-hans/#{folder}/vault.png")
|
||||
assert_equal "new container capture", File.binread(destination)
|
||||
assert File.exist?(File.join(control, "ack-#{token}"))
|
||||
refute Dir.exist?(old_container)
|
||||
status_bar = commands.find { |args| args.include?("override") }
|
||||
assert_equal "discharging", status_bar[status_bar.index("--batteryState") + 1]
|
||||
assert_equal "100", status_bar[status_bar.index("--batteryLevel") + 1]
|
||||
if device == "iphone-duo-outer"
|
||||
assert_includes commands.find { |args| args.include?("screenshot") }, "--display=primary"
|
||||
elsif device == "iphone-duo-inner"
|
||||
screenshot = commands.find { |args| args.include?("screenshot") }
|
||||
assert_includes screenshot, "--display-unique-id"
|
||||
assert_includes screenshot, "display-iphone-duo-inner"
|
||||
end
|
||||
FileUtils.remove_entry(new_container)
|
||||
end
|
||||
|
||||
def test_duo_rejects_the_inactive_display_before_starting_capture
|
||||
capture = runner(:ios, devices: "iphone-duo-inner")
|
||||
capture.define_singleton_method(:command) do |*args|
|
||||
JSON.generate("result" => { "displays" => [
|
||||
{ "active" => true, "nativeSize" => [1398, 2034] },
|
||||
{ "active" => false, "nativeSize" => [2007, 2853] }
|
||||
] })
|
||||
end
|
||||
error = assert_raises(RuntimeError) { capture.validate_display("iphone-duo-inner", "duo-id") }
|
||||
assert_includes error.message, "Select Open"
|
||||
end
|
||||
|
||||
def test_failed_scan_never_promotes_a_staged_mac_capture
|
||||
capture = runner(:mac, scenes: "hosts", locales: "en")
|
||||
capture.define_singleton_method(:test_configuration) { |*| File.join(@root, "test.xctestrun") }
|
||||
capture.instance_variable_set(:@scan, ->(_) { raise "scan failed" })
|
||||
error = assert_raises(RuntimeError) { capture.capture_mac("hosts", "en", "platform=macOS") }
|
||||
assert_equal "scan failed", error.message
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind/assets/captures"))
|
||||
end
|
||||
|
||||
def test_capture_failure_is_reported_even_if_scan_succeeds
|
||||
capture = runner
|
||||
attempted = Queue.new
|
||||
capture.instance_variable_set(:@scan, ->(_) { attempted.pop })
|
||||
error = assert_raises(RuntimeError) do
|
||||
capture.capture_during_test({}) do
|
||||
attempted << true
|
||||
raise "native capture failed"
|
||||
end
|
||||
end
|
||||
assert_equal "native capture failed", error.message
|
||||
end
|
||||
end
|
||||
|
||||
require "minitest/mock"
|
||||
require "fastlane"
|
||||
require "frameit"
|
||||
require "zlib"
|
||||
require_relative "../../fastlane/lib/screenshot_frames"
|
||||
|
||||
class ScreenshotFramesTests < Minitest::Test
|
||||
def setup
|
||||
@root = Dir.mktmpdir("termind-frames-")
|
||||
FileUtils.mkdir_p(File.join(@root, "com.akinokaede.termind/assets"))
|
||||
File.write(File.join(@root, "com.akinokaede.termind/.git"), "gitdir: fixture")
|
||||
FileUtils.mkdir_p(File.join(@root, "fastlane"))
|
||||
FileUtils.cp(File.expand_path("../../fastlane/Framefile.json", __dir__), File.join(@root, "fastlane"))
|
||||
frame_template(File.join(@root, "com.akinokaede.termind/assets/device_templates/iphone-18-pro.png"), 1350, 2760)
|
||||
@templates = File.join(@root, "templates")
|
||||
FileUtils.mkdir_p(@templates)
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@root)
|
||||
end
|
||||
|
||||
def png(path, width, height)
|
||||
chunk = lambda do |name, bytes|
|
||||
[bytes.bytesize].pack("N") + name + bytes + [Zlib.crc32(name + bytes)].pack("N")
|
||||
end
|
||||
data = "\x89PNG\r\n\x1a\n".b + chunk.call("IHDR", [width, height, 8, 2, 0, 0, 0].pack("NNCCCCC"))
|
||||
data += chunk.call("IDAT", Zlib.deflate(("\0" * (width * 3 + 1)) * height)) + chunk.call("IEND", "")
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
File.binwrite(path, data)
|
||||
end
|
||||
|
||||
def frame_template(path, width, height)
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
ScreenshotFrames.new.command("magick", "-size", "#{width}x#{height}", "xc:none",
|
||||
"-fill", "none", "-stroke", "black", "-strokewidth", "40",
|
||||
"-draw", "roundrectangle 40,40,#{width - 41},#{height - 41},100,100", path)
|
||||
end
|
||||
|
||||
def test_screen_mask_removes_exterior_corners_without_fading_content
|
||||
runner = ScreenshotFrames.new
|
||||
template = File.join(@root, "rounded-frame.png")
|
||||
runner.command("magick", "-size", "120x180", "xc:none", "-fill", "none", "-stroke", "black",
|
||||
"-strokewidth", "8", "-draw", "roundrectangle 8,8,111,171,25,25",
|
||||
"-fill", "black", "-stroke", "none", "-draw", "rectangle 50,8,70,28", template)
|
||||
[0, -90, 90].each do |rotation|
|
||||
landscape = !rotation.zero?
|
||||
source = File.join(@root, "screen.png")
|
||||
runner.command("magick", "-size", landscape ? "160x100" : "100x160", "xc:#327abe", source)
|
||||
runner.mask_screen(source, template, "+10+10", rotation: rotation)
|
||||
assert_equal(landscape ? [160, 100] : [100, 160], FastImage.size(source))
|
||||
center = landscape ? "80,50" : "50,80"
|
||||
assert_equal "1", runner.command("magick", source, "-format", "%[fx:p{#{center}}.a]", "info:").strip
|
||||
assert_equal "0", runner.command("magick", source, "-format", "%[fx:p{0,0}.a]", "info:").strip
|
||||
assert_equal "50,122,190", runner.command("magick", source, "-format",
|
||||
"%[fx:round(255*p{#{center}}.r)],%[fx:round(255*p{#{center}}.g)],%[fx:round(255*p{#{center}}.b)]", "info:").strip
|
||||
notch = { 0 => "50,5", -90 => "5,50", 90 => "155,50" }.fetch(rotation)
|
||||
assert_equal "0", runner.command("magick", source, "-format", "%[fx:p{#{notch}}.a]", "info:").strip
|
||||
end
|
||||
end
|
||||
|
||||
def test_screen_mask_rejects_an_open_bezel
|
||||
runner = ScreenshotFrames.new
|
||||
template = File.join(@root, "open-frame.png")
|
||||
source = File.join(@root, "screen.png")
|
||||
runner.command("magick", "-size", "120x180", "xc:none", template)
|
||||
png(source, 100, 160)
|
||||
original = Digest::SHA256.file(source).hexdigest
|
||||
assert_raises(RuntimeError) { runner.mask_screen(source, template, "+10+10") }
|
||||
assert_equal original, Digest::SHA256.file(source).hexdigest
|
||||
end
|
||||
|
||||
def test_mac_composition_clips_corners_and_preserves_source_and_content
|
||||
template = File.join(@root, "mac-frame.png")
|
||||
runner = ScreenshotFrames.new({ mac_frame: template }, root: @root)
|
||||
runner.command("magick", "-size", "3860x2320", "xc:none", "-fill", "none",
|
||||
"-stroke", "black", "-strokewidth", "20",
|
||||
"-draw", "roundrectangle 408,58,3451,2041,100,100", template)
|
||||
source = File.join(@root, "capture.png")
|
||||
output = File.join(@root, "composed.png")
|
||||
runner.command("magick", "-size", "3024x1964", "xc:#327abe", "-fill", "red",
|
||||
"-draw", "point 1512,982", source)
|
||||
original = Digest::SHA256.file(source).hexdigest
|
||||
runner.compose_mac(source, output)
|
||||
assert_equal [3860, 2320], FastImage.size(output)
|
||||
# Native screen pixels must land at the aperture offset without resampling.
|
||||
assert_equal "255,0,0", runner.command("magick", output, "-format",
|
||||
"%[fx:round(255*p{1930,1050}.r)],%[fx:round(255*p{1930,1050}.g)],%[fx:round(255*p{1930,1050}.b)]", "info:").strip
|
||||
assert_equal original, Digest::SHA256.file(source).hexdigest
|
||||
# The old rectangular placement leaked here, outside the curved bezel.
|
||||
assert_equal "0", runner.command("magick", output, "-format", "%[fx:p{418,68}.a]", "info:").strip
|
||||
assert_equal "1", runner.command("magick", output, "-format", "%[fx:p{1930,1160}.a]", "info:").strip
|
||||
assert_equal "50,122,190", runner.command("magick", output, "-format",
|
||||
"%[fx:round(255*p{1930,1160}.r)],%[fx:round(255*p{1930,1160}.g)],%[fx:round(255*p{1930,1160}.b)]", "info:").strip
|
||||
end
|
||||
|
||||
def test_default_frames_only_include_website_used_scenes
|
||||
jobs = ScreenshotFrames.new({}, root: @root).plan
|
||||
assert_equal 7 * Screenshots::LOCALES.size, jobs.size
|
||||
refute jobs.any? { |job| job[:input].end_with?("/ipad-15/ssh.png", "/iphone-6.3/metrics.png") }
|
||||
explicit = ScreenshotFrames.new({ devices: "ipad", scenes: "ssh", locales: "en" }, root: @root).plan
|
||||
assert_equal 1, explicit.size
|
||||
assert explicit.first[:input].end_with?("/ipad-15/ssh.png")
|
||||
end
|
||||
|
||||
def test_default_frames_fail_before_creating_outputs_when_submodule_is_missing
|
||||
FileUtils.remove_entry(File.join(@root, "com.akinokaede.termind"))
|
||||
runner = ScreenshotFrames.new({ devices: "iphone", scenes: "vault", locales: "en" }, root: @root)
|
||||
error = assert_raises(RuntimeError) { runner.run { flunk "frameit must not start" } }
|
||||
assert_includes error.message, "git submodule update --init com.akinokaede.termind"
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind"))
|
||||
end
|
||||
|
||||
def test_explicit_frame_paths_work_without_submodule_and_keep_source_bytes
|
||||
template = File.join(@root, "external-template.png")
|
||||
FileUtils.mv(File.join(@root, "com.akinokaede.termind/assets/device_templates/iphone-18-pro.png"), template)
|
||||
FileUtils.remove_entry(File.join(@root, "com.akinokaede.termind"))
|
||||
source = File.join(@root, "review-input/en/iphone-6.3/vault.png")
|
||||
png(source, 1206, 2622)
|
||||
original_hash = Digest::SHA256.file(source).hexdigest
|
||||
runner = ScreenshotFrames.new({ devices: "iphone", scenes: "vault", locales: "en",
|
||||
input: "review-input", output: "review-output", iphone_frame: template }, root: @root)
|
||||
downloader = Object.new
|
||||
downloader.define_singleton_method(:frames_exist?) { true }
|
||||
Frameit::FrameDownloader.stub(:templates_path, @templates) do
|
||||
Frameit::FrameDownloader.stub(:new, downloader) do
|
||||
runner.run do |options|
|
||||
assert_equal "iPhone 18 Pro", options.fetch(:force_device_type)
|
||||
framefile = JSON.parse(File.read(File.join(options.fetch(:path), "Framefile.json")))
|
||||
assert_equal({ "offset" => "+72+69", "width" => 1206 }, framefile.fetch("default").fetch("offset"))
|
||||
png(File.join(options.fetch(:path), "iphone_framed.png"), 1350, 2760)
|
||||
end
|
||||
end
|
||||
end
|
||||
assert File.exist?(File.join(@root, "review-output/en/iphone-6.3/vault.png"))
|
||||
assert_equal original_hash, Digest::SHA256.file(source).hexdigest
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind"))
|
||||
end
|
||||
|
||||
def test_default_template_works_without_a_local_build_cache
|
||||
source = File.join(@root, "com.akinokaede.termind/assets/captures/en/iphone-6.3/vault.png")
|
||||
png(source, 1206, 2622)
|
||||
refute Dir.exist?(File.join(@root, ".build"))
|
||||
runner = ScreenshotFrames.new({ devices: "iphone", scenes: "vault", locales: "en" }, root: @root)
|
||||
downloader = Object.new
|
||||
downloader.define_singleton_method(:frames_exist?) { true }
|
||||
Frameit::FrameDownloader.stub(:templates_path, @templates) do
|
||||
Frameit::FrameDownloader.stub(:new, downloader) do
|
||||
runner.run do |options|
|
||||
installed = File.join(@templates, "Apple iPhone 18 Pro Silver.png")
|
||||
assert_equal Digest::SHA256.file(File.join(@root, "com.akinokaede.termind/assets/device_templates/iphone-18-pro.png")).hexdigest,
|
||||
Digest::SHA256.file(installed).hexdigest
|
||||
png(File.join(options.fetch(:path), "iphone_framed.png"), 1350, 2760)
|
||||
end
|
||||
end
|
||||
end
|
||||
assert_equal [1350, 2760], FastImage.size(File.join(@root, "com.akinokaede.termind/assets/device_frames/en/iphone-6.3/vault.png"))
|
||||
end
|
||||
|
||||
def test_rejects_pro_max_sources_before_calling_frameit
|
||||
source = File.join(@root, "com.akinokaede.termind/assets/captures/en/iphone-6.3/vault.png")
|
||||
png(source, 1320, 2868)
|
||||
runner = ScreenshotFrames.new({ devices: "iphone", scenes: "vault", locales: "en" }, root: @root)
|
||||
called = false
|
||||
assert_raises(RuntimeError) { runner.run { called = true } }
|
||||
refute called
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind/assets/device_frames"))
|
||||
end
|
||||
|
||||
def test_duo_frames_preserve_native_sources_and_use_each_display_aperture
|
||||
templates = File.join(@root, "templates")
|
||||
FileUtils.mkdir_p(templates)
|
||||
downloader = Object.new
|
||||
downloader.define_singleton_method(:frames_exist?) { true }
|
||||
expected = {
|
||||
"outer" => [[1398, 2034], [1574, 2194], "+88+80", "iPhone Duo Outer"],
|
||||
"inner" => [[2853, 2007], [2247, 3093], "+120+120", "iPhone Duo Inner"]
|
||||
}
|
||||
expected.each do |display, (native, frame, offset, name)|
|
||||
device = "iphone-duo-#{display}"
|
||||
source = File.join(@root, "input/en/#{device}/vault.png")
|
||||
template = File.join(@root, "#{device}.png")
|
||||
png(source, *native)
|
||||
frame_template(template, *frame)
|
||||
source_hash = Digest::SHA256.file(source).hexdigest
|
||||
options = { devices: device, scenes: "vault", locales: "en", input: "input", output: "output",
|
||||
"iphone_duo_#{display}_frame".to_sym => template }
|
||||
Frameit::FrameDownloader.stub(:templates_path, templates) do
|
||||
Frameit::FrameDownloader.stub(:new, downloader) do
|
||||
ScreenshotFrames.new(options, root: @root).run do |configuration|
|
||||
assert_equal name, configuration.fetch(:force_device_type)
|
||||
framefile = JSON.parse(File.read(File.join(configuration.fetch(:path), "Framefile.json")))
|
||||
assert_equal({ "offset" => offset, "width" => native.min }, framefile.fetch("default").fetch("offset"))
|
||||
assert_equal :landscape_right, configuration.fetch(:force_orientation_block).call(nil) if display == "inner"
|
||||
png(File.join(configuration.fetch(:path), "#{device}_framed.png"), *(display == "inner" ? frame.reverse : frame))
|
||||
end
|
||||
end
|
||||
end
|
||||
assert_equal source_hash, Digest::SHA256.file(source).hexdigest
|
||||
assert_equal(display == "inner" ? frame.reverse : frame, FastImage.size(File.join(@root, "output/en/#{device}/vault.png")))
|
||||
end
|
||||
end
|
||||
|
||||
def test_missing_duo_template_fails_before_publishing
|
||||
runner = ScreenshotFrames.new({ devices: "iphone-duo-inner", locales: "en" }, root: @root)
|
||||
error = assert_raises(RuntimeError) { runner.run { flunk "frameit must not start" } }
|
||||
assert_includes error.message, "iphone_duo_inner_frame:"
|
||||
assert_includes error.message, "git submodule update --init com.akinokaede.termind"
|
||||
refute Dir.exist?(File.join(@root, "com.akinokaede.termind/assets/device_frames"))
|
||||
end
|
||||
|
||||
def test_missing_frameit_output_keeps_all_existing_outputs_and_sources
|
||||
%w[en zh-hans].each do |locale|
|
||||
png(File.join(@root, "com.akinokaede.termind/assets/captures/#{locale}/iphone-6.3/vault.png"), 1206, 2622)
|
||||
end
|
||||
destination = File.join(@root, "com.akinokaede.termind/assets/device_frames/en/iphone-6.3/vault.png")
|
||||
FileUtils.mkdir_p(File.dirname(destination))
|
||||
File.write(destination, "existing frame")
|
||||
source = File.join(@root, "com.akinokaede.termind/assets/captures/en/iphone-6.3/vault.png")
|
||||
original_hash = Digest::SHA256.file(source).hexdigest
|
||||
runner = ScreenshotFrames.new({ devices: "iphone", scenes: "vault", locales: "en,zh-Hans" }, root: @root)
|
||||
downloader = Object.new
|
||||
downloader.define_singleton_method(:frames_exist?) { true }
|
||||
calls = 0
|
||||
Frameit::FrameDownloader.stub(:templates_path, @templates) do
|
||||
Frameit::FrameDownloader.stub(:new, downloader) do
|
||||
assert_raises(RuntimeError) do
|
||||
runner.run do |options|
|
||||
calls += 1
|
||||
png(File.join(options.fetch(:path), "iphone_framed.png"), 1350, 2760) if calls == 1
|
||||
# frameit can log an error and return successfully without an image.
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
assert_equal 2, calls
|
||||
assert_equal "existing frame", File.read(destination)
|
||||
assert_equal original_hash, Digest::SHA256.file(source).hexdigest
|
||||
refute File.exist?(destination.sub("/en/", "/zh-hans/"))
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
swift_host="$(dirname "$(dirname "$(xcrun --find swift)")")/lib/swift/host"
|
||||
|
||||
exec xcrun swift -I "$swift_host" -L "$swift_host" \
|
||||
-lSwiftParser -lSwiftSyntax -Xlinker -rpath -Xlinker "$swift_host" \
|
||||
"$script_dir/check-appstrings.swift" "$script_dir/.."
|
||||
@@ -1,87 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftParser
|
||||
import SwiftSyntax
|
||||
|
||||
/// Uses Xcode's parser so comments, interpolation expressions, URLs, symbols,
|
||||
/// HTTP fields and identifiers cannot be mistaken for UI copy by a text grep.
|
||||
final class CopyBoundaryVisitor: SyntaxVisitor {
|
||||
private let path: String
|
||||
private let locations: SourceLocationConverter
|
||||
private(set) var violations: [String] = []
|
||||
|
||||
private let presentationCalls: Set<String> = [
|
||||
"Text", "Button", "Label", "TextField", "SecureField", "Section", "LabeledContent", "Picker",
|
||||
"ContentUnavailableView", "ProgressView", "Toggle", "Menu", "Link", "CommandMenu", "TableColumn", "Window",
|
||||
"LocalizedStringResource", "LocalizedStringKey", "navigationTitle", "help", "accessibilityLabel",
|
||||
"accessibilityValue", "accessibilityHint", "alert", "confirmationDialog", "dismissalConfirmationDialog",
|
||||
"localized", "pageTitle", "feature", "statusCard", "emptyRow", "permissionHeader",
|
||||
]
|
||||
|
||||
init(path: String, tree: SourceFileSyntax) {
|
||||
self.path = path
|
||||
locations = SourceLocationConverter(fileName: path, tree: tree)
|
||||
super.init(viewMode: .sourceAccurate)
|
||||
}
|
||||
|
||||
override func visit(_ node: StringLiteralExprSyntax) -> SyntaxVisitorContinueKind {
|
||||
let prose = node.segments.compactMap { $0.as(StringSegmentSyntax.self)?.content.text }.joined()
|
||||
guard prose.contains(where: { $0.isLetter }), isPresentationLiteral(node) else { return .visitChildren }
|
||||
let line = locations.location(for: node.positionAfterSkippingLeadingTrivia).line
|
||||
violations.append(
|
||||
"\(path):\(line): move strings into TermindPresentation/Strings: \(node.trimmedDescription)")
|
||||
return .visitChildren
|
||||
}
|
||||
|
||||
private func isPresentationLiteral(_ node: StringLiteralExprSyntax) -> Bool {
|
||||
var ancestor = node.parent
|
||||
var argument: LabeledExprSyntax?
|
||||
while let current = ancestor {
|
||||
if current.is(MacroExpansionExprSyntax.self) { return false }
|
||||
if let labeled = current.as(LabeledExprSyntax.self), argument == nil { argument = labeled }
|
||||
if let call = current.as(FunctionCallExprSyntax.self) {
|
||||
let name =
|
||||
call.calledExpression.as(MemberAccessExprSyntax.self)?.declName.baseName.text
|
||||
?? call.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text ?? ""
|
||||
guard let argument else { return false }
|
||||
let label = argument.label?.text ?? ""
|
||||
if name == "String" { return label == "localized" }
|
||||
if name == "Text", label == "verbatim" { return true }
|
||||
if ["title", "prompt", "help", "emptyDescription", "actionTitle"].contains(label) { return true }
|
||||
return label.isEmpty && call.arguments.first?.id == argument.id && presentationCalls.contains(name)
|
||||
}
|
||||
if let binding = current.as(PatternBindingSyntax.self), let type = binding.typeAnnotation?.type {
|
||||
return type.trimmedDescription.contains("LocalizedString")
|
||||
}
|
||||
if let function = current.as(FunctionDeclSyntax.self), let type = function.signature.returnClause?.type {
|
||||
return type.trimmedDescription.contains("LocalizedString")
|
||||
}
|
||||
ancestor = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let repository = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL
|
||||
let sharedStrings = repository.appending(path: "Frameworks/TermindPresentation/Strings/AppStrings.swift")
|
||||
guard FileManager.default.fileExists(atPath: sharedStrings.path) else {
|
||||
throw CocoaError(.fileReadNoSuchFile)
|
||||
}
|
||||
var violations: [String] = []
|
||||
for app in ["TFM", "TFI"] {
|
||||
let directory = repository.appending(path: app)
|
||||
guard let files = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else {
|
||||
throw CocoaError(.fileReadNoSuchFile)
|
||||
}
|
||||
for case let file as URL in files where file.pathExtension == "swift" {
|
||||
let source = try String(contentsOf: file, encoding: .utf8)
|
||||
let tree = Parser.parse(source: source)
|
||||
let visitor = CopyBoundaryVisitor(path: file.path, tree: tree)
|
||||
visitor.walk(tree)
|
||||
violations += visitor.violations
|
||||
}
|
||||
}
|
||||
if !violations.isEmpty {
|
||||
FileHandle.standardError.write(Data((violations.sorted().joined(separator: "\n") + "\n").utf8))
|
||||
exit(1)
|
||||
}
|
||||
print("TFM and TFI presentation copy uses shared AppStrings resources.")
|
||||
@@ -1,45 +0,0 @@
|
||||
[
|
||||
{ "title": "AlmaLinux", "slug": "almalinux", "ids": ["almalinux", "alma"] },
|
||||
{ "title": "Alpine Linux", "slug": "alpinelinux", "ids": ["alpine"] },
|
||||
{ "title": "Arch Linux", "slug": "archlinux", "ids": ["arch"] },
|
||||
{ "title": "Artix Linux", "slug": "artixlinux", "ids": ["artix"] },
|
||||
{ "title": "Asahi Linux", "slug": "asahilinux", "ids": ["asahi"] },
|
||||
{ "title": "CachyOS", "slug": "cachyos", "ids": ["cachyos"] },
|
||||
{ "title": "CentOS", "slug": "centos", "ids": ["centos"] },
|
||||
{ "title": "Debian", "slug": "debian", "ids": ["debian"] },
|
||||
{ "title": "deepin", "slug": "deepin", "ids": ["deepin"] },
|
||||
{ "title": "Devuan", "slug": "devuan", "ids": ["devuan"] },
|
||||
{ "title": "elementary", "slug": "elementary", "ids": ["elementary"] },
|
||||
{ "title": "EndeavourOS", "slug": "endeavouros", "ids": ["endeavouros"] },
|
||||
{ "title": "Fedora", "slug": "fedora", "ids": ["fedora"] },
|
||||
{ "title": "Garuda Linux", "slug": "garudalinux", "ids": ["garuda"] },
|
||||
{ "title": "Gentoo", "slug": "gentoo", "ids": ["gentoo"] },
|
||||
{ "title": "Kali Linux", "slug": "kalilinux", "ids": ["kali"] },
|
||||
{ "title": "Kubuntu", "slug": "kubuntu", "ids": ["kubuntu"] },
|
||||
{ "title": "Linux Mint", "slug": "linuxmint", "ids": ["linuxmint"] },
|
||||
{ "title": "Lubuntu", "slug": "lubuntu", "ids": ["lubuntu"] },
|
||||
{ "title": "Manjaro", "slug": "manjaro", "ids": ["manjaro"] },
|
||||
{ "title": "MX Linux", "slug": "mxlinux", "ids": ["mx"] },
|
||||
{ "title": "NixOS", "slug": "nixos", "ids": ["nixos"] },
|
||||
{ "title": "Nobara Linux", "slug": "nobaralinux", "ids": ["nobara"] },
|
||||
{
|
||||
"title": "openSUSE",
|
||||
"slug": "opensuse",
|
||||
"ids": ["opensuse", "opensuse-leap", "opensuse-tumbleweed", "opensuse-microos"]
|
||||
},
|
||||
{ "title": "OpenWrt", "slug": "openwrt", "ids": ["openwrt"] },
|
||||
{ "title": "Parrot Security", "slug": "parrotsecurity", "ids": ["parrot"] },
|
||||
{ "title": "Pop!_OS", "slug": "popos", "ids": ["pop", "popos"] },
|
||||
{ "title": "Qubes OS", "slug": "qubesos", "ids": ["qubes"] },
|
||||
{ "title": "Raspberry Pi", "slug": "raspberrypi", "ids": ["raspbian", "raspberrypios"] },
|
||||
{ "title": "Red Hat", "slug": "redhat", "ids": ["rhel", "redhat"] },
|
||||
{ "title": "Rocky Linux", "slug": "rockylinux", "ids": ["rocky"] },
|
||||
{ "title": "Slackware", "slug": "slackware", "ids": ["slackware"] },
|
||||
{ "title": "Solus", "slug": "solus", "ids": ["solus"] },
|
||||
{ "title": "SUSE", "slug": "suse", "ids": ["suse", "sles", "sled"] },
|
||||
{ "title": "Ubuntu", "slug": "ubuntu", "ids": ["ubuntu"] },
|
||||
{ "title": "Ubuntu MATE", "slug": "ubuntumate", "ids": ["ubuntu-mate", "ubuntu-mate-desktop"] },
|
||||
{ "title": "Void Linux", "slug": "voidlinux", "ids": ["void"] },
|
||||
{ "title": "Xubuntu", "slug": "xubuntu", "ids": ["xubuntu"] },
|
||||
{ "title": "Zorin", "slug": "zorin", "ids": ["zorin"] }
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// Preserve the former native Pro hero in RevenueCat's remote image component.
|
||||
// Run from the repository root: swift Scripts/render-pro-paywall-icon.swift
|
||||
@MainActor
|
||||
func renderIcon(name: String, red: Double, green: Double, blue: Double) throws {
|
||||
let content = Image(systemName: "sparkles.rectangle.stack.fill")
|
||||
.font(.system(size: 52, weight: .medium))
|
||||
.foregroundStyle(.tint)
|
||||
.tint(Color(red: red, green: green, blue: blue))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.frame(width: 80, height: 72)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = 4
|
||||
guard let cgImage = renderer.cgImage,
|
||||
let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:])
|
||||
else { throw CocoaError(.fileWriteUnknown) }
|
||||
let directory = URL(filePath: FileManager.default.currentDirectoryPath)
|
||||
.appending(path: "com.akinokaede.termind/design/paywall", directoryHint: .isDirectory)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try png.write(to: directory.appending(path: name))
|
||||
}
|
||||
|
||||
let assetRoot = URL(filePath: FileManager.default.currentDirectoryPath)
|
||||
.appending(path: "com.akinokaede.termind", directoryHint: .isDirectory)
|
||||
guard FileManager.default.fileExists(atPath: assetRoot.appending(path: ".git").path),
|
||||
FileManager.default.fileExists(atPath: assetRoot.appending(path: "design/brand").path)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "Termind.AssetWorkflow", code: 1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Initialize the website and assets first: git submodule update --init com.akinokaede.termind"
|
||||
])
|
||||
}
|
||||
|
||||
try MainActor.assumeIsolated {
|
||||
try renderIcon(name: "pro_hero_light.png", red: 0, green: 122.0 / 255, blue: 1)
|
||||
try renderIcon(name: "pro_hero_dark.png", red: 10.0 / 255, green: 132.0 / 255, blue: 1)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
cd "${0:A:h:h}"
|
||||
exec bundle exec fastlane mac screenshots "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
cd "${0:A:h:h}"
|
||||
exec bundle exec fastlane ios screenshots "$@"
|
||||
@@ -70,7 +70,7 @@ def main():
|
||||
simple_source["source"] = "https://github.com/simple-icons/simple-icons"
|
||||
tabler_source["source"] = "https://github.com/tabler/tabler-icons"
|
||||
metadata = {entry["title"]: entry for entry in json.loads(read(simple, "data/simple-icons.json"))}
|
||||
requested = json.loads((ROOT / "Scripts/linux-distribution-icons.json").read_text())
|
||||
requested = json.loads((ROOT / "Configuration/HostIcons/linux-distribution-icons.json").read_text())
|
||||
requested.append({"title": "macOS", "slug": "macos", "ids": ["macos"], "id": "macos"})
|
||||
for title, slug, ids in [
|
||||
("BSD", "bsd", ["bsd", "dragonfly"]),
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Compatibility entry point; generation now includes macOS and Windows artwork.
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 SIMPLE_ICONS_VERSION" >&2
|
||||
exit 64
|
||||
fi
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec python3 "$script_dir/update-host-icons.py" --simple-icons-version "$1"
|
||||
Reference in new issue
Block a user