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:
@@ -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*;?)"
|
||||
)
|
||||
|
||||
|
||||
245
tests/test_japanese_locale.py
Normal file
245
tests/test_japanese_locale.py
Normal 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+4E00–U+9FFF
|
||||
# Hiragana U+3040–U+309F
|
||||
# Katakana U+30A0–U+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}"
|
||||
111
tests/test_pr1441_ime_safari_guard.py
Normal file
111
tests/test_pr1441_ime_safari_guard.py
Normal 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)"
|
||||
)
|
||||
Reference in New Issue
Block a user