feat(localization): add Japanese language support and CJK validation

This commit is contained in:
Kaede Akino committed 2026-10-07 12:43:39 +08:00
1 parent 19beba46c9
commit 7148691114
20 files changed
+12692 -161

No files matched your search

+8 -4
View File
@@ -5,7 +5,8 @@ on:
paths:
- "Frameworks/Resources/*.xcstrings"
- "Scripts/normalize-string-catalogs.py"
- "Scripts/check-zh-localization.sh"
- "Scripts/check-cjk-localization.py"
- "Scripts/Tests/test_cjk_localization.py"
- "Scripts/Tests/test_normalize_string_catalogs.py"
- ".github/workflows/localization.yml"
push:
@@ -13,7 +14,8 @@ on:
paths:
- "Frameworks/Resources/*.xcstrings"
- "Scripts/normalize-string-catalogs.py"
- "Scripts/check-zh-localization.sh"
- "Scripts/check-cjk-localization.py"
- "Scripts/Tests/test_cjk_localization.py"
- "Scripts/Tests/test_normalize_string_catalogs.py"
- ".github/workflows/localization.yml"
@@ -28,5 +30,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Test catalog normalization
run: python3 -m unittest discover -s Scripts/Tests -p 'test_normalize_string_catalogs.py'
- name: Check catalog stability and Chinese translations
run: Scripts/check-zh-localization.sh
- 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
Submodule AgentKit updated: d6f4692de8...8108c9bb87.
+54
View File
@@ -11,6 +11,12 @@
"value" : "Termind"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -35,6 +41,12 @@
"value" : "Termind"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -59,6 +71,12 @@
"value" : "Termind uses the camera to attach photos to your chat."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "チャットに写真を添付するために、Termindがカメラを使用します。"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -82,6 +100,12 @@
"value" : "Authenticate to use your SSH keys."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH鍵を使用するために認証します。"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -105,6 +129,12 @@
"value" : "Termind connects to servers and model providers on your local network."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termindがローカルネットワーク上のサーバやモデルプロバイダに接続します。"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -129,6 +159,12 @@
"value" : "Termind uses the microphone to transcribe speech into your chat draft."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "音声をチャットの下書きに文字起こしするために、Termindがマイクを使用します。"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -153,6 +189,12 @@
"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."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "TermindはAppleの音声認識を使用して録音をテキストに変換します。システム設定や言語の対応状況によっては、Appleが音声をオンラインで処理する場合があります。"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -177,6 +219,12 @@
"value" : "Connect"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "接続"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
@@ -194,6 +242,12 @@
"Termind SFTP Items" : {
"extractionState" : "manual",
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termind SFTP項目"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
File diff suppressed because it is too large. Load diff
@@ -3482,10 +3482,11 @@ extension WeakAgentUserInteraction: AgentUserInteractionHandling {}
extension ConversationModel {
public static func demo(language: AppLanguage = .english) -> ConversationModel {
func text(_ en: String, _ zhHans: String, _ zhHant: String) -> String {
func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String {
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english: en
}
}
@@ -3495,8 +3496,7 @@ extension ConversationModel {
text: text(
"Find the cause of the 502 responses. Inspect only—do not change configuration.",
"查明 502 响应的原因。仅检查,不要更改配置。",
"找出 502 回應的原因。僅進行檢查,不要變更設定。"
),
"找出 502 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。"),
createdAt: now.addingTimeInterval(-18)
)
let answer = ChatMessage(
@@ -3504,14 +3504,15 @@ extension ConversationModel {
text: text(
"The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.",
"上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。",
"上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。"
"上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。",
"上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。"
),
createdAt: now
)
let runID = UUID()
let descriptor = AgentToolDescriptor(
name: "ssh_exec",
summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。"),
summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。"),
inputSchema: .object([:]), target: .host, approvalPolicy: .approve,
concurrency: .parallel,
presentation: AgentToolDescriptor.Presentation(
@@ -3548,7 +3549,7 @@ extension ConversationModel {
)
]
)
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應")
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應", "502応答の診断")
conversation.messages = [
user, answer,
]
@@ -27,6 +27,7 @@ public nonisolated enum AppLocalization {
switch locale.language.languageCode?.identifier {
case "zh": locale.language.script?.identifier == "Hant" ? "zh-Hant" : "zh-Hans"
case "en": "en"
case "ja": "ja"
default: nil
}
guard let localization,
@@ -40,6 +41,7 @@ public nonisolated enum AppLocalization {
public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
case system
case english = "en"
case japanese = "ja"
case simplifiedChinese = "zh-Hans"
case traditionalChinese = "zh-Hant"
@@ -49,6 +51,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
switch self {
case .system: String(localized: "Follow System")
case .english: "English"
case .japanese: "日本語"
case .simplifiedChinese: "简体中文"
case .traditionalChinese: "繁體中文"
}
@@ -87,7 +90,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
switch language {
case .system:
defaults.removeObject(forKey: "AppleLanguages")
case .english, .simplifiedChinese, .traditionalChinese:
case .english, .japanese, .simplifiedChinese, .traditionalChinese:
defaults.set([language.rawValue], forKey: "AppleLanguages")
}
}
@@ -96,7 +99,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
switch self {
case .system: .autoupdatingCurrent
case .english: Locale(identifier: "en")
case .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
case .japanese, .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
}
}
}
@@ -82,7 +82,7 @@ nonisolated public enum AgentToolPresentation {
if let operation = AgentKnowledgeToolPresentation.operation(for: card, locale: locale, approval: true) {
let action = localizedActionVerb(operation.action, locale: locale)
let title =
isChinese(locale)
usesLocalizedActionTemplates(locale)
? localized("Approval tool action: \(action) \(operation.activity)", locale: locale)
: uppercaseFirst(actionName(operation.action, form: .infinitive, locale: locale)) + " "
+ operation.activity
@@ -92,14 +92,14 @@ nonisolated public enum AgentToolPresentation {
guard let kind = actionKind(for: card) else {
let action = localized(AppStrings.Assistant.run(activity), locale: locale)
guard let hostName, !hostName.isEmpty else { return action }
if isChinese(locale) {
if usesLocalizedActionTemplates(locale) {
return localized("Approval action target: \(action) \(hostName)", locale: locale)
}
return "\(action) on \(hostName)"
}
let action = localizedActionVerb(kind, locale: locale)
if isChinese(locale) {
if usesLocalizedActionTemplates(locale) {
guard let hostName, !hostName.isEmpty else {
return localized("Approval tool action: \(action) \(activity)", locale: locale)
}
@@ -285,6 +285,10 @@ nonisolated public enum AgentToolPresentation {
let clauses = order.compactMap { key in
grouped[key].map { batchClause($0, locale: locale) }
}
if locale.language.languageCode?.identifier == "ja" {
// These clauses are sentences. The enumeration comma cannot join them.
return clauses.joined(separator: "。")
}
if locale.language.languageCode?.identifier == "zh" {
return clauses.joined(separator: ",")
}
@@ -630,7 +634,7 @@ nonisolated public enum AgentToolPresentation {
return RenderedOperation(text: activityName(.suggestion, locale: locale), isArgumentValue: false)
}
guard let presentation = card.descriptor.presentation else {
if isChinese(locale),
if usesLocalizedActionTemplates(locale),
let recovered = recoveredBuiltInOperation(
for: card, locale: locale
)
@@ -1058,9 +1062,12 @@ nonisolated public enum AgentToolPresentation {
state: AgentToolCard.State, activity: String, isArgumentValue: Bool,
target: String?, locale: Locale
) -> String {
if isChinese(locale) {
if usesLocalizedActionTemplates(locale) {
let action = localizedActionVerb(kind, locale: locale)
let displayedActivity = isArgumentValue ? "“\(activity)”" : activity
let displayedActivity =
isArgumentValue
? (locale.language.languageCode?.identifier == "ja" ? "「\(activity)」" : "“\(activity)”")
: activity
let rendered: String =
switch state {
case .proposed, .reviewing:
@@ -1129,9 +1136,9 @@ nonisolated public enum AgentToolPresentation {
) -> String {
// Commands already carry their own shell punctuation. English reads
// more naturally without another pair of prose quotes around the
// entire command, while Chinese uses quotation marks to separate the
// entire command, while Chinese and Japanese use quotation marks to separate the
// command from the surrounding sentence.
if !isChinese(locale) {
if !usesLocalizedActionTemplates(locale) {
return switch kind {
case .sshPreparingCommand:
target.map { "Preparing to run \(activity) on \($0)" }
@@ -1208,7 +1215,7 @@ nonisolated public enum AgentToolPresentation {
_ kind: AgentToolDescriptor.Presentation.ActionKind,
form: ActionForm, locale: Locale
) -> String {
if isChinese(locale) { return localizedActionVerb(kind, locale: locale) }
if usesLocalizedActionTemplates(locale) { return localizedActionVerb(kind, locale: locale) }
let forms: ActionForms =
switch kind {
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
@@ -1286,8 +1293,8 @@ nonisolated public enum AgentToolPresentation {
}
}
private static func isChinese(_ locale: Locale) -> Bool {
locale.language.languageCode?.identifier == "zh"
private static func usesLocalizedActionTemplates(_ locale: Locale) -> Bool {
["zh", "ja"].contains(locale.language.languageCode?.identifier ?? "")
}
private static func activityName(
@@ -10,6 +10,7 @@
switch language {
case .simplifiedChinese: prose = "部署之前先检查状态,并保留可复用的验证步骤。"
case .traditionalChinese: prose = "部署之前先檢查狀態,並保留可重用的驗證步驟。"
case .japanese: prose = "デプロイ前に状態を確認し、再利用できる検証手順を残してください。"
case .english, .system: prose = "Check status before deploying and retain reusable verification steps."
}
let body = Array(repeating: prose, count: 80).joined(separator: "\n")
@@ -22,6 +22,8 @@
"**检查上游服务。**\n先读取 `nginx` 日志,再核对监听端口;暂不更改配置。"
case .traditionalChinese:
"**檢查上游服務。**\n先讀取 `nginx` 日誌,再核對監聽連接埠;暫不變更設定。"
case .japanese:
"**上流サービスを確認します。**\nまず`nginx`のログを読み、待ち受けポートを確認します。設定はまだ変更しません。"
case .system, .english:
"**Inspect the upstream service.**\nRead the `nginx` logs, then check listening ports before changing configuration."
}
@@ -41,6 +43,7 @@
switch language {
case .simplifiedChinese: "连接上游服务失败:连接被拒绝。"
case .traditionalChinese: "連線至上游服務失敗:連線遭拒。"
case .japanese: "上流サービスに接続できませんでした:接続が拒否されました。"
case .system, .english: "connect() failed: connection refused"
}
cards[1].result = AgentToolResult(
+121
View File
@@ -0,0 +1,121 @@
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()
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Check Chinese and Japanese catalog coverage, format arguments, and spacing."""
from __future__ import annotations
import argparse
from collections import Counter
import importlib.util
from pathlib import Path
import re
import sys
SCRIPTS = Path(__file__).resolve().parent
SPEC = importlib.util.spec_from_file_location("catalog_normalizer", SCRIPTS / "normalize-string-catalogs.py")
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@%])")
def units(value: dict):
if "stringUnit" in value:
yield value["stringUnit"]
for key, child in value.items():
if key != "stringUnit" and isinstance(child, dict):
yield from units(child)
def arguments(value: str) -> Counter:
"""Positions matter: translating word order must not exchange argument types."""
result = Counter()
next_position = 1
for match in FORMAT.finditer(value):
kind = match["type"]
if kind == "%":
result[(0, "%")] += 1
continue
position = int(match["position"]) if match["position"] else next_position
next_position += match["position"] is None
result[(position, kind)] += 1
return result
def problems(catalog: dict, languages=("zh-Hans", "zh-Hant", "ja")) -> list[str]:
return [problem for language in languages for problem in language_problems(catalog, language)]
def language_problems(catalog: dict, language: str) -> list[str]:
failures = []
for key, entry in catalog["strings"].items():
if entry.get("shouldTranslate") is False:
continue
localizations = entry.get("localizations", {})
localization = localizations.get(language, {})
translated = list(units(localization))
if not translated:
failures.append(f"{key!r}: missing {language} translation")
continue
english = localizations.get(catalog["sourceLanguage"], {})
source_unit = english.get("stringUnit") or english.get("variations", {}).get("plural", {}).get(
"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'")
for unit in translated:
value = unit.get("value")
if unit.get("state") != "translated" or not isinstance(value, str) or (key and not value):
failures.append(f"{key!r}: unfinished {language} translation")
continue
if arguments(value) != expected:
failures.append(f"{key!r}: {language} format arguments differ: {value!r}")
# Key-card subtitles intentionally separate the label and value.
spacing_value = value
if language == "ja":
if key == "Type %@":
spacing_value = re.sub(r"^種類 (?=%(?:1\$)?@)", "種類", value)
invalid_spacing = MIXED_SPACING.search(spacing_value)
else:
if key == "Type %@":
spacing_value = re.sub(r"^(类型|類型) (?=%@)", r"\1", value)
han = r"\u3400-\u4DBF\u4E00-\u9FFF"
invalid_spacing = re.search(rf"[{han}]", spacing_value) and (
re.search(rf"[{han}]{SPACE}[{LATIN}]|[{LATIN}]{SPACE}[{han}]", spacing_value)
or re.search(r"[ \t]+[—·→:,。!?;、/:]|[—·→:,。!?;、/:][ \t]+", spacing_value)
or re.search(rf"[{han}][,.!?;]|[,.!?;][{han}]", spacing_value)
)
if invalid_spacing:
failures.append(f"{key!r}: invalid {language} spacing: {value!r}")
return failures
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("catalogs", nargs="*", type=Path)
args = parser.parse_args()
failed = False
for path in args.catalogs or CATALOGS.DEFAULT_CATALOGS:
try:
original = path.read_bytes()
failures = problems(CATALOGS.load_catalog(original))
if CATALOGS.normalize(original) != original:
failures.append("expected manual extraction state and canonical Xcode formatting; "
"run Scripts/normalize-string-catalogs.py --write")
except (OSError, ValueError) as error:
print(f"{path}: {error}", file=sys.stderr)
failed = True
continue
for failure in failures:
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.")
return int(failed)
if __name__ == "__main__":
sys.exit(main())
-88
View File
@@ -1,88 +0,0 @@
#!/bin/bash
set -euo pipefail
script_dir=$(cd "$(dirname "$0")" && pwd)
if [[ $# -gt 1 ]]; then
echo "Usage: $0 [catalog]" >&2
exit 2
fi
if [[ $# -eq 1 ]]; then
catalogs=("$1")
else
catalogs=(
"$script_dir/../Frameworks/Resources/Localizable.xcstrings"
"$script_dir/../Frameworks/Resources/InfoPlist.xcstrings"
)
fi
python3 "$script_dir/normalize-string-catalogs.py" --check "${catalogs[@]}"
for catalog in "${catalogs[@]}"; do
if ! jq empty "$catalog"; then
echo "Invalid string catalog: $catalog" >&2
exit 1
fi
for language in zh-Hans zh-Hant; do
missing=$(jq -r --arg language "$language" '
.strings | to_entries[]
| select(.value.shouldTranslate != false and .value.localizations[$language] == null)
| .key
' "$catalog")
if [[ -n "$missing" ]]; then
echo "Missing $language localizations:" >&2
echo "$missing" >&2
exit 1
fi
unfinished=$(jq -r --arg language "$language" '
.strings | to_entries[] as $entry
| select($entry.value.shouldTranslate != false)
| [
$entry.value.localizations[$language]
| .. | objects | .stringUnit?
| select(. != null and (.state != "translated" or (.value == "" and $entry.key != "")))
]
| select(length > 0)
| $entry.key
' "$catalog")
if [[ -n "$unfinished" ]]; then
echo "Unfinished $language localizations:" >&2
echo "$unfinished" >&2
exit 1
fi
violations=$(jq -r --arg language "$language" '
.strings | to_entries[] as $entry
| select($entry.value.shouldTranslate != false)
| [
($entry.value.localizations[$language] // {})
| .. | objects | .stringUnit?.value? // empty
]
| .[]
| select(
# Key-card type subtitles intentionally separate the label and value.
(if $entry.key == "Type %@" then sub("^(?<label>类型|類型) (?=%@)"; "\(.label)") else . end)
|
test("\\p{Han}")
and (
test("\\p{Han}[ \\t]+[A-Za-z0-9%@`]|[A-Za-z0-9%@`][ \\t]+\\p{Han}")
or test("[ \\t]+[—·→:,。!?;、/:]|[—·→:,。!?;、/:][ \\t]+")
or test("\\p{Han}[,.!?;]|[,.!?;]\\p{Han}")
)
)
| "\($entry.key): \(.)"
' "$catalog")
if [[ -n "$violations" ]]; then
echo "Invalid $language spacing:" >&2
echo "$violations" >&2
exit 1
fi
done
done
echo "zh-Hans and zh-Hant localization checks passed."
+5 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
usage() {
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command|metrics|metrics-detail|app-settings> <en|zh-Hans|zh-Hant>"
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command|metrics|metrics-detail|app-settings> <en|ja|zh-Hans|zh-Hant>"
}
if (( $# != 2 )); then
@@ -28,6 +28,10 @@ case "$language" in
apple_language="en"
apple_locale="en_US"
;;
ja)
apple_language="ja"
apple_locale="ja_JP"
;;
zh-Hans)
apple_language="zh-Hans"
apple_locale="zh-Hans"
+11 -9
View File
@@ -29,12 +29,12 @@ import TermindApplication
return "Pass --termind-demo only once."
case .invalidFormat(let value):
return "Invalid Demo argument \(value). Expected "
+ "--termind-demo=<hosts|workspace|agent|command|metrics|metrics-detail|app-settings>:<en|zh-Hans|zh-Hant>."
+ "--termind-demo=<hosts|workspace|agent|command|metrics|metrics-detail|app-settings>:<en|ja|zh-Hans|zh-Hant>."
case .unknownScene(let value):
return
"Unknown Demo scene \(value). Expected hosts, workspace, agent, command, metrics, metrics-detail, or app-settings."
case .unknownLanguage(let value):
return "Unknown Demo language \(value). Expected en or zh."
return "Unknown Demo language \(value). Expected en, ja, zh-Hans, or zh-Hant."
}
}
}
@@ -78,6 +78,7 @@ import TermindApplication
let language: AppLanguage
switch languageValue {
case "en": language = .english
case "ja": language = .japanese
case "zh-Hans": language = .simplifiedChinese
case "zh-Hant": language = .traditionalChinese
default: throw ParseError.unknownLanguage(languageValue)
@@ -147,10 +148,10 @@ import TermindApplication
let session = SSHSessionModel.demo(host: host)
let pane = Pane(.sshSession(session))
pane.customTitle = text(en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷")
pane.customTitle = text(en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷", ja: "nginxの診断")
let workspace = Workspace(pane: pane)
workspace.customTitle = text(
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷")
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断")
workspace.focusedPaneID = pane.id
let conversation = ConversationModel.demo(language: language)
@@ -192,12 +193,12 @@ import TermindApplication
let first = Pane(.sshSession(firstSession))
first.customTitle =
showsCommandResult
? text(en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌")
? text(en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌", ja: "nginxのログ")
: "api-01"
let sftpChannel = SFTPChannelModel.demo(hostID: saved[0].id)
sftpChannel.selection = ["/etc/nginx/nginx.conf"]
let sftp = Pane(.sftpChannel(sftpChannel))
sftp.customTitle = text(en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定")
sftp.customTitle = text(en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定", ja: "nginxの設定")
let secondSession =
showsCommandResult
? SSHSessionModel.demo(host: saved[1])
@@ -231,8 +232,8 @@ import TermindApplication
workspace.focusedPaneID = first.id
workspace.customTitle =
showsCommandResult
? text(en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查")
: text(en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷")
? text(en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查", ja: "ログの調査")
: text(en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断")
workspace.broadcastsInput = !showsCommandResult
if showsCommandResult {
workspace.commandGeneratorState = .inserted(targetCount: 1)
@@ -277,10 +278,11 @@ import TermindApplication
]).joined(separator: "\r\n")
}
private func text(en: String, zhHans: String, zhHant: String) -> String {
private func text(en: String, zhHans: String, zhHant: String, ja: String) -> String {
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english: en
}
}
@@ -10,10 +10,11 @@ extension HostLibraryModel {
/// Seed data for P0, so the sidebar and workspace list can be exercised before any
/// persistence or networking exists.
static func demo(language: AppLanguage = .english, includesConnectionActivity: Bool = true) -> HostLibraryModel {
func text(_ en: String, _ zhHans: String, _ zhHant: String) -> String {
func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String {
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english: en
}
}
@@ -24,22 +25,22 @@ extension HostLibraryModel {
return distribution
}
let production = HostGroup(name: text("Production", "生产环境", "正式環境"), sortIndex: 0)
let staging = HostGroup(name: text("Staging", "预发布环境", "測試環境"), sortIndex: 1)
let homelab = HostGroup(name: text("Homelab", "家庭实验室", "家庭實驗室"), sortIndex: 2)
let production = HostGroup(name: text("Production", "生产环境", "正式環境", "本番環境"), sortIndex: 0)
let staging = HostGroup(name: text("Staging", "预发布环境", "測試環境", "ステージング"), sortIndex: 1)
let homelab = HostGroup(name: text("Homelab", "家庭实验室", "家庭實驗室", "ホームラボ"), sortIndex: 2)
let edge = HostGroup(
name: text("Edge", "边缘节点", "邊緣節點"), parentID: production.id, sortIndex: 0
name: text("Edge", "边缘节点", "邊緣節點", "エッジ"), parentID: production.id, sortIndex: 0
)
let now = Date(timeIntervalSinceReferenceDate: 776_000_000)
let personalKey = SSHKeyRef(
label: text("Personal", "个人", "個人"), algorithm: .ed25519,
label: text("Personal", "个人", "個人", "個人用"), algorithm: .ed25519,
fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM",
credentialRef: "termind.key.personal", colorTag: .blue
)
let workKey = SSHKeyRef(
label: text("Work (no sync)", "工作(不同步)", "工作(不同步)"), algorithm: .ecdsa256,
label: text("Work (no sync)", "工作(不同步)", "工作(不同步)", "仕事用(同期なし)"), algorithm: .ecdsa256,
fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY",
credentialRef: "termind.key.work",
storage: .thisDevice, colorTag: .purple
@@ -49,24 +50,25 @@ extension HostLibraryModel {
// One of each shape, so all four combinations of the two secrets are on
// screen at P0: key only, key *and* password, password only, neither.
let deploy = Identity(
name: text("Deploy", "部署账号", "部署帳號"), username: "deploy", keyID: personalKey.id,
notes: text("Production web tier", "生产环境 Web 层", "正式環境 Web 層"),
name: text("Deploy", "部署账号", "部署帳號", "デプロイ"), username: "deploy", keyID: personalKey.id,
notes: text("Production web tier", "生产环境 Web 层", "正式環境 Web 層", "本番環境のWeb層"),
colorTag: .green
)
let postgres = Identity(
name: text("Postgres admin", "Postgres 管理员", "Postgres 管理員"), username: "postgres",
name: text("Postgres admin", "Postgres 管理员", "Postgres 管理員", "Postgres管理者"), username: "postgres",
credentialRef: "termind.password.postgres", keyID: workKey.id,
notes: text("Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo"),
notes: text(
"Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo", "ログインには鍵、sudoにはパスワード"),
colorTag: .orange
)
let consoleRoot = Identity(
name: text("Console root", "控制台 root", "主控台 root"), username: "root",
name: text("Console root", "控制台 root", "主控台 root", "コンソールのroot"), username: "root",
credentialRef: "termind.password.console-root",
notes: text("Out-of-band only", "仅限带外管理", "僅限頻外管理"), colorTag: .red
notes: text("Out-of-band only", "仅限带外管理", "僅限頻外管理", "帯域外管理専用"), colorTag: .red
)
let unfinished = Identity(
name: text("New admin", "新管理员", "新增管理員"), username: "admin",
notes: text("No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊")
name: text("New admin", "新管理员", "新增管理員", "新しい管理者"), username: "admin",
notes: text("No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊", "シークレットは未選択")
)
// Half the fleet is left without an identity on purpose, so the "not set
@@ -125,35 +127,35 @@ extension HostLibraryModel {
]
let diagnostics = SnippetPackage(
name: text("Diagnostics", "诊断", "診斷"), sortIndex: 0
name: text("Diagnostics", "诊断", "診斷", "診断"), sortIndex: 0
)
let webOperations = SnippetPackage(
name: text("Web Operations", "Web 运维", "Web 維運"),
name: text("Web Operations", "Web 运维", "Web 維運", "Web運用"),
parentID: diagnostics.id, sortIndex: 0
)
let snippets = [
Snippet(
title: text("Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量"),
title: text("Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量", "ディレクトリ別のディスク使用量"),
body: "du -sh /* | sort -h",
packageID: diagnostics.id, targetHostIDs: [hosts[0].id],
sortIndex: 0
),
Snippet(
title: text("Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序"),
title: text("Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序", "メモリ使用量の多いプロセス"),
body: "ps aux --sort=-%mem | head -20",
packageID: diagnostics.id,
targetHostIDs: [hosts[0].id, hosts[1].id],
sortIndex: 1
),
Snippet(
title: text("Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤"),
title: text("Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤", "nginxのエラーを追跡"),
body: "tail -f /var/log/nginx/error.log",
packageID: webOperations.id,
targetHostIDs: [hosts[0].id, hosts[1].id],
sortIndex: 0
),
Snippet(
title: text("Listening ports", "监听中的端口", "監聽中的連接埠"),
title: text("Listening ports", "监听中的端口", "監聽中的連接埠", "待ち受けポート"),
body: "ss -tulpn", sortIndex: 3
),
]
@@ -187,11 +189,11 @@ extension HostLibraryModel {
PortForwardingRule(
hostID: db.id, kind: .local, bindPort: 5432,
targetHost: "localhost", targetPort: 5432,
label: text("Postgres", "Postgres 数据库", "Postgres 資料庫")
label: text("Postgres", "Postgres 数据库", "Postgres 資料庫", "Postgres")
),
PortForwardingRule(
hostID: web01.id, kind: .dynamic, bindPort: 1080,
label: text("SOCKS proxy", "SOCKS 代理", "SOCKS Proxy")
label: text("SOCKS proxy", "SOCKS 代理", "SOCKS Proxy", "SOCKSプロキシ")
),
]
@@ -214,7 +216,7 @@ extension HostLibraryModel {
hostID: web01.id, direction: .upload,
localPath: "~/Config/nginx.conf", remotePath: "/etc/nginx/nginx.conf",
byteCount: 8_192,
state: .failed(text("Permission denied", "权限被拒绝", "權限遭拒")),
state: .failed(text("Permission denied", "权限被拒绝", "權限遭拒", "アクセスが拒否されました")),
queuedAt: now.addingTimeInterval(-3_600)
),
SFTPTransfer(
@@ -245,7 +247,7 @@ extension HostLibraryModel {
store.setStatus(.connecting, for: db.id)
if let staging = hosts.first(where: { $0.name == "staging-app" }) {
store.setStatus(
.failed(text("Connection refused", "连接被拒绝", "連線遭拒")), for: staging.id
.failed(text("Connection refused", "连接被拒绝", "連線遭拒", "接続が拒否されました")), for: staging.id
)
}
+24
View File
@@ -994,6 +994,7 @@ struct AgentToolPresentationTests {
]
let locales: [(String, String?, String?)] = [
("en", nil, nil),
("ja", "「", "」"),
("zh-Hans", "“", "”"),
("zh-Hant", "「", "」"),
]
@@ -1182,6 +1183,29 @@ struct AgentToolPresentationTests {
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: language))
== expected)
}
#expect(
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: "ja"))
== "2個のファイルの書き込みが完了しました。ファイルの書き込みに失敗しました")
}
@Test
func japaneseApprovalUsesTheActionAndHostInJapaneseWordOrder() {
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: "ja-JP"))
#expect(presentation.title == "web-01でコマンドを実行")
}
@Test
+26 -6
View File
@@ -1,6 +1,7 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@@ -23,6 +24,12 @@ struct AppLanguageTests {
#expect(AppLanguage.stored(in: defaults) == .english)
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["en"])
AppLanguage.store(.japanese, in: defaults)
#expect(AppLanguage.stored(in: defaults) == .japanese)
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["ja"])
#expect(AppLanguage.japanese.locale.identifier == "ja")
#expect(AppLanguage.japanese.displayName == "日本語")
AppLanguage.store(.simplifiedChinese, in: defaults)
#expect(AppLanguage.stored(in: defaults) == .simplifiedChinese)
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["zh-Hans"])
@@ -84,6 +91,19 @@ struct AppLanguageTests {
#expect(AppLocalization.string("Settings", locale: Locale(identifier: "zh-Hant")) == "設定")
}
@Test(arguments: ["ja", "ja-JP", "ja_JP"])
func japaneseLocalesResolveAppCopyAndPreserveInterpolations(identifier: String) {
let locale = Locale(identifier: identifier)
#expect(AppLocalization.string("Settings", locale: locale) == "設定")
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "キャンセル")
let host = "web-01"
let command = "uptime"
#expect(
AppLocalization.string("Ran “\(command)” on \(host)", locale: locale)
== "web-01で「uptime」を実行しました")
#expect(AppLocalization.string("\(3) files", locale: locale) == "3個のファイル")
}
@Test(arguments: [("zh-Hans", "词元"), ("zh-Hant", "符元")])
func assistantTokenCountsUseModelTerminology(language: String, unit: String) {
let locale = Locale(identifier: language)
@@ -125,9 +145,9 @@ 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 ["zh-Hans", "zh-Hant"] {
let chinese = try #require(localizations[language] as? [String: Any])
let unit = try #require(chinese["stringUnit"] as? [String: Any])
for language in ["ja", "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)
let placeholders = translation.matches(of: /%(?:[1-9][0-9]*\$)?@/)
#expect(placeholders.count == arguments.count, "Placeholder mismatch: \(language) \(key)")
@@ -164,9 +184,9 @@ 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 ["zh-Hans", "zh-Hant"] {
let chinese = try #require(localizations[language] as? [String: Any])
#expect(pluralForms(in: chinese).keys.sorted() == ["other"])
for language in ["ja", "zh-Hans", "zh-Hant"] {
let localization = try #require(localizations[language] as? [String: Any])
#expect(pluralForms(in: localization).keys.sorted() == ["other"])
}
}
}
+7 -7
View File
@@ -18,7 +18,7 @@ struct PresentationCopyTests {
NativeMenuEntry(title: String("Custom server")),
])
]
for language in ["en", "zh-Hans", "zh-Hant"] {
for language in ["en", "ja", "zh-Hans", "zh-Hant"] {
let locale = Locale(identifier: language)
let menu = NativeMenuEntry.menu(entries, locale: locale)
#expect(
@@ -31,7 +31,7 @@ struct PresentationCopyTests {
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [0, 1, 7])
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"], [0, 1, 7])
func fileDeletionKeepsLiteralPathsAndLocalizesItsRemainder(language: String, remaining: Int) {
let locale = Locale(identifier: language)
let title = AppLocalization.resolve(AppStrings.SFTP.deleteConfirmation("file.txt"), locale: locale)
@@ -54,7 +54,7 @@ struct PresentationCopyTests {
#expect(!message.contains("NSLanguage"))
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [0, 1, 2])
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"], [0, 1, 2])
func groupDeletionUsesTheRequestedLanguageAndPreservesPluralRules(language: String, count: Int) {
let locale = Locale(identifier: language)
let withoutGroups = AppStrings.Vault.groupDeletionMessage(
@@ -80,7 +80,7 @@ struct PresentationCopyTests {
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"])
func snippetCountsKeepZeroSingularAndPluralMeanings(language: String) {
let locale = Locale(identifier: language)
for count in 0...2 {
@@ -110,7 +110,7 @@ struct PresentationCopyTests {
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"])
func modelAccessibilityOnlyIncludesAvailableConfiguration(language: String) {
let locale = Locale(identifier: language)
#expect(
@@ -142,7 +142,7 @@ struct PresentationCopyTests {
#expect(AppLocalization.resolve(reasoning, locale: locale).contains("High"))
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [0, 1, 2])
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"], [0, 1, 2])
func transferConflictsKeepTheirCount(language: String, count: Int) {
let message = AppLocalization.resolve(
AppStrings.SFTP.existingItemsMessage(count: count), locale: Locale(identifier: language))
@@ -169,7 +169,7 @@ struct PresentationCopyTests {
}
@MainActor
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"])
func swiftUIResourceBridgePreservesEnvironmentLanguageAndMarkdown(language: String) throws {
let locale = Locale(identifier: language)
let resource = AppStrings.Settings.terminalConfigurationExplanation
@@ -16,6 +16,7 @@ import TermindPresentation
func parsesEverySceneAndLanguageCombination() throws {
let languages: [(argument: String, language: AppLanguage)] = [
("en", .english),
("ja", .japanese),
("zh-Hans", .simplifiedChinese),
("zh-Hant", .traditionalChinese),
]
@@ -96,7 +97,7 @@ import TermindPresentation
@Test
func vaultCaptureStatusesMatchItsTwoWorkspacesInEveryLanguage() throws {
for language in [AppLanguage.english, .simplifiedChinese, .traditionalChinese] {
for language in [AppLanguage.english, .japanese, .simplifiedChinese, .traditionalChinese] {
let configuration = ScreenshotDemoConfiguration(scene: .hosts, language: language)
let hosts = configuration.makeHostLibrary()
let window = configuration.makeWindow(hosts: hosts)
+1
View File
@@ -1381,6 +1381,7 @@
hasScannedForEncodings = 0;
knownRegions = (
en,
ja,
"zh-Hans",
"zh-Hant",
Base,