mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 22:51:07 +00:00
feat(i18n): add Czech (cs) locale support
Add a complete Czech (cs) locale to hermes-webui: full key parity with the English reference (1642/1642 keys), real Czech translations for all string and function-valued keys, Slavic plural helpers for tool/worklog summaries, the cs login-screen locale in api/routes.py, and a dedicated tests/test_czech_locale.py parity+placeholder+diacritics guard mirroring the other per-locale tests. All 15 locale-count assertions bumped 14->15. Co-authored-by: ostravajih <ostravajih@users.noreply.github.com>
This commit is contained in:
committed by
nesquena-hermes
parent
bb0439dcfa
commit
6da81f7a30
@@ -9204,6 +9204,15 @@ _LOGIN_LOCALE = {
|
||||
"invalid_pw": "M\u1eadt kh\u1ea9u kh\u00f4ng h\u1ee3p l\u1ec7",
|
||||
"conn_failed": "K\u1ebft n\u1ed1i th\u1ea5t b\u1ea1i",
|
||||
},
|
||||
"cs": {
|
||||
"lang": "cs-CZ",
|
||||
"title": "P\u0159ihl\u00e1sit se",
|
||||
"subtitle": "Zadejte heslo pro pokra\u010dov\u00e1n\u00ed",
|
||||
"placeholder": "Heslo",
|
||||
"btn": "P\u0159ihl\u00e1sit se",
|
||||
"invalid_pw": "Neplatn\u00e9 heslo",
|
||||
"conn_failed": "P\u0159ipojen\u00ed selhalo",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1692
File diff suppressed because it is too large
Load Diff
@@ -229,7 +229,7 @@ class TestOpenInVsCodeI18n:
|
||||
"""open_in_vscode key must appear exactly once per locale (14 total)."""
|
||||
src = I18N.read_text(encoding="utf-8")
|
||||
count = src.count("open_in_vscode:")
|
||||
assert count == 14, (
|
||||
assert count == 15, (
|
||||
f"Expected 14 open_in_vscode: entries (one per locale), found {count}"
|
||||
)
|
||||
|
||||
@@ -237,7 +237,7 @@ class TestOpenInVsCodeI18n:
|
||||
"""open_in_vscode_failed key must appear exactly once per locale (14 total)."""
|
||||
src = I18N.read_text(encoding="utf-8")
|
||||
count = src.count("open_in_vscode_failed:")
|
||||
assert count == 14, (
|
||||
assert count == 15, (
|
||||
f"Expected 14 open_in_vscode_failed: entries (one per locale), found {count}"
|
||||
)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_busy_placeholder_helper_preserves_compression_and_drafts():
|
||||
|
||||
def test_locale_blocks_cover_new_keys():
|
||||
locale_blocks = I18N_JS.count("settings_default_message_mode_steer")
|
||||
assert locale_blocks == 14
|
||||
assert locale_blocks == 15
|
||||
for key in [
|
||||
"settings_label_busy_placeholder_hint",
|
||||
"settings_desc_busy_placeholder_hint",
|
||||
|
||||
@@ -330,7 +330,7 @@ class TestAuxiliaryModelsI18n:
|
||||
"""Count of each key should equal the number of locales (12 with Turkish)."""
|
||||
for key in self.REQUIRED_KEYS:
|
||||
count = I18N_JS.count(f"{key}:")
|
||||
assert count == 14, (
|
||||
assert count == 15, (
|
||||
f"i18n key '{key}' found {count} times — expected 14 (one per locale)"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def extract_locale_block(src: str, locale_key: str) -> str:
|
||||
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
|
||||
assert start_match, f"{locale_key} locale block not found"
|
||||
|
||||
start = start_match.end() - 1
|
||||
depth = 0
|
||||
in_single = False
|
||||
in_double = False
|
||||
in_backtick = False
|
||||
escape = False
|
||||
|
||||
for i in range(start, len(src)):
|
||||
ch = src[i]
|
||||
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
|
||||
if in_single:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == "'":
|
||||
in_single = False
|
||||
continue
|
||||
|
||||
if in_double:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_double = False
|
||||
continue
|
||||
|
||||
if in_backtick:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == "`":
|
||||
in_backtick = False
|
||||
continue
|
||||
|
||||
if ch == "'":
|
||||
in_single = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_double = True
|
||||
continue
|
||||
if ch == "`":
|
||||
in_backtick = True
|
||||
continue
|
||||
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
continue
|
||||
if ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start + 1 : i]
|
||||
|
||||
raise AssertionError(f"{locale_key} locale block braces are not balanced")
|
||||
|
||||
|
||||
def locale_keys(src: str, locale_key: str) -> list[str]:
|
||||
key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE)
|
||||
return key_pattern.findall(extract_locale_block(src, locale_key))
|
||||
|
||||
|
||||
def test_czech_locale_block_exists():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
assert cs_block
|
||||
assert "_lang: 'cs'" in cs_block
|
||||
assert "_label: 'Čeština'" in cs_block
|
||||
assert "_speech: 'cs-CZ'" in cs_block
|
||||
|
||||
|
||||
def test_czech_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
expected = [
|
||||
"settings_title: 'Nastavení'",
|
||||
"settings_label_language: 'Jazyk'",
|
||||
"login_title: 'Přihlášení'",
|
||||
"approval_heading: 'Požadováno schválení'",
|
||||
"tab_tasks: 'Úkoly'",
|
||||
"tab_profiles: 'Profily'",
|
||||
"empty_title: 'Jak vám mohu pomoci?'",
|
||||
"onboarding_title: 'Vítejte v Hermes Web UI'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in cs_block, f"missing expected Czech translation: {entry}"
|
||||
|
||||
|
||||
def test_czech_locale_matches_english_key_coverage():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_keys = set(locale_keys(src, "en"))
|
||||
cs_keys = set(locale_keys(src, "cs"))
|
||||
assert sorted(en_keys - cs_keys) == []
|
||||
assert sorted(cs_keys - en_keys) == []
|
||||
|
||||
|
||||
def test_czech_locale_has_no_duplicate_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
keys = locale_keys(src, "cs")
|
||||
|
||||
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
|
||||
assert not duplicates, f"Czech locale has duplicate keys: {duplicates}"
|
||||
|
||||
|
||||
def test_czech_locale_keys_use_standard_indentation():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
|
||||
# Enforce strict 4-space indentation for keys.
|
||||
badly_indented = []
|
||||
for line in cs_block.splitlines():
|
||||
m = re.match(r"^(\s*)[a-zA-Z0-9_]+\s*:", line)
|
||||
if m and len(m.group(1)) != 4:
|
||||
badly_indented.append(f"{len(m.group(1))} spaces: {line.strip()}")
|
||||
assert badly_indented == []
|
||||
|
||||
|
||||
def test_czech_locale_arrow_function_values_mirror_english():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_block = extract_locale_block(src, "en")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
|
||||
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
|
||||
arrow_re = re.compile(r"^\s*\(?[a-zA-Z_,\s]*\)?\s*=>")
|
||||
helper_re = re.compile(r"^_i18n[A-Za-z]+$")
|
||||
|
||||
def callable_values(block):
|
||||
out = set()
|
||||
for k, v in value_re.findall(block):
|
||||
v = v.strip()
|
||||
if arrow_re.match(v) or helper_re.match(v):
|
||||
out.add(k)
|
||||
return out
|
||||
|
||||
# Every key whose English value is a function must also be a function in cs
|
||||
# (either an arrow or a named _i18n* helper reference).
|
||||
assert callable_values(en_block) == callable_values(cs_block)
|
||||
|
||||
|
||||
def test_czech_locale_preserves_placeholder_patterns():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_block = extract_locale_block(src, "en")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
|
||||
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
|
||||
placeholder_re = re.compile(r"\{[0-9]+\}|\$\{[a-zA-Z_][a-zA-Z0-9_]*\}")
|
||||
|
||||
def kv(block):
|
||||
out = {}
|
||||
for k, v in value_re.findall(block):
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
en_kv = kv(en_block)
|
||||
cs_kv = kv(cs_block)
|
||||
|
||||
for key, en_val in en_kv.items():
|
||||
if key not in cs_kv:
|
||||
continue
|
||||
en_vars = sorted(placeholder_re.findall(en_val))
|
||||
cs_vars = sorted(placeholder_re.findall(cs_kv[key]))
|
||||
# Skip arrow functions which might contain conditional logic and thus more template vars.
|
||||
if "=>" not in cs_kv[key]:
|
||||
if en_vars or cs_vars:
|
||||
assert cs_vars == en_vars, f"Key '{key}' placeholder mismatch in cs locale"
|
||||
|
||||
|
||||
def test_czech_locale_has_no_double_escaped_unicode_sequences():
|
||||
"""JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI."""
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"):
|
||||
assert bad not in cs_block, f"Czech locale must not contain {bad!r}"
|
||||
|
||||
|
||||
def test_czech_locale_uses_real_utf8_diacritics():
|
||||
"""Czech uses á č ď é ě í ň ó ř š ť ú ů ý ž — confirm the block carries real
|
||||
UTF-8 diacritics, not ASCII-only text (which would mean nothing was translated)."""
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
cs_block = extract_locale_block(src, "cs")
|
||||
diacritics = "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ"
|
||||
assert any(ch in cs_block for ch in diacritics), "Czech locale has no diacritics"
|
||||
@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
|
||||
"voice_mode_toggle_active",
|
||||
)
|
||||
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi")
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi", "cs")
|
||||
|
||||
def test_legacy_voice_toggle_key_removed(self):
|
||||
"""The old key whose string was 'Voice input' caused the duplicate-
|
||||
@@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n:
|
||||
class TestVoiceModePreferenceGate:
|
||||
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
|
||||
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi")
|
||||
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi", "cs")
|
||||
|
||||
def test_voice_mode_pref_is_localstorage_backed(self):
|
||||
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
|
||||
|
||||
@@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate():
|
||||
def test_cache_usage_labels_are_localized():
|
||||
src = (ROOT / "static" / "i18n.js").read_text()
|
||||
|
||||
assert src.count("usage_cache_hit_detail:") == 14
|
||||
assert src.count("usage_cached_percent:") == 14
|
||||
assert src.count("usage_cache_hit_detail:") == 15
|
||||
assert src.count("usage_cached_percent:") == 15
|
||||
assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src
|
||||
assert "usage_cached_percent: '{0}% cached'" in src
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions():
|
||||
|
||||
def test_hide_suggestions_i18n_all_locales_and_changelog():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
assert js.count("settings_label_hide_suggestions:") == 14
|
||||
assert js.count("settings_desc_hide_suggestions:") == 14
|
||||
assert js.count("settings_label_hide_suggestions:") == 15
|
||||
assert js.count("settings_desc_hide_suggestions:") == 15
|
||||
changelog = CHANGELOG.read_text(encoding="utf-8")
|
||||
assert "#2679" in changelog
|
||||
assert "hide_empty_state_suggestions" in changelog
|
||||
|
||||
@@ -6,7 +6,7 @@ PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
LOCALE_COUNT = 14 # en, it, ja, ru, es, de, zh, zh-TW, pt, ko, fr, tr, pl
|
||||
LOCALE_COUNT = 15 # en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr, pl, vi, cs
|
||||
|
||||
|
||||
def test_help_nav_button_present():
|
||||
|
||||
@@ -5,7 +5,7 @@ BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
LOCALE_COUNT = 14
|
||||
LOCALE_COUNT = 15
|
||||
|
||||
|
||||
def test_raw_audio_active_recording_uses_dedicated_i18n_key():
|
||||
|
||||
@@ -76,8 +76,8 @@ def test_panels_round_trip_and_hot_apply_virtualize_toggle():
|
||||
|
||||
def test_virtualize_toggle_i18n_all_locales():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
assert js.count("settings_label_virtualize_transcript:") == 14
|
||||
assert js.count("settings_desc_virtualize_transcript:") == 14
|
||||
assert js.count("settings_label_virtualize_transcript:") == 15
|
||||
assert js.count("settings_desc_virtualize_transcript:") == 15
|
||||
|
||||
|
||||
# ── #4343 force-off-for-everyone migration (load_settings behavior) ──────────
|
||||
|
||||
@@ -86,4 +86,4 @@ def test_i18n_no_git_key_all_locales():
|
||||
|
||||
# Count occurrences of the new key (should be exactly 13, one per locale)
|
||||
count = content.count("settings_update_no_git")
|
||||
assert count == 14, f"Expected exactly 14 settings_update_no_git keys, found {count}"
|
||||
assert count == 15, f"Expected exactly 15 settings_update_no_git keys, found {count}"
|
||||
|
||||
@@ -130,7 +130,7 @@ def test_i18n_new_keys_in_all_locales():
|
||||
]
|
||||
for key in required:
|
||||
count = I18N_JS.count(f"{key}:")
|
||||
assert count == 14, f"Key '{key}' appears {count} times, expected 14 (one per locale)"
|
||||
assert count == 15, f"Key '{key}' appears {count} times, expected 15 (one per locale)"
|
||||
|
||||
|
||||
def test_backend_already_accepts_new_fields():
|
||||
|
||||
@@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys():
|
||||
def test_rtl_localized_in_all_locales():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
# Count occurrences — should match the 11 locale blocks
|
||||
assert js.count("settings_label_rtl:") == 14
|
||||
assert js.count("settings_desc_rtl:") == 14
|
||||
assert js.count("settings_label_rtl:") == 15
|
||||
assert js.count("settings_desc_rtl:") == 15
|
||||
|
||||
@@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip():
|
||||
|
||||
def test_quota_chip_localized_in_all_locales():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
assert js.count("settings_label_quota_chip:") == 14, "12 locales expected"
|
||||
assert js.count("settings_desc_quota_chip:") == 14, "12 locales expected"
|
||||
assert js.count("settings_label_quota_chip:") == 15, "12 locales expected"
|
||||
assert js.count("settings_desc_quota_chip:") == 15, "12 locales expected"
|
||||
|
||||
@@ -38,4 +38,4 @@ def test_verdigris_has_no_light_variant():
|
||||
def test_verdigris_i18n_lists_skin_in_all_locales():
|
||||
# There are 12 locales; each should now include verdigris as the trailing skin.
|
||||
# 10 locales use ASCII closing paren, 2 Chinese locales use full-width paren.
|
||||
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris)") == 14
|
||||
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris)") == 15
|
||||
|
||||
@@ -45,4 +45,4 @@ def test_zeus_modals_are_not_navy():
|
||||
|
||||
def test_zeus_i18n_lists_skin_in_all_locales():
|
||||
# There are 12 locales; each should include zeus in the skin list.
|
||||
assert I18N_JS.count("/zeus/") == 14
|
||||
assert I18N_JS.count("/zeus/") == 15
|
||||
|
||||
Reference in New Issue
Block a user