From 204dc23c6b58be28f95ea303a16c4bcf5543da6f Mon Sep 17 00:00:00 2001 From: vansour Date: Tue, 14 Apr 2026 17:14:01 +0000 Subject: [PATCH] fix i18n review comments and locale test robustness --- api/routes.py | 2 +- static/boot.js | 40 ++++++- static/i18n.js | 6 + static/panels.js | 11 +- tests/test_chinese_locale.py | 80 +++++++++++--- tests/test_language_precedence.py | 178 +++++++++++++++++++++++++++++- tests/test_login_locale.py | 8 +- 7 files changed, 298 insertions(+), 27 deletions(-) diff --git a/api/routes.py b/api/routes.py index f665242..b233589 100644 --- a/api/routes.py +++ b/api/routes.py @@ -233,7 +233,7 @@ _LOGIN_LOCALE = { "title": "Iniciar sesi\u00f3n", "subtitle": "Introduce tu contrase\u00f1a para continuar", "placeholder": "Contrase\u00f1a", - "btn": "Iniciar sesi\u00f3n", + "btn": "Entrar", "invalid_pw": "Contrase\u00f1a inv\u00e1lida", "conn_failed": "Error de conexi\u00f3n", }, diff --git a/static/boot.js b/static/boot.js index 3112edc..1983b9b 100644 --- a/static/boot.js +++ b/static/boot.js @@ -584,7 +584,45 @@ function applyBotName(){ (async()=>{ // Load send key preference let _bootSettings={}; - try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);if(typeof setLocale==='function'){const _lang=typeof resolvePreferredLocale==='function'?resolvePreferredLocale(s.language,localStorage.getItem('hermes-lang')):(s.language||localStorage.getItem('hermes-lang')||'en');setLocale(_lang);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};document.body.classList.remove('bubble-layout');} + try{ + const s=await api('/api/settings'); + _bootSettings=s; + window._sendKey=s.send_key||'enter'; + window._showTokenUsage=!!s.show_token_usage; + window._showCliSessions=!!s.show_cli_sessions; + window._soundEnabled=!!s.sound_enabled; + window._notificationsEnabled=!!s.notifications_enabled; + window._botName=s.bot_name||'Hermes'; + const _theme=s.theme||'dark'; + document.documentElement.dataset.theme=_theme; + localStorage.setItem('hermes-theme',_theme); + document.body.classList.toggle('bubble-layout',!!s.bubble_layout); + if(typeof setLocale==='function'){ + const _lang=typeof resolvePreferredLocale==='function' + ? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang')) + : (s.language || localStorage.getItem('hermes-lang') || 'en'); + setLocale(_lang); + if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); + } + applyBotName(); + }catch(e){ + window._sendKey='enter'; + window._showTokenUsage=false; + window._showCliSessions=false; + window._soundEnabled=false; + window._notificationsEnabled=false; + window._botName='Hermes'; + _bootSettings={check_for_updates:false}; + document.body.classList.remove('bubble-layout'); + if(typeof setLocale==='function'){ + const _lang=typeof resolvePreferredLocale==='function' + ? resolvePreferredLocale(null, localStorage.getItem('hermes-lang')) + : (localStorage.getItem('hermes-lang') || 'en'); + setLocale(_lang); + if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); + } + applyBotName(); + } // Non-blocking update check (fire-and-forget, once per tab session) // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; diff --git a/static/i18n.js b/static/i18n.js index 36f1d4e..7b4e41d 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -379,8 +379,10 @@ const LOCALES = { profile_gateway_stopped: 'Gateway stopped', profile_active: 'ACTIVE', profile_no_configuration: 'No configuration', + profile_skill_count: (count) => `${count} skill${count === 1 ? '' : 's'}`, profile_use: 'Use', profile_switch_title: 'Switch to this profile', + profile_delete_title: 'Delete this profile', manage_profiles: 'Manage profiles', profiles_load_failed: 'Failed to load profiles', profiles_busy_switch: 'Cannot switch profiles while agent is running', @@ -771,8 +773,10 @@ const LOCALES = { profile_gateway_stopped: 'Gateway stopped', profile_active: 'ACTIVE', profile_no_configuration: 'No configuration', + profile_skill_count: (count) => `${count} habilidad${count === 1 ? '' : 'es'}`, profile_use: 'Use', profile_switch_title: 'Switch to this profile', + profile_delete_title: 'Eliminar este perfil', manage_profiles: 'Manage profiles', profiles_load_failed: 'Failed to load profiles', profiles_busy_switch: 'Cannot switch profiles while agent is running', @@ -1358,8 +1362,10 @@ const LOCALES = { profile_gateway_stopped: '网关已停止', profile_active: '当前', profile_no_configuration: '无配置', + profile_skill_count: (count) => `${count} 个技能`, profile_use: '使用', profile_switch_title: '切换到此配置档', + profile_delete_title: '删除此配置档', manage_profiles: '管理配置档', profiles_load_failed: '加载配置档失败', profiles_busy_switch: 'Agent 运行中,无法切换配置档', diff --git a/static/panels.js b/static/panels.js index 0cf648e..73d4b44 100644 --- a/static/panels.js +++ b/static/panels.js @@ -808,7 +808,7 @@ async function loadProfilesPanel() { const meta = []; if (p.model) meta.push(p.model.split('/').pop()); if (p.provider) meta.push(p.provider); - if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : '')); + if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); if (p.has_env) meta.push(t('profile_api_keys_configured')); const gwDot = p.gateway_running ? `` @@ -823,7 +823,7 @@ async function loadProfilesPanel() {
${!isActive ? `` : ''} - ${!p.is_default ? `` : ''} + ${!p.is_default ? `` : ''}
`; panel.appendChild(card); @@ -844,7 +844,7 @@ function renderProfileDropdown(data) { opt.className = 'profile-opt' + (p.name === active ? ' active' : ''); const meta = []; if (p.model) meta.push(p.model.split('/').pop()); - if (p.skill_count) meta.push(p.skill_count + ' skills'); + if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); const gwDot = ``; const checkmark = p.name === active ? ' ' : ''; opt.innerHTML = `
${gwDot}${esc(p.name)}${p.is_default ? ' (default)' : ''}${checkmark}
` + @@ -1000,7 +1000,10 @@ async function submitProfileCreate() { toggleProfileForm(); await loadProfilesPanel(); showToast(t('profile_created', name)); - } catch (e) { errEl.textContent = e.message || 'Create failed'; errEl.style.display = ''; } + } catch (e) { + errEl.textContent = e.message || t('create_failed'); + errEl.style.display = ''; + } } async function deleteProfile(name) { diff --git a/tests/test_chinese_locale.py b/tests/test_chinese_locale.py index 9ae3082..a854020 100644 --- a/tests/test_chinese_locale.py +++ b/tests/test_chinese_locale.py @@ -10,6 +10,66 @@ 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 test_chinese_locale_block_exists(): src = read(REPO / "static" / "i18n.js") assert "\n zh: {" in src @@ -35,17 +95,9 @@ def test_chinese_locale_includes_representative_translations(): def test_chinese_locale_covers_english_keys(): src = read(REPO / "static" / "i18n.js") - en_match = re.search(r"\n en: \{([\s\S]*?)\n \},\n\n es: \{", src) - zh_match = re.search( - r"\n zh: \{([\s\S]*?)\n \},\n\n // Traditional Chinese \(zh-Hant\)", - src, - ) - assert en_match, "English locale block not found" - assert zh_match, "Chinese locale block not found" - key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) - en_keys = set(key_pattern.findall(en_match.group(1))) - zh_keys = set(key_pattern.findall(zh_match.group(1))) + en_keys = set(key_pattern.findall(extract_locale_block(src, "en"))) + zh_keys = set(key_pattern.findall(extract_locale_block(src, "zh"))) missing = sorted(en_keys - zh_keys) assert not missing, f"Chinese locale missing keys: {missing}" @@ -53,13 +105,7 @@ def test_chinese_locale_covers_english_keys(): def test_chinese_locale_has_no_duplicate_keys(): src = read(REPO / "static" / "i18n.js") - zh_match = re.search( - r"\n zh: \{([\s\S]*?)\n \},\n\n // Traditional Chinese \(zh-Hant\)", - src, - ) - assert zh_match, "Chinese locale block not found" - key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) - keys = key_pattern.findall(zh_match.group(1)) + keys = key_pattern.findall(extract_locale_block(src, "zh")) duplicates = sorted(k for k, count in Counter(keys).items() if count > 1) assert not duplicates, f"Chinese locale has duplicate keys: {duplicates}" diff --git a/tests/test_language_precedence.py b/tests/test_language_precedence.py index 29c92a2..2d477c7 100644 --- a/tests/test_language_precedence.py +++ b/tests/test_language_precedence.py @@ -39,6 +39,180 @@ def _run_i18n_case(script_expr: str) -> dict: return json.loads(proc.stdout) +def _extract_call_arglists(src: str, fn_name: str) -> list[str]: + token = f"{fn_name}(" + out = [] + search_from = 0 + + while True: + start = src.find(token, search_from) + if start < 0: + return out + + i = start + len(token) + depth = 1 + in_single = False + in_double = False + in_backtick = False + escape = False + + while i < len(src): + ch = src[i] + + if escape: + escape = False + i += 1 + continue + + if in_single: + if ch == "\\": + escape = True + elif ch == "'": + in_single = False + i += 1 + continue + + if in_double: + if ch == "\\": + escape = True + elif ch == '"': + in_double = False + i += 1 + continue + + if in_backtick: + if ch == "\\": + escape = True + elif ch == "`": + in_backtick = False + i += 1 + continue + + if ch == "'": + in_single = True + elif ch == '"': + in_double = True + elif ch == "`": + in_backtick = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + out.append(src[start + len(token) : i]) + break + i += 1 + + search_from = start + len(token) + + +def _split_top_level_args(arg_src: str) -> list[str]: + args = [] + cur = [] + paren = 0 + brace = 0 + bracket = 0 + in_single = False + in_double = False + in_backtick = False + escape = False + + for ch in arg_src: + if escape: + cur.append(ch) + escape = False + continue + + if in_single: + cur.append(ch) + if ch == "\\": + escape = True + elif ch == "'": + in_single = False + continue + + if in_double: + cur.append(ch) + if ch == "\\": + escape = True + elif ch == '"': + in_double = False + continue + + if in_backtick: + cur.append(ch) + if ch == "\\": + escape = True + elif ch == "`": + in_backtick = False + continue + + if ch == "'": + in_single = True + cur.append(ch) + continue + if ch == '"': + in_double = True + cur.append(ch) + continue + if ch == "`": + in_backtick = True + cur.append(ch) + continue + + if ch == "(": + paren += 1 + cur.append(ch) + continue + if ch == ")": + paren -= 1 + cur.append(ch) + continue + if ch == "{": + brace += 1 + cur.append(ch) + continue + if ch == "}": + brace -= 1 + cur.append(ch) + continue + if ch == "[": + bracket += 1 + cur.append(ch) + continue + if ch == "]": + bracket -= 1 + cur.append(ch) + continue + + if ch == "," and paren == 0 and brace == 0 and bracket == 0: + args.append("".join(cur).strip()) + cur = [] + continue + + cur.append(ch) + + if cur: + args.append("".join(cur).strip()) + return args + + +def _has_precedence_call(src: str, first_arg: str) -> bool: + expected_second = { + "localStorage.getItem('hermes-lang')", + 'localStorage.getItem("hermes-lang")', + } + for arg_src in _extract_call_arglists(src, "resolvePreferredLocale"): + args = _split_top_level_args(arg_src) + if len(args) < 2: + continue + first = re.sub(r"\s+", "", args[0]) + second = re.sub(r"\s+", "", args[1]) + if first == first_arg and second in expected_second: + return True + return False + + def test_i18n_exposes_locale_resolvers(): assert "function resolveLocale(" in I18N_JS assert "function resolvePreferredLocale(" in I18N_JS @@ -84,5 +258,5 @@ def test_set_locale_normalizes_alias_and_persists_canonical_key(): def test_boot_and_settings_panel_use_shared_locale_precedence(): - assert re.search(r"resolvePreferredLocale\(s\.language\s*,\s*localStorage\.getItem\('hermes-lang'\)\)", BOOT_JS) - assert re.search(r"resolvePreferredLocale\(settings\.language\s*,\s*localStorage\.getItem\('hermes-lang'\)\)", PANELS_JS) + assert _has_precedence_call(BOOT_JS, "s.language") + assert _has_precedence_call(PANELS_JS, "settings.language") diff --git a/tests/test_login_locale.py b/tests/test_login_locale.py index ad63e01..5cdea58 100644 --- a/tests/test_login_locale.py +++ b/tests/test_login_locale.py @@ -46,7 +46,9 @@ def test_login_page_uses_simplified_chinese_for_zh_cn_alias(): assert "\u767b\u5f55" in html assert "\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528" in html finally: - post("/api/settings", {"language": prev_lang}) + restored, restore_status = post("/api/settings", {"language": prev_lang}) + assert restore_status == 200 + assert restored.get("language") == prev_lang def test_login_page_uses_traditional_chinese_for_zh_hant(): @@ -61,4 +63,6 @@ def test_login_page_uses_traditional_chinese_for_zh_hant(): assert "\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528" in html assert "\u5bc6\u78bc\u932f\u8aa4" in html finally: - post("/api/settings", {"language": prev_lang}) + restored, restore_status = post("/api/settings", {"language": prev_lang}) + assert restore_status == 200 + assert restored.get("language") == prev_lang