feat(localization): complete German, French, and Korean support

Update AgentKit to 0.19.2, align the maintenance gitlink and resolved package revision, and unify localization validation.
This commit is contained in:
Kaede Akino committed 2026-10-14 13:05:48 +08:00
1 parent 7098f5f1fb
commit 0eba936203
19 files changed
+38696 -156

No files matched your search

+7 -7
View File
@@ -5,8 +5,8 @@ on:
paths:
- "Frameworks/Resources/*.xcstrings"
- "Scripts/normalize-string-catalogs.py"
- "Scripts/check-cjk-localization.py"
- "Scripts/Tests/test_cjk_localization.py"
- "Scripts/check-localization.py"
- "Scripts/Tests/test_localization.py"
- "Scripts/Tests/test_normalize_string_catalogs.py"
- ".github/workflows/localization.yml"
push:
@@ -14,8 +14,8 @@ on:
paths:
- "Frameworks/Resources/*.xcstrings"
- "Scripts/normalize-string-catalogs.py"
- "Scripts/check-cjk-localization.py"
- "Scripts/Tests/test_cjk_localization.py"
- "Scripts/check-localization.py"
- "Scripts/Tests/test_localization.py"
- "Scripts/Tests/test_normalize_string_catalogs.py"
- ".github/workflows/localization.yml"
@@ -31,6 +31,6 @@ jobs:
- name: Test catalog normalization
run: python3 -m unittest discover -s Scripts/Tests -p 'test_normalize_string_catalogs.py'
- name: Test localization checks
run: python3 -m unittest discover -s Scripts/Tests -p 'test_cjk_localization.py'
- name: Check catalog stability and CJK translations
run: python3 Scripts/check-cjk-localization.py
run: python3 -m unittest discover -s Scripts/Tests -p 'test_localization.py'
- name: Check catalog stability and translations
run: python3 Scripts/check-localization.py
Submodule AgentKit updated: 8108c9bb87...ca325632bc.
+162
View File
@@ -5,18 +5,36 @@
"comment" : "Bundle display name",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Termind"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -35,18 +53,36 @@
"comment" : "Bundle name",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Termind"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -65,18 +101,36 @@
"comment" : "Privacy - Camera Usage Description",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind verwendet die Kamera, um Fotos an Ihren Chat anzuhängen."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind uses the camera to attach photos to your chat."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind utilise l’appareil photo pour joindre des photos à votre chat."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "チャットに写真を添付するために、Termindがカメラを使用します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind는 채팅에 사진을 첨부하기 위해 카메라를 사용합니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -94,18 +148,36 @@
"NSFaceIDUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Authentifizieren Sie sich, um Ihre SSH-Schlüssel zu verwenden."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Authenticate to use your SSH keys."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Authentifiez-vous pour utiliser vos clés SSH."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH鍵を使用するために認証します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH 키를 사용하려면 인증하세요."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -123,18 +195,36 @@
"NSLocalNetworkUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind verbindet sich mit Servern und Modellanbietern in Ihrem lokalen Netzwerk."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind connects to servers and model providers on your local network."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind se connecte aux serveurs et aux fournisseurs de modèles sur votre réseau local."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindがローカルネットワーク上のサーバやモデルプロバイダに接続します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind는 로컬 네트워크의 서버와 모델 제공업체에 연결합니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -153,18 +243,36 @@
"comment" : "Privacy - Microphone Usage Description",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind verwendet das Mikrofon, um Sprache in Ihren Chatentwurf zu transkribieren."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Termind uses the microphone to transcribe speech into your chat draft."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind utilise le microphone pour transcrire vos paroles dans votre brouillon de chat."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "音声をチャットの下書きに文字起こしするために、Termindがマイクを使用します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind는 음성을 채팅 초안의 텍스트로 변환하기 위해 마이크를 사용합니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -183,18 +291,36 @@
"comment" : "Privacy - Speech Recognition Usage Description",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind verwendet die Apple-Spracherkennung, um Ihre Aufnahme in Text umzuwandeln. Je nach Systemeinstellungen und Sprachunterstützung verarbeitet Apple die Audiodaten möglicherweise online."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Termind uses Apple Speech Recognition to turn your recording into text. Depending on system settings and language support, Apple may process the audio online."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind utilise la reconnaissance vocale Apple pour convertir votre enregistrement en texte. Selon les réglages du système et les langues prises en charge, Apple peut traiter l’audio en ligne."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "TermindはAppleの音声認識を使用して録音をテキストに変換します。システム設定や言語の対応状況によっては、Appleが音声をオンラインで処理する場合があります。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind는 Apple 음성 인식을 사용하여 녹음을 텍스트로 변환합니다. 시스템 설정 및 언어 지원에 따라 Apple이 오디오를 온라인으로 처리할 수 있습니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -213,18 +339,36 @@
"comment" : "Title of the Home Screen quick action that opens the connection picker.",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verbinden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connect"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se connecter"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "接続"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "연결"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -242,12 +386,30 @@
"Termind SFTP Items" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind-SFTP-Objekte"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Éléments SFTP Termind"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind SFTP項目"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind SFTP 항목"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
File diff suppressed because it is too large. Load diff
@@ -3431,7 +3431,7 @@ extension ConversationModel {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english: en
case .system, .english, .german, .french, .korean: en
}
}
let now = Date(timeIntervalSinceReferenceDate: 809_020_800)
@@ -27,7 +27,10 @@ public nonisolated enum AppLocalization {
switch locale.language.languageCode?.identifier {
case "zh": locale.language.script?.identifier == "Hant" ? "zh-Hant" : "zh-Hans"
case "en": "en"
case "de": "de"
case "fr": "fr"
case "ja": "ja"
case "ko": "ko"
default: nil
}
guard let localization,
@@ -41,7 +44,10 @@ public nonisolated enum AppLocalization {
public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
case system
case english = "en"
case german = "de"
case french = "fr"
case japanese = "ja"
case korean = "ko"
case simplifiedChinese = "zh-Hans"
case traditionalChinese = "zh-Hant"
@@ -51,7 +57,10 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
switch self {
case .system: String(localized: "Follow System")
case .english: "English"
case .german: "Deutsch"
case .french: "Français"
case .japanese: "日本語"
case .korean: "한국어"
case .simplifiedChinese: "简体中文"
case .traditionalChinese: "繁體中文"
}
@@ -90,7 +99,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
switch language {
case .system:
defaults.removeObject(forKey: "AppleLanguages")
case .english, .japanese, .simplifiedChinese, .traditionalChinese:
case .english, .german, .french, .japanese, .korean, .simplifiedChinese, .traditionalChinese:
defaults.set([language.rawValue], forKey: "AppleLanguages")
}
}
@@ -99,7 +108,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
switch self {
case .system: .autoupdatingCurrent
case .english: Locale(identifier: "en")
case .japanese, .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
case .german, .french, .japanese, .korean, .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
}
}
}
@@ -83,7 +83,7 @@ nonisolated public enum AgentToolPresentation {
let action = localizedActionVerb(operation.action, locale: locale)
let title =
usesLocalizedActionTemplates(locale)
? localized("Approval tool action: \(action) \(operation.activity)", locale: locale)
? uppercaseFirst(localized("Approval tool action: \(action) \(operation.activity)", locale: locale))
: uppercaseFirst(actionName(operation.action, form: .infinitive, locale: locale)) + " "
+ operation.activity
return AgentKnowledgeToolPresentation.title(title)
@@ -101,7 +101,7 @@ nonisolated public enum AgentToolPresentation {
let action = localizedActionVerb(kind, locale: locale)
if usesLocalizedActionTemplates(locale) {
guard let hostName, !hostName.isEmpty else {
return localized("Approval tool action: \(action) \(activity)", locale: locale)
return uppercaseFirst(localized("Approval tool action: \(action) \(activity)", locale: locale))
}
return localized(
"Approval tool action target: \(action) \(activity) \(hostName)",
@@ -292,6 +292,12 @@ nonisolated public enum AgentToolPresentation {
if locale.language.languageCode?.identifier == "zh" {
return clauses.joined(separator: ",")
}
if locale.language.languageCode?.identifier == "de" {
return clauses.joined(separator: "; ")
}
if locale.language.languageCode?.identifier == "fr" {
return clauses.joined(separator: "\u{202F}; ")
}
return clauses.enumerated()
.map { index, clause in index == 0 ? clause : lowercaseFirst(clause) }
.joined(separator: ", ")
@@ -1066,7 +1072,7 @@ nonisolated public enum AgentToolPresentation {
let action = localizedActionVerb(kind, locale: locale)
let displayedActivity =
isArgumentValue
? (locale.language.languageCode?.identifier == "ja" ? "「\(activity)」" : "“\(activity)”")
? quotedActivity(activity, locale: locale)
: activity
let rendered: String =
switch state {
@@ -1294,7 +1300,17 @@ nonisolated public enum AgentToolPresentation {
}
private static func usesLocalizedActionTemplates(_ locale: Locale) -> Bool {
["zh", "ja"].contains(locale.language.languageCode?.identifier ?? "")
["de", "fr", "zh", "ja", "ko"].contains(locale.language.languageCode?.identifier ?? "")
}
private static func quotedActivity(_ activity: String, locale: Locale) -> String {
switch locale.language.languageCode?.identifier {
case "de": "„\(activity)“"
case "fr": "«\u{00A0}\(activity)\u{00A0}»"
case "ja": "「\(activity)」"
case "ko": "‘\(activity)’"
default: "“\(activity)”"
}
}
private static func activityName(
@@ -11,6 +11,12 @@
case .simplifiedChinese: prose = "部署之前先检查状态,并保留可复用的验证步骤。"
case .traditionalChinese: prose = "部署之前先檢查狀態,並保留可重用的驗證步驟。"
case .japanese: prose = "デプロイ前に状態を確認し、再利用できる検証手順を残してください。"
case .korean: prose = "배포 전에 상태를 확인하고 재사용할 수 있는 검증 절차를 남겨 주세요."
case .german:
prose =
"Prüfen Sie vor der Bereitstellung den Status und bewahren Sie wiederverwendbare Prüfschritte auf."
case .french:
prose = "Vérifiez l’état avant le déploiement et conservez des étapes de vérification réutilisables."
case .english, .system: prose = "Check status before deploying and retain reusable verification steps."
}
let body = Array(repeating: prose, count: 80).joined(separator: "\n")
@@ -24,6 +24,12 @@
"**檢查上游服務。**\n先讀取 `nginx` 日誌,再核對監聽連接埠;暫不變更設定。"
case .japanese:
"**上流サービスを確認します。**\nまず`nginx`のログを読み、待ち受けポートを確認します。設定はまだ変更しません。"
case .korean:
"**업스트림 서비스를 확인합니다.**\n먼저 `nginx` 로그를 읽고 수신 대기 포트를 확인합니다. 설정은 아직 변경하지 않습니다."
case .german:
"**Vorgelagerten Dienst prüfen.**\nZuerst die `nginx`-Protokolle lesen und dann die lauschenden Ports prüfen, ohne die Konfiguration zu ändern."
case .french:
"**Inspecter le service en amont.**\nLire les journaux `nginx`, puis vérifier les ports en écoute avant de modifier la configuration."
case .system, .english:
"**Inspect the upstream service.**\nRead the `nginx` logs, then check listening ports before changing configuration."
}
@@ -44,6 +50,9 @@
case .simplifiedChinese: "连接上游服务失败:连接被拒绝。"
case .traditionalChinese: "連線至上游服務失敗:連線遭拒。"
case .japanese: "上流サービスに接続できませんでした:接続が拒否されました。"
case .korean: "업스트림 서비스에 연결할 수 없습니다. 연결이 거부되었습니다."
case .german: "Verbindung zum vorgelagerten Dienst fehlgeschlagen: Verbindung abgelehnt."
case .french: "Échec de la connexion au service en amont : connexion refusée."
case .system, .english: "connect() failed: connection refused"
}
cards[1].result = AgentToolResult(
-121
View File
@@ -1,121 +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-cjk-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), 2)
self.assertTrue(any("zh-Hans" in failure for failure in failures))
self.assertTrue(any("zh-Hant" in failure for failure in failures))
if __name__ == "__main__":
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
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()
+25 -11
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Check Chinese and Japanese catalog coverage, format arguments, and spacing."""
"""Check all app translations, with language-specific plural and spacing rules."""
from __future__ import annotations
@@ -17,10 +17,6 @@ assert SPEC is not None and SPEC.loader is not None
CATALOGS = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(CATALOGS)
JAPANESE = r"\u3005\u3006\u3041-\u3096\u30A1-\u30FA\u30FC\u3400-\u4DBF\u4E00-\u9FFF"
LATIN = r"A-Za-z0-9%@`"
SPACE = r"[ \t\u00A0\u3000]+"
MIXED_SPACING = re.compile(rf"[{JAPANESE}]{SPACE}[{LATIN}]|[{LATIN}]{SPACE}[{JAPANESE}]")
FORMAT = re.compile(r"%(?:(?P<position>[1-9][0-9]*)\$)?[-+ #0]*[0-9]*(?:\.[0-9]+)?(?P<type>hh[diouxX]|h[diouxX]|ll[diouxX]|l[diouxXfFeEgG]|z[diouxX]|[diouxXfFeEgGaAcCsSp@%])")
@@ -47,11 +43,21 @@ def arguments(value: str) -> Counter:
return result
def problems(catalog: dict, languages=("zh-Hans", "zh-Hant", "ja")) -> list[str]:
SUPPORTED_LANGUAGES = ("zh-Hans", "zh-Hant", "ja", "ko", "de", "fr")
JAPANESE = r"\u3005\u3006\u3041-\u3096\u30A1-\u30FA\u30FC\u3400-\u4DBF\u4E00-\u9FFF"
LATIN = r"A-Za-z0-9%@`"
SPACE = r"[ \t\u00A0\u3000]+"
MIXED_SPACING = re.compile(rf"[{JAPANESE}]{SPACE}[{LATIN}]|[{LATIN}]{SPACE}[{JAPANESE}]")
def problems(catalog: dict, languages=SUPPORTED_LANGUAGES) -> list[str]:
return [problem for language in languages for problem in language_problems(catalog, language)]
def language_problems(catalog: dict, language: str) -> list[str]:
if language not in SUPPORTED_LANGUAGES:
raise ValueError(f"unsupported localization: {language}")
failures = []
for key, entry in catalog["strings"].items():
if entry.get("shouldTranslate") is False:
@@ -67,9 +73,16 @@ def language_problems(catalog: dict, language: str) -> list[str]:
"other", {}
).get("stringUnit", {"value": key})
expected = arguments(source_unit["value"])
if "plural" in english.get("variations", {}):
if set(localization.get("variations", {}).get("plural", {})) != {"other"}:
failures.append(f"{key!r}: {language} plural rules must contain only 'other'")
plural = localization.get("variations", {}).get("plural", {})
if "plural" in english.get("variations", {}) or plural:
categories = set(plural)
required = {"one", "other"} if language in ("de", "fr") else {"other"}
allowed = required | {"many"} if language == "fr" else required
if not required <= categories or not categories <= allowed:
failures.append(
f"{key!r}: {language} plural rules require {sorted(required)}"
+ (" (optional 'many')" if language == "fr" else " only")
)
for unit in translated:
value = unit.get("value")
if unit.get("state") != "translated" or not isinstance(value, str) or (key and not value):
@@ -79,11 +92,12 @@ def language_problems(catalog: dict, language: str) -> list[str]:
failures.append(f"{key!r}: {language} format arguments differ: {value!r}")
# Key-card subtitles intentionally separate the label and value.
spacing_value = value
invalid_spacing = None
if language == "ja":
if key == "Type %@":
spacing_value = re.sub(r"^種類 (?=%(?:1\$)?@)", "種類", value)
invalid_spacing = MIXED_SPACING.search(spacing_value)
else:
elif language in ("zh-Hans", "zh-Hant"):
if key == "Type %@":
spacing_value = re.sub(r"^(类型|類型) (?=%@)", r"\1", value)
han = r"\u3400-\u4DBF\u4E00-\u9FFF"
@@ -117,7 +131,7 @@ def main() -> int:
print(f"{path}: {failure}", file=sys.stderr)
failed |= bool(failures)
if not failed:
print("zh-Hans, zh-Hant, and ja coverage, format arguments, spacing, and catalog checks passed.")
print(f"{', '.join(SUPPORTED_LANGUAGES)} coverage, format arguments, plurals, spacing, and catalog checks passed.")
return int(failed)
+4 -1
View File
@@ -79,6 +79,9 @@ import TermindApplication
switch languageValue {
case "en": language = .english
case "ja": language = .japanese
case "ko": language = .korean
case "de": language = .german
case "fr": language = .french
case "zh-Hans": language = .simplifiedChinese
case "zh-Hant": language = .traditionalChinese
default: throw ParseError.unknownLanguage(languageValue)
@@ -283,7 +286,7 @@ import TermindApplication
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english: en
case .system, .english, .german, .french, .korean: en
}
}
}
@@ -15,7 +15,7 @@ extension HostLibraryModel {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english: en
case .system, .english, .german, .french, .korean: en
}
}
func distribution(_ id: String) -> DetectedDistribution {
+99
View File
@@ -995,6 +995,7 @@ struct AgentToolPresentationTests {
let locales: [(String, String?, String?)] = [
("en", nil, nil),
("ja", "「", "」"),
("ko", "‘", "’"),
("zh-Hans", "“", "”"),
("zh-Hant", "「", "」"),
]
@@ -1186,6 +1187,9 @@ struct AgentToolPresentationTests {
#expect(
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: "ja"))
== "2個のファイルの書き込みが完了しました。ファイルの書き込みに失敗しました")
#expect(
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: "ko"))
== "파일 2개 쓰기 완료, 파일 쓰기 실패")
}
@Test
@@ -1208,6 +1212,101 @@ struct AgentToolPresentationTests {
#expect(presentation.title == "web-01でコマンドを実行")
}
@Test(arguments: [
("de-DE", "Auf web-01: einen Befehl ausführen"),
("fr-CA", "Sur web-01\u{00A0}: exécuter une commande"),
])
func europeanApprovalLocalizesTheActionWithoutExposingCommandArguments(language: String, expected: String) {
let request = AgentApprovalRequest(
invocation: AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(id: "approval", name: "ssh_exec", arguments: .object(["command": .string("pwd")]))
),
descriptor: AgentToolDescriptor(
name: "ssh_exec", summary: "Execute over SSH", inputSchema: .object([:]), target: .host,
approvalPolicy: .ask,
presentation: .init(
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .sshCommand),
actionKind: .run)),
userIntent: "Inspect"
)
let presentation = AgentToolPresentation.approval(
for: request, hostName: "web-01", locale: Locale(identifier: language))
#expect(presentation.title == expected)
#expect(!presentation.title.contains("pwd"))
}
@Test(arguments: [
(
"de", "Abgeschlossen: „/tmp/report.txt“ lesen (auf web-01)",
"Fehlgeschlagen: „/tmp/report.txt“ lesen (auf web-01)"
),
(
"fr", "Terminé\u{00A0}: lire «\u{00A0}/tmp/report.txt\u{00A0}» (sur web-01)",
"Échec\u{00A0}: lire «\u{00A0}/tmp/report.txt\u{00A0}» (sur web-01)"
),
])
func europeanToolLifecycleUsesLocalizedTemplatesAndQuotationMarks(
language: String, completed: String, failed: String
) {
var card = makeCard(
name: "sftp_read", target: .host, arguments: ["path": .string("/tmp/report.txt")],
presentation: .init(
symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), actionKind: .read))
let locale = Locale(identifier: language)
card.state = .completed
#expect(AgentToolPresentation.summary(for: card, hostName: "web-01", locale: locale) == completed)
card.state = .failed
#expect(AgentToolPresentation.summary(for: card, hostName: "web-01", locale: locale) == failed)
}
@Test(arguments: [
(AgentToolCard.State.proposed, "준비 중"),
(.reviewing, "준비 중"),
(.waitingForApproval, "대기 중"),
(.waitingForUser, "대기 중"),
(.running, "중"),
(.completed, "완료"),
(.denied, "거부됨"),
(.failed, "실패"),
(.interrupted, "중단됨"),
])
func koreanToolLifecyclePlacesTheHostBeforeTheObjectAndAction(
state: AgentToolCard.State, suffix: String
) {
var card = makeCard(
name: "sftp_read", target: .host, arguments: ["path": .string("/tmp/report.txt")],
presentation: .init(
symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), actionKind: .read))
card.state = state
let locale = Locale(identifier: "ko-KR")
#expect(
AgentToolPresentation.summary(for: card, hostName: "web-01", locale: locale)
== "web-01에서 ‘/tmp/report.txt’ 읽기 \(suffix)")
#expect(
AgentToolPresentation.summary(for: card, locale: locale) == "‘/tmp/report.txt’ 읽기 \(suffix)")
}
@Test
func koreanApprovalUsesTheSemanticObjectRatherThanAnEnglishVerbPrefix() {
let request = AgentApprovalRequest(
invocation: AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "approval", name: "sftp_read", arguments: .object(["path": .string("/tmp/report.txt")]))
),
descriptor: AgentToolDescriptor(
name: "sftp_read", summary: "Read a remote file", inputSchema: .object([:]), target: .host,
approvalPolicy: .ask,
presentation: .init(
symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), actionKind: .read)),
userIntent: "Inspect"
)
let presentation = AgentToolPresentation.approval(
for: request, hostName: "web-01", locale: Locale(identifier: "ko"))
#expect(presentation.title == "web-01에서 파일 읽기")
}
@Test
func batchesCountArrayElementsAndKeepUnknownToolObjects() {
for activity: AgentToolDescriptor.Presentation.Activity in [
+149 -2
View File
@@ -12,6 +12,9 @@ struct AppLanguageTests {
("zh-Hans", "SSH认证失败:key detail;password detail"),
("zh-Hant", "SSH認證失敗:key detail;password detail"),
("ja", "SSH認証に失敗しました:key detail;password detail"),
("de", "SSH-Authentifizierung fehlgeschlagen: key detail; password detail"),
("fr", "Échec de l’authentification SSH\u{00A0}: key detail; password detail"),
("ko", "SSH 인증 실패: key detail; password detail"),
])
func authenticationFailuresUseOneLocalizedPrefixAndSemicolon(language: String, expected: String) {
let locale = Locale(identifier: language)
@@ -58,6 +61,28 @@ struct AppLanguageTests {
#expect(AppLanguage.traditionalChinese.displayName == "繁體中文")
}
@Test(arguments: [
(AppLanguage.german, "de", "Deutsch"),
(.french, "fr", "Français"),
(.korean, "ko", "한국어"),
])
func additionalLanguageChoicesPersistAndRestoreSystemNegotiation(
language: AppLanguage, tag: String, name: String
) throws {
let suiteName = "AppLanguageTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
#expect(AppLanguage.allCases.contains(language))
#expect(language.displayName == name)
#expect(language.locale.language.languageCode?.identifier == tag)
AppLanguage.store(language, in: defaults)
#expect(AppLanguage.stored(in: defaults) == language)
#expect(defaults.stringArray(forKey: "AppleLanguages") == [tag])
AppLanguage.store(.system, in: defaults)
#expect(AppLanguage.stored(in: defaults) == .system)
#expect(defaults.persistentDomain(forName: suiteName)?["AppleLanguages"] == nil)
}
@Test(arguments: ["zh-Hans-CN", "zh-CN", "zh_CN", "zh-Hans-SG", "zh"])
func legacySimplifiedChinesePreferencesMigrateToCanonicalTag(identifier: String) throws {
let defaults = try makeDefaults()
@@ -109,6 +134,9 @@ struct AppLanguageTests {
@Test(arguments: [
(AppLanguage.english, "The new language will be used after Termind relaunches."),
(.japanese, "新しい言語はTermindの再起動後に適用されます。"),
(.german, "Die neue Sprache wird nach dem Neustart von Termind verwendet."),
(.french, "La nouvelle langue sera utilisée après le redémarrage de Termind."),
(.korean, "새 언어는 Termind를 다시 실행한 후 적용됩니다."),
(.simplifiedChinese, "重新启动Termind后将使用新语言。"),
(.traditionalChinese, "重新啟動Termind後將使用新語言。"),
])
@@ -132,6 +160,121 @@ struct AppLanguageTests {
#expect(AppLocalization.string("\(3) files", locale: locale) == "3個のファイル")
}
@Test(arguments: ["de", "de-DE", "de-AT", "de_CH"])
func germanLocalesResolveCopyAndReorderedArguments(identifier: String) {
let locale = Locale(identifier: identifier)
#expect(AppLocalization.string("Settings", locale: locale) == "Einstellungen")
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "Abbrechen")
let duration = "2 s"
#expect(
AppLocalization.string("Command failed with exit code \(7) after \(duration).", locale: locale)
== "Befehl nach 2 s mit Exit-Code 7 fehlgeschlagen."
)
#expect(AppLocalization.string("Memory", locale: locale) == "Arbeitsspeicher")
#expect(AppLocalization.string("memory", locale: locale) == "Erinnerungen")
}
@Test(arguments: ["fr", "fr-FR", "fr-CA", "fr_BE"])
func frenchLocalesResolveCopyAndPreserveCommandAndHost(identifier: String) {
let locale = Locale(identifier: identifier)
#expect(AppLocalization.string("Settings", locale: locale) == "Réglages")
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "Annuler")
let command = "uptime"
let host = "web-01"
#expect(
AppLocalization.string("Ran “\(command)” on \(host)", locale: locale)
== "«\u{00A0}uptime\u{00A0}» exécuté sur web-01"
)
let count = "1.2k"
let prefix = "≈"
#expect(AppLocalization.string("\(prefix)\(count) tokens", locale: locale) == "≈1.2k tokens")
#expect(AppLocalization.string("Bearer Token", locale: locale) == "Jeton Bearer")
}
@Test(arguments: ["ko", "ko-KR", "ko_KR"])
func koreanLocalesResolveCopyAndReorderCommandArguments(identifier: String) {
let locale = Locale(identifier: identifier)
#expect(AppLocalization.string("Settings", locale: locale) == "설정")
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "취소")
let command = "uptime"
let host = "web-01"
#expect(
AppLocalization.string("Ran “\(command)” on \(host)", locale: locale)
== "web-01에서 ‘uptime’ 실행됨")
let duration = "2 s"
#expect(
AppLocalization.string("Command failed with exit code \(7) after \(duration).", locale: locale)
== "명령이 2 s 후 종료 코드 7로 실패했습니다.")
}
@Test(arguments: [
("Memory", "메모리"), ("memory", "기억"),
("Identity", "인증 프로필"), ("Credential", "자격 증명"),
("Password", "암호"), ("Passphrase", "암호 구문"),
("Export and Attach", "내보내고 호스트에 연결"),
("Discouraged", "사용자 확인 요청 안 함"),
])
func koreanTermsReflectTheirProductContext(key: String, expected: String) throws {
let path = try #require(Bundle.main.path(forResource: "ko", ofType: "lproj"))
let bundle = try #require(Bundle(path: path))
#expect(bundle.localizedString(forKey: key, value: nil, table: nil) == expected)
}
@Test(arguments: ["Google", "OpenAI", "사용자 모델"])
func koreanDynamicLabelsDoNotAssumeTheFinalConsonantOfAProviderName(provider: String) {
let locale = Locale(identifier: "ko")
#expect(
AppLocalization.resolve(
AppStrings.Assistant.modelHelp(modelName: "model-01", providerName: provider, effortName: nil),
locale: locale
) == "model-01 · 제공업체: \(provider)")
#expect(
AppLocalization.resolve(
AppStrings.Assistant.modelHelp(modelName: "model-01", providerName: provider, effortName: "높음"),
locale: locale
) == "model-01 · 제공업체: \(provider) · 추론 강도: 높음")
#expect(
AppLocalization.resolve(
AppStrings.Assistant.compactionSummary(summarized: "2,000 토큰", retained: "500 토큰"),
locale: locale
) == "압축됨 — 2,000 토큰 → 500 토큰")
}
@Test(arguments: [
("de", "Export and Attach", "Exportieren und zuordnen"),
("fr", "Export and Attach", "Exporter et associer"),
("de", "Retry Attachment", "Zuordnung erneut versuchen"),
("fr", "Retry Attachment", "Réessayer l’association"),
("de", "Discouraged", "Benutzerverifizierung nicht anfordern"),
("fr", "Discouraged", "Ne pas demander de vérification"),
])
func europeanKeySettingsDistinguishAssociationFromFileAttachments(
language: String, key: String, expected: String
) throws {
let path = try #require(Bundle.main.path(forResource: language, ofType: "lproj"))
let bundle = try #require(Bundle(path: path))
#expect(bundle.localizedString(forKey: key, value: nil, table: nil) == expected)
}
@Test(arguments: [
("de", 0, "0 Dateien"), ("de", 1, "1 Datei"), ("de", 2, "2 Dateien"),
("fr", 0, "0 fichier"), ("fr", 1, "1 fichier"), ("fr", 2, "2 fichiers"),
("ko", 0, "파일 0개"), ("ko", 1, "파일 1개"), ("ko", 2, "파일 2개"),
])
func countedResourcesUseCompiledPluralRules(language: String, count: Int, expected: String) {
#expect(AppLocalization.string("\(count) files", locale: Locale(identifier: language)) == expected)
}
@Test(arguments: [("de", "Verbinden"), ("fr", "Se connecter"), ("ko", "연결")])
func additionalBundleMetadataIsLocalized(language: String, expected: String) throws {
let path = try #require(Bundle.main.path(forResource: language, ofType: "lproj"))
let bundle = try #require(Bundle(path: path))
#expect(
bundle.localizedString(forKey: "QUICK_ACTION_CONNECT_TITLE", value: nil, table: "InfoPlist") == expected
)
#expect(bundle.localizedString(forKey: "CFBundleDisplayName", value: nil, table: "InfoPlist") == "Termind")
}
@Test(arguments: [("zh-Hans", "词元"), ("zh-Hant", "符元")])
func assistantTokenCountsUseModelTerminology(language: String, unit: String) {
let locale = Locale(identifier: language)
@@ -173,7 +316,7 @@ struct AppLanguageTests {
for (key, arguments) in interpolations.sorted(by: { $0.key < $1.key }) {
let entry = try #require(strings[key] as? [String: Any], "Missing forwarding template: \(key)")
let localizations = try #require(entry["localizations"] as? [String: Any])
for language in ["ja", "zh-Hans", "zh-Hant"] {
for language in ["de", "fr", "ja", "ko", "zh-Hans", "zh-Hant"] {
let localization = try #require(localizations[language] as? [String: Any])
let unit = try #require(localization["stringUnit"] as? [String: Any])
let translation = try #require(unit["value"] as? String)
@@ -212,7 +355,11 @@ struct AppLanguageTests {
let localizations = try #require(entry["localizations"] as? [String: Any])
let english = try #require(localizations["en"] as? [String: Any])
#expect(pluralForms(in: english).keys.sorted() == ["one", "other"])
for language in ["ja", "zh-Hans", "zh-Hant"] {
for language in ["de", "fr"] {
let localization = try #require(localizations[language] as? [String: Any])
#expect(pluralForms(in: localization).keys.sorted() == ["one", "other"])
}
for language in ["ja", "ko", "zh-Hans", "zh-Hant"] {
let localization = try #require(localizations[language] as? [String: Any])
#expect(pluralForms(in: localization).keys.sorted() == ["other"])
}
@@ -17,6 +17,9 @@ import TermindPresentation
let languages: [(argument: String, language: AppLanguage)] = [
("en", .english),
("ja", .japanese),
("ko", .korean),
("de", .german),
("fr", .french),
("zh-Hans", .simplifiedChinese),
("zh-Hant", .traditionalChinese),
]
@@ -65,9 +68,9 @@ import TermindPresentation
"Termind", "--termind-demo=vault:en",
])
}
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("fr")) {
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("unsupported")) {
try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo=hosts:fr",
"Termind", "--termind-demo=hosts:unsupported",
])
}
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("zh")) {
+4 -1
View File
@@ -1381,7 +1381,10 @@
hasScannedForEncodings = 0;
knownRegions = (
en,
de,
fr,
ja,
ko,
"zh-Hans",
"zh-Hant",
Base,
@@ -3000,7 +3003,7 @@
repositoryURL = "https://github.com/AkinoKaede/AgentKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.19.1;
minimumVersion = 0.19.2;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/AgentKit.git",
"state" : {
"revision" : "8108c9bb87c546860cfc1769571e115af919fe8a",
"version" : "0.19.1"
"revision" : "ca325632bc91886702673a79522e3b862ac940ff",
"version" : "0.19.2"
}
},
{