feat: add full Russian (ru-RU) localization — v0.50.95 (PR #713)
Full Russian locale — 389/389 English keys, Slavic plural forms, native Cyrillic. Rebased from PR #605 with rebase artifacts fixed. Login page Russian added to api/routes.py. Credits: @DrMaks22 (translation), @renheqiang (PR #605 author). Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com> Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
This commit is contained in:
@@ -66,3 +66,21 @@ def test_login_page_uses_traditional_chinese_for_zh_hant():
|
||||
restored, restore_status = post("/api/settings", {"language": prev_lang})
|
||||
assert restore_status == 200
|
||||
assert restored.get("language") == prev_lang
|
||||
|
||||
|
||||
def test_login_page_uses_russian_for_ru():
|
||||
prev_lang = _current_language()
|
||||
try:
|
||||
saved, status = post("/api/settings", {"language": "ru"})
|
||||
assert status == 200
|
||||
assert saved.get("language") == "ru"
|
||||
html, status2 = get_raw("/login")
|
||||
assert status2 == 200
|
||||
assert 'lang="ru-RU"' in html
|
||||
assert "\u0412\u043e\u0439\u0442\u0438" in html
|
||||
assert "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c" in html
|
||||
assert "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c" in html
|
||||
finally:
|
||||
restored, restore_status = post("/api/settings", {"language": prev_lang})
|
||||
assert restore_status == 200
|
||||
assert restored.get("language") == prev_lang
|
||||
|
||||
@@ -6,7 +6,7 @@ Covers:
|
||||
2. static/ui.js: _checkProviderMismatch() helper exists and logic is correct
|
||||
3. static/messages.js: apperror handler has auth_mismatch branch
|
||||
4. static/i18n.js: provider_mismatch_warning and provider_mismatch_label keys
|
||||
present in all 5 locales (en, es, de, zh, zh-Hant)
|
||||
present in all locales (en, es, de, ru, zh, zh-Hant)
|
||||
5. static/boot.js: modelSelect.onchange calls _checkProviderMismatch
|
||||
6. /api/models: response includes active_provider field
|
||||
"""
|
||||
@@ -165,31 +165,43 @@ class TestApperrorHandler:
|
||||
)
|
||||
|
||||
|
||||
# ── 4. static/i18n.js: all 5 locales ────────────────────────────────────────
|
||||
# ── 4. static/i18n.js: all locales ───────────────────────────────────────────
|
||||
|
||||
class TestI18nProviderMismatch:
|
||||
"""All 5 locales must have provider_mismatch_warning and provider_mismatch_label."""
|
||||
"""All locales must have provider_mismatch_warning and provider_mismatch_label."""
|
||||
|
||||
REQUIRED_KEYS = ["provider_mismatch_warning", "provider_mismatch_label"]
|
||||
|
||||
def _locale_names(self, src: str) -> list[str]:
|
||||
pattern = re.compile(
|
||||
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
names = []
|
||||
for match in pattern.finditer(src):
|
||||
names.append(match.group("quoted") or match.group("plain"))
|
||||
return names
|
||||
|
||||
def _count_key(self, src: str, key: str) -> int:
|
||||
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
|
||||
|
||||
def test_all_locales_have_warning_key(self):
|
||||
"""provider_mismatch_warning must appear in all 5 locales."""
|
||||
"""provider_mismatch_warning must appear in all locales."""
|
||||
src = _read("static/i18n.js")
|
||||
locale_count = len(self._locale_names(src))
|
||||
count = self._count_key(src, "provider_mismatch_warning")
|
||||
assert count >= 5, (
|
||||
f"provider_mismatch_warning found {count} times, expected >= 5 "
|
||||
f"(one per locale: en, es, de, zh, zh-Hant)"
|
||||
assert count >= locale_count, (
|
||||
f"provider_mismatch_warning found {count} times, expected >= {locale_count} "
|
||||
f"(one per locale)"
|
||||
)
|
||||
|
||||
def test_all_locales_have_label_key(self):
|
||||
"""provider_mismatch_label must appear in all 5 locales."""
|
||||
"""provider_mismatch_label must appear in all locales."""
|
||||
src = _read("static/i18n.js")
|
||||
locale_count = len(self._locale_names(src))
|
||||
count = self._count_key(src, "provider_mismatch_label")
|
||||
assert count >= 5, (
|
||||
f"provider_mismatch_label found {count} times, expected >= 5"
|
||||
assert count >= locale_count, (
|
||||
f"provider_mismatch_label found {count} times, expected >= {locale_count}"
|
||||
)
|
||||
|
||||
def test_warning_is_function_in_en(self):
|
||||
|
||||
116
tests/test_russian_locale.py
Normal file
116
tests/test_russian_locale.py
Normal file
@@ -0,0 +1,116 @@
|
||||
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 test_russian_locale_block_exists():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
assert "\n ru: {" in src
|
||||
assert "_label: 'Русский'" in src
|
||||
assert "_speech: 'ru-RU'" in src
|
||||
|
||||
|
||||
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 test_russian_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
expected = [
|
||||
"settings_title: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438'",
|
||||
"login_title: '\u0412\u0445\u043e\u0434'",
|
||||
"approval_heading: '\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u0435'",
|
||||
"tab_tasks: '\u0417\u0430\u0434\u0430\u0447\u0438'",
|
||||
"tab_profiles: '\u041f\u0440\u043e\u0444\u0438\u043b\u0438'",
|
||||
"session_time_just_now: '\u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e'",
|
||||
"onboarding_title: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 Hermes Web UI'",
|
||||
"onboarding_complete: '\u041f\u0435\u0440\u0432\u0438\u0447\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430'",
|
||||
"profile_default_label: '\u0028\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e\u0029'",
|
||||
"profile_name_placeholder: '\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u044f \u0028\u0441\u0442\u0440\u043e\u0447\u043d\u044b\u0435 \u0431\u0443\u043a\u0432\u044b, a-z, 0-9, \u0434\u0435\u0444\u0438\u0441\u044b\u0029'",
|
||||
"profile_clone_label: '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0438\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u044f'",
|
||||
"profile_base_url_placeholder: '\u0411\u0430\u0437\u043e\u0432\u044b\u0439 URL \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440 http://localhost:11434\u0029'",
|
||||
"profile_api_key_placeholder: 'API-\u043a\u043b\u044e\u0447 \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0029'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in src
|
||||
|
||||
|
||||
def test_russian_locale_covers_english_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
|
||||
ru_keys = set(key_pattern.findall(extract_locale_block(src, "ru")))
|
||||
|
||||
missing = sorted(en_keys - ru_keys)
|
||||
assert not missing, f"Russian locale missing keys: {missing}"
|
||||
|
||||
|
||||
def test_russian_locale_has_no_duplicate_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
keys = key_pattern.findall(extract_locale_block(src, "ru"))
|
||||
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
|
||||
assert not duplicates, f"Russian locale has duplicate keys: {duplicates}"
|
||||
@@ -30,6 +30,14 @@ def read(path):
|
||||
return (REPO / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _locale_count(src: str) -> int:
|
||||
pattern = re.compile(
|
||||
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
return sum(1 for _ in pattern.finditer(src))
|
||||
|
||||
|
||||
# ── 1–4. cancelStream() cleanup is unconditional ─────────────────────────────
|
||||
|
||||
class TestCancelStreamCleanup:
|
||||
@@ -165,9 +173,10 @@ def test_sse_cancel_handler_calls_set_busy():
|
||||
def test_cancel_failed_i18n_key_exists_in_all_locales():
|
||||
"""cancel_failed key must still exist in i18n.js for all locales."""
|
||||
src = read("static/i18n.js")
|
||||
# Should appear once per locale (en, es, de, zh-Hans, zh-Hant)
|
||||
# Should appear once per locale (en, es, de, ru, zh, zh-Hant)
|
||||
locale_count = _locale_count(src)
|
||||
count = src.count("cancel_failed:")
|
||||
assert count >= 5, (
|
||||
assert count >= locale_count, (
|
||||
f"cancel_failed key only found {count} times in i18n.js — "
|
||||
"expected at least 5 (one per locale)"
|
||||
f"expected at least {locale_count} (one per locale)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user