test: pr1441 IME helper guards + pr1439 ja locale parity

- Loosen test_ime_composition._ime_guarded_enter_pattern to accept the
  new _isImeEnter(e) helper (PR #1441 widened guard for Safari + 229 keyCode
  + manual _imeComposing flag). Original e.isComposing-only pattern still
  matches via alternation.
- Add test_pr1441_ime_safari_guard.py (6 tests): pin the 3-guard helper,
  compositionstart sets manual flag, compositionend defers reset to next
  tick (Safari race), null-guard $('msg') for non-chat pages, send-Enter
  uses helper, dropdown-Enter uses helper.
- Add test_japanese_locale.py (8 tests): mirror Chinese/Korean templates,
  block exists, representative translations, full key parity with English,
  no extra keys, duplicates mirror en exactly, placeholders preserved,
  arrow-function values mirrored, _label uses Japanese script.
This commit is contained in:
nesquena-hermes
2026-05-02 02:44:59 +00:00
parent cad2d1c0aa
commit 71cf06cd1c
3 changed files with 368 additions and 1 deletions

View File

@@ -9,10 +9,21 @@ SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _ime_guarded_enter_pattern(event_var_pattern, require_no_shift=False):
"""Accept both the original `e.isComposing` guard AND the broader
`_isImeEnter(e)` helper introduced in PR #1441 (which folds in
`keyCode===229` and a manual `_imeComposing` flag for Safari).
"""
no_shift = rf"\s*&&\s*!\s*{event_var_pattern}\.shiftKey" if require_no_shift else ""
# Either: if(e.isComposing) ... OR if(_isImeEnter(e)) ...
guard = (
rf"if\s*\(\s*"
rf"(?:{event_var_pattern}\.isComposing"
rf"|_isImeEnter\(\s*{event_var_pattern}\s*\))"
rf"\s*\)\s*"
)
return (
rf"if\s*\(\s*{event_var_pattern}\.key\s*===\s*'Enter'{no_shift}\s*\)\s*\{{\s*"
rf"if\s*\(\s*{event_var_pattern}\.isComposing\s*\)\s*"
+ guard +
rf"(?:\{{\s*return\s*;?\s*\}}|return\s*;?)"
)

View File

@@ -0,0 +1,245 @@
"""Regression tests for the Japanese (`ja`) locale added by PR #1439.
Mirrors `test_chinese_locale.py` and `test_korean_locale.py` — confirms the
locale block exists, has the required identifier triple (`_lang/_label/_speech`),
covers the same key set as English, and contains representative translations.
Per PR #1439, `ja` is inserted between `en` and `ru` in the LOCALES object.
"""
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 test_japanese_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
assert "\n ja: {" in src
assert "_lang: 'ja'" in src
assert "_label: '日本語'" in src
assert "_speech: 'ja-JP'" in src
def test_japanese_locale_includes_representative_translations():
"""Spot-check a handful of high-traffic UI strings to make sure they were
actually translated (not left in English or replaced with a placeholder).
"""
src = read(REPO / "static" / "i18n.js")
expected = [
"settings_title: '設定'",
"login_title: 'サインイン'",
"approval_heading: '承認が必要'",
"tab_tasks: 'タスク'",
"tab_profiles: 'プロファイル'",
"session_time_bucket_today: '今日'",
"onboarding_title: 'Hermes Web UI へようこそ'",
"mcp_servers_title: 'MCPサーバー'",
"tree_view: 'ツリー'",
]
for entry in expected:
assert entry in src, f"Missing expected translation: {entry}"
def test_japanese_locale_covers_english_keys():
"""The ja locale must define every translation key that en defines.
JS object semantics: missing keys at runtime fall through to LOCALES.en[key]
via the i18n.js fallback path, but parity is the contract — a missing key
means a half-translated UI surface for ja users.
"""
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")))
ja_keys = set(key_pattern.findall(extract_locale_block(src, "ja")))
missing = sorted(en_keys - ja_keys)
assert not missing, f"Japanese locale missing keys: {missing}"
def test_japanese_locale_has_no_keys_outside_english():
"""ja should not invent keys that en doesn't have — those would only ever
fire on the ja branch and silently regress every other locale.
"""
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")))
ja_keys = set(key_pattern.findall(extract_locale_block(src, "ja")))
extra = sorted(ja_keys - en_keys)
assert not extra, f"Japanese locale has keys not in English: {extra}"
def test_japanese_locale_duplicates_match_english():
"""JS object literal duplicates use last-wins semantics. en has 8 known
duplicates (untitled, dialog_*, discard, clear, create, remove,
project_name_prompt) where the second occurrence is the intended value
for a different UI surface. ja must mirror exactly the same duplicate
set so the JS resolution order is consistent.
"""
src = read(REPO / "static" / "i18n.js")
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
en_dupes = sorted(
k for k, c in Counter(key_pattern.findall(extract_locale_block(src, "en"))).items() if c > 1
)
ja_dupes = sorted(
k for k, c in Counter(key_pattern.findall(extract_locale_block(src, "ja"))).items() if c > 1
)
assert en_dupes == ja_dupes, (
f"Japanese duplicates must mirror English exactly. "
f"en_dupes={en_dupes}, ja_dupes={ja_dupes}"
)
def test_japanese_locale_preserves_placeholder_patterns():
"""Translation values may not strip `${var}` template-literal placeholders
or `{0}`-style positional placeholders — those are interpolated by JS at
render time and missing them produces literal `${name}` in the UI.
"""
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
ja_block = extract_locale_block(src, "ja")
# value_re matches: key: <whitespace> <value-up-to-comma-or-EOL>
value_re = re.compile(
r"^\s{4}([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):
# last-wins to match JS semantics
out = {}
for k, v in value_re.findall(block):
out[k] = v
return out
en_kv = kv(en_block)
ja_kv = kv(ja_block)
mismatches = []
for k, en_v in en_kv.items():
if k in {"_lang", "_label", "_speech"}:
continue
if k not in ja_kv:
continue
en_ph = sorted(placeholder_re.findall(en_v))
ja_ph = sorted(placeholder_re.findall(ja_kv[k]))
if en_ph != ja_ph:
mismatches.append((k, en_ph, ja_ph))
assert not mismatches, (
f"Japanese translations must preserve every {{0}} and ${{var}} "
f"placeholder from English. Mismatches: {mismatches[:5]}"
)
def test_japanese_locale_arrow_function_values_mirror_english():
"""Function-valued translations (e.g. `n_messages: (n) => ...`) must remain
function values in ja — turning one into a static string breaks the call
site `t('n_messages')(5)` and produces `[object Function]` in the UI.
"""
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
ja_block = extract_locale_block(src, "ja")
value_re = re.compile(
r"^\s{4}([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)",
re.MULTILINE,
)
arrow_re = re.compile(r"^\s*\(?[a-zA-Z_,\s]*\)?\s*=>")
def arrows(block):
return {k for k, v in value_re.findall(block) if arrow_re.match(v)}
en_arrows = arrows(en_block)
ja_arrows = arrows(ja_block)
diff = en_arrows.symmetric_difference(ja_arrows)
assert not diff, (
f"Japanese must mirror English arrow-function values exactly. "
f"Mismatch (in one but not the other): {sorted(diff)}"
)
def test_japanese_label_is_japanese_script():
"""The locale label in the language picker must actually be in Japanese
script (kanji/hiragana/katakana), not transliterated 'Japanese'.
"""
src = read(REPO / "static" / "i18n.js")
# Find the ja locale's _label
m = re.search(r"\bja\s*:\s*\{[^{}]*?_label:\s*['\"]([^'\"]+)['\"]", src, re.DOTALL)
assert m, "ja locale _label not found"
label = m.group(1)
# CJK Unified Ideographs (kanji) U+4E00U+9FFF
# Hiragana U+3040U+309F
# Katakana U+30A0U+30FF
has_jp = bool(re.search(r"[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]", label))
assert has_jp, f"ja _label must contain Japanese script, got: {label!r}"

View File

@@ -0,0 +1,111 @@
"""Regression tests for PR #1441 — IME composition Enter on Safari + broader IME coverage.
Original guard was `e.isComposing` only, which fails on Safari where the committing
keydown for IME composition fires AFTER `compositionend` with `isComposing=false`.
PR #1441 adds two more guards (`keyCode===229` + manual `_imeComposing` flag).
These tests pin the structural shape of the helper so a future cleanup pass that
strips one of the three guards trips a test before shipping.
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
def test_ime_helper_function_exists():
"""The `_isImeEnter` helper must exist and combine all 3 guards."""
# Helper definition — single function, three guards joined by ||
pattern = re.compile(
r"function\s+_isImeEnter\s*\(\s*e\s*\)\s*\{[^}]*"
r"e\.isComposing"
r"[^}]*"
r"e\.keyCode\s*===\s*229"
r"[^}]*"
r"_imeComposing"
r"[^}]*\}",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"_isImeEnter helper must combine e.isComposing, keyCode===229, and "
"_imeComposing flag (PR #1441)"
)
def test_compositionstart_sets_manual_flag():
"""A compositionstart listener on #msg must set _imeComposing = true."""
# The listener registers on the resolved `_c` (i.e. $('msg')) element
pattern = re.compile(
r"compositionstart['\"]\s*,\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"_imeComposing\s*=\s*true",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"compositionstart listener must set _imeComposing = true (PR #1441)"
)
def test_compositionend_resets_flag_on_next_tick():
"""compositionend must reset _imeComposing in a setTimeout(..., 0) — NOT
synchronously — so Safari's trailing committing-Enter keydown is still
swallowed (it fires AFTER compositionend).
"""
pattern = re.compile(
r"compositionend['\"]\s*,\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"setTimeout\s*\(\s*(?:\(\s*\)|function\s*\(\s*\))\s*=>?\s*\{?\s*"
r"_imeComposing\s*=\s*false",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"compositionend listener must reset _imeComposing in setTimeout(..., 0) "
"to handle Safari's post-compositionend trailing Enter (PR #1441)"
)
def test_ime_listeners_null_guard_msg_lookup():
"""The IIFE that registers composition listeners must null-guard $('msg') so
boot.js does not throw on pages that don't have a #msg textarea (e.g. login,
onboarding).
"""
# The IIFE pattern: (()=>{const _c=$('msg');if(!_c)return; ...
pattern = re.compile(
r"\(\s*\(\s*\)\s*=>\s*\{\s*const\s+_c\s*=\s*\$\(\s*['\"]msg['\"]\s*\)\s*;\s*"
r"if\s*\(\s*!\s*_c\s*\)\s*return\s*;",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"Composition-listener IIFE must null-guard $('msg') so non-chat pages "
"(login, onboarding) don't throw (PR #1441)"
)
def test_chat_send_enter_uses_helper():
"""The send-Enter path must call _isImeEnter(e), not e.isComposing."""
# The original was `if(e.isComposing){return;}` inside `if(e.key==='Enter')`.
# Now it must be `if(_isImeEnter(e)){return;}`.
pattern = re.compile(
r"if\s*\(\s*e\.key\s*===\s*['\"]Enter['\"]\s*\)\s*\{\s*"
r"if\s*\(\s*_isImeEnter\s*\(\s*e\s*\)\s*\)\s*",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"Chat composer send-Enter path must use _isImeEnter(e) helper (PR #1441)"
)
def test_dropdown_enter_uses_helper():
"""The autocomplete-dropdown Enter path must also use _isImeEnter(e).
Otherwise IME-confirming Enter inside a slash-command dropdown would
select the highlighted item instead of just committing the IME candidate.
"""
pattern = re.compile(
r"if\s*\(\s*e\.key\s*===\s*['\"]Enter['\"]\s*&&\s*!\s*e\.shiftKey\s*\)\s*\{\s*"
r"if\s*\(\s*_isImeEnter\s*\(\s*e\s*\)\s*\)\s*",
re.DOTALL,
)
assert pattern.search(BOOT_JS), (
"Command-dropdown Enter path must use _isImeEnter(e) helper (PR #1441)"
)