Some checks failed
Release & Docker / release (push) Has been cancelled
Complete Polish locale. #3781 (@leszek3737). Full suite green, CI 11/11, 180 locale/parity tests pass. Co-authored-by: leszek3737 <leszek3737@users.noreply.github.com>
282 lines
14 KiB
Python
282 lines
14 KiB
Python
"""Tests for #1488 — composer voice buttons (dictation vs voice mode).
|
|
|
|
The composer footer shows two voice-related buttons that look identical and
|
|
share the same tooltip ("Voice input") in master. This module pins the fix:
|
|
|
|
1. The buttons MUST have distinct, descriptive tooltips bound to i18n keys.
|
|
2. The voice-mode button MUST use the audio-lines (waveform) icon — the
|
|
industry-standard glyph for two-way voice conversation, matching ChatGPT
|
|
and Gemini.
|
|
3. The voice-mode button MUST be hidden by default and surface behind a
|
|
Preferences toggle so the default composer footer stays uncluttered.
|
|
4. The dictation button (the older feature) MUST stay visible by default,
|
|
unchanged.
|
|
5. All four new i18n keys (active and idle states for both buttons) MUST
|
|
exist in every locale.
|
|
6. The legacy `voice_toggle` i18n key MUST be removed everywhere — its
|
|
string was identical to the dictation tooltip and caused the bug.
|
|
"""
|
|
import re
|
|
|
|
|
|
def _src(name: str) -> str:
|
|
with open(f"static/{name}") as f:
|
|
return f.read()
|
|
|
|
|
|
class TestComposerVoiceButtonHTML:
|
|
"""index.html composer markup for the dictation + voice-mode buttons."""
|
|
|
|
def test_dictation_button_has_dictate_i18n_key(self):
|
|
"""btnMic must bind data-i18n-title="voice_dictate" so its tooltip
|
|
is distinct from the voice-mode button and localizable."""
|
|
html = _src("index.html")
|
|
m = re.search(
|
|
r'<button[^>]*\bid="btnMic"[^>]*>',
|
|
html,
|
|
re.DOTALL,
|
|
)
|
|
assert m, "btnMic <button> tag must exist"
|
|
tag = m.group(0)
|
|
assert 'data-i18n-title="voice_dictate"' in tag, \
|
|
"btnMic must have data-i18n-title=\"voice_dictate\" — without " \
|
|
"it the tooltip stays as the static fallback and ignores locale."
|
|
# Static fallback should also match (read by users with stale i18n).
|
|
# Accept either the legacy `title="Dictate"` or the custom-tooltip
|
|
# variant `data-tooltip="Dictate"` introduced in #1775.
|
|
assert 'title="Dictate"' in tag or 'data-tooltip="Dictate"' in tag, \
|
|
"btnMic static tooltip fallback must say 'Dictate' (not 'Voice input')."
|
|
|
|
def test_voice_mode_button_has_voice_mode_i18n_key(self):
|
|
"""btnVoiceMode must bind data-i18n-title="voice_mode_toggle"."""
|
|
html = _src("index.html")
|
|
m = re.search(
|
|
r'<button[^>]*\bid="btnVoiceMode"[^>]*>',
|
|
html,
|
|
re.DOTALL,
|
|
)
|
|
assert m, "btnVoiceMode <button> tag must exist"
|
|
tag = m.group(0)
|
|
assert 'data-i18n-title="voice_mode_toggle"' in tag, \
|
|
"btnVoiceMode must use data-i18n-title=\"voice_mode_toggle\". " \
|
|
"The legacy key 'voice_toggle' resolved to 'Voice input' and " \
|
|
"made btnMic and btnVoiceMode appear identical."
|
|
assert 'voice_toggle"' not in tag, \
|
|
"Stale voice_toggle reference still on btnVoiceMode — must be voice_mode_toggle."
|
|
|
|
def test_buttons_have_distinct_static_titles(self):
|
|
"""The static title/tooltip attributes must differ as a fallback for
|
|
users whose i18n hasn't loaded yet (e.g. very early page load)."""
|
|
html = _src("index.html")
|
|
mic = re.search(r'<button[^>]*\bid="btnMic"[^>]*>', html, re.DOTALL)
|
|
vm = re.search(r'<button[^>]*\bid="btnVoiceMode"[^>]*>', html, re.DOTALL)
|
|
assert mic and vm
|
|
# Accept either `title=` (legacy) or `data-tooltip=` (custom tooltip
|
|
# introduced in #1775) as the static fallback string.
|
|
def _static_tooltip(tag: str) -> str:
|
|
m = re.search(r'\bdata-tooltip="([^"]+)"', tag) \
|
|
or re.search(r'\btitle="([^"]+)"', tag)
|
|
assert m, f"no static tooltip on {tag[:120]}"
|
|
return m.group(1)
|
|
mic_title = _static_tooltip(mic.group(0))
|
|
vm_title = _static_tooltip(vm.group(0))
|
|
assert mic_title != vm_title, \
|
|
f"Static tooltips must differ; both say {mic_title!r}"
|
|
assert "voice input" not in mic_title.lower(), \
|
|
f"btnMic static tooltip still says 'Voice input': {mic_title!r}"
|
|
assert "voice input" not in vm_title.lower(), \
|
|
f"btnVoiceMode static tooltip still says 'Voice input': {vm_title!r}"
|
|
|
|
def test_voice_mode_uses_audio_lines_glyph(self):
|
|
"""btnVoiceMode SVG must use the audio-lines (waveform) shape.
|
|
We detect the pattern by looking for the 6 vertical-bar paths
|
|
characteristic of Lucide's audio-lines icon."""
|
|
html = _src("index.html")
|
|
# Extract the full button (open tag through </button>)
|
|
m = re.search(
|
|
r'<button[^>]*\bid="btnVoiceMode"[^>]*>(.+?)</button>',
|
|
html,
|
|
re.DOTALL,
|
|
)
|
|
assert m, "btnVoiceMode element must be parseable"
|
|
body = m.group(1)
|
|
# Lucide audio-lines path data — six <path d="M{x} {y}v{h}"/> entries.
|
|
bars = re.findall(r'<path d="M\d+\s+\d+v\d+"', body)
|
|
assert len(bars) >= 5, (
|
|
f"btnVoiceMode SVG must use audio-lines (>=5 vertical-bar paths); "
|
|
f"found {len(bars)}. Visual confusion bug returns if reverted to "
|
|
f"the old 'mic with sparkles' shape."
|
|
)
|
|
# Must NOT contain the old mic-shaped rect (rx="3" capsule) — that's
|
|
# the dictation glyph and using it again recreates #1488.
|
|
assert 'rect x="9" y="1" width="6" height="12" rx="3"' not in body, \
|
|
"btnVoiceMode regressed to mic shape — the visual confusion bug returns."
|
|
|
|
|
|
class TestComposerVoiceButtonI18n:
|
|
"""i18n.js must define the four new keys and remove the stale voice_toggle."""
|
|
|
|
REQUIRED_KEYS = (
|
|
"voice_dictate",
|
|
"voice_dictate_active",
|
|
"voice_mode_toggle",
|
|
"voice_mode_toggle_active",
|
|
)
|
|
|
|
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
|
|
|
|
def test_legacy_voice_toggle_key_removed(self):
|
|
"""The old key whose string was 'Voice input' caused the duplicate-
|
|
tooltip bug. It must no longer appear in i18n.js."""
|
|
src = _src("i18n.js")
|
|
# Match the property name only (not strings that happen to mention it).
|
|
leftover = re.findall(r'\bvoice_toggle\s*:', src)
|
|
assert not leftover, (
|
|
f"Stale voice_toggle: key still in i18n.js ({len(leftover)} "
|
|
f"occurrences). Replace with voice_mode_toggle / voice_dictate."
|
|
)
|
|
|
|
def test_all_locales_define_new_keys(self):
|
|
"""Every locale block must define all 4 new composer voice-button keys."""
|
|
src = _src("i18n.js")
|
|
for key in self.REQUIRED_KEYS:
|
|
count = len(re.findall(rf'\b{re.escape(key)}\s*:', src))
|
|
assert count == len(self.LOCALES), (
|
|
f"i18n key {key!r} appears {count} times — expected one per "
|
|
f"locale ({len(self.LOCALES)} locales: {self.LOCALES}). "
|
|
f"Each locale block must define all four composer voice keys."
|
|
)
|
|
|
|
def test_english_dictate_label_is_dictate(self):
|
|
"""English voice_dictate must read 'Dictate' (not 'Voice input')."""
|
|
src = _src("i18n.js")
|
|
# Find the en block (first occurrence of voice_dictate is in en)
|
|
m = re.search(r"\bvoice_dictate\s*:\s*'([^']+)'", src)
|
|
assert m, "voice_dictate key not found"
|
|
assert m.group(1) == "Dictate", \
|
|
f"English voice_dictate should be 'Dictate'; got {m.group(1)!r}"
|
|
|
|
def test_english_voice_mode_label_is_voice_mode(self):
|
|
"""English voice_mode_toggle must read 'Voice mode' — matches
|
|
ChatGPT/Gemini convention (industry-standard label)."""
|
|
src = _src("i18n.js")
|
|
# Find the FIRST voice_mode_toggle in the file (en block) but skip
|
|
# _active suffix variant — use a lookahead to assert no _active.
|
|
m = re.search(r"\bvoice_mode_toggle\s*:\s*'([^']+)'", src)
|
|
assert m, "voice_mode_toggle key not found"
|
|
assert m.group(1) == "Voice mode", \
|
|
f"English voice_mode_toggle should be 'Voice mode'; got {m.group(1)!r}"
|
|
|
|
|
|
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")
|
|
|
|
def test_voice_mode_pref_is_localstorage_backed(self):
|
|
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
|
|
src = _src("boot.js")
|
|
assert "'hermes-voice-mode-button'" in src, (
|
|
"boot.js must read/write the localStorage key 'hermes-voice-mode-button' "
|
|
"for the voice-mode visibility pref."
|
|
)
|
|
|
|
def test_voice_mode_button_hidden_until_pref_enabled(self):
|
|
"""Default state of btnVoiceMode display must be 'none'; visibility
|
|
gated by the pref check, not unconditional."""
|
|
src = _src("boot.js")
|
|
# Find the voice-mode pref helper. Must NOT contain an
|
|
# unconditional `modeBtn.style.display='';` (the master bug).
|
|
# Instead, the function _applyVoiceModePref must be the source of truth.
|
|
assert "_applyVoiceModePref" in src, \
|
|
"boot.js must expose _applyVoiceModePref so settings toggle re-applies live."
|
|
assert "_voiceModePrefEnabled" in src, \
|
|
"boot.js must define _voiceModePrefEnabled to read the pref."
|
|
# The pre-existing `modeBtn.style.display='';` line must be gone.
|
|
# We allow `style.display = _voiceModePrefEnabled() ? '' : 'none'`.
|
|
assert "modeBtn.style.display='';" not in src, (
|
|
"boot.js still contains unconditional `modeBtn.style.display='';` — "
|
|
"this defeats the Preferences gate and reintroduces #1488."
|
|
)
|
|
|
|
def test_settings_pane_has_voice_mode_checkbox(self):
|
|
"""index.html Preferences pane must include the toggle checkbox."""
|
|
html = _src("index.html")
|
|
assert 'id="settingsVoiceModeEnabled"' in html, \
|
|
"Preferences pane must include #settingsVoiceModeEnabled checkbox."
|
|
assert 'data-i18n="settings_label_voice_mode"' in html, \
|
|
"Voice-mode pref label must use data-i18n='settings_label_voice_mode'."
|
|
assert 'data-i18n="settings_desc_voice_mode"' in html, \
|
|
"Voice-mode pref description must use data-i18n='settings_desc_voice_mode'."
|
|
|
|
def test_settings_pane_has_voice_mode_i18n_keys(self):
|
|
"""The two new pref-label i18n keys must exist in every locale."""
|
|
src = _src("i18n.js")
|
|
for key in ("settings_label_voice_mode", "settings_desc_voice_mode"):
|
|
count = len(re.findall(rf'\b{re.escape(key)}\s*:', src))
|
|
assert count == len(self.LOCALES), (
|
|
f"Preferences i18n key {key!r} appears {count} times — "
|
|
f"expected {len(self.LOCALES)} (one per locale)."
|
|
)
|
|
|
|
def test_panels_js_wires_voice_mode_pref(self):
|
|
"""panels.js must read the checkbox state, persist to localStorage,
|
|
and call _applyVoiceModePref so the change is live without reload."""
|
|
src = _src("panels.js")
|
|
assert "settingsVoiceModeEnabled" in src, \
|
|
"panels.js must reference the #settingsVoiceModeEnabled checkbox."
|
|
assert "'hermes-voice-mode-button'" in src, \
|
|
"panels.js must persist the pref to localStorage key 'hermes-voice-mode-button'."
|
|
assert "_applyVoiceModePref" in src, \
|
|
"panels.js onchange handler must call window._applyVoiceModePref() " \
|
|
"so the button appears/disappears immediately."
|
|
|
|
|
|
class TestActiveStateTooltips:
|
|
"""When recording / in voice mode, tooltips should flip to the
|
|
'stop' variants so the affordance is honest."""
|
|
|
|
def test_dictation_active_tooltip_changes_when_recording(self):
|
|
"""_setRecording(on) should flip btnMic.title to voice_dictate_active."""
|
|
src = _src("boot.js")
|
|
m = re.search(r"function _setRecording\(on\)\{.*?\n \}", src, re.DOTALL)
|
|
assert m, "_setRecording function must exist"
|
|
body = m.group(0)
|
|
assert "voice_dictate_active" in body, (
|
|
"_setRecording must flip the tooltip to voice_dictate_active when "
|
|
"recording starts so the user knows pressing it now stops dictation."
|
|
)
|
|
assert "voice_dictate'" in body or "voice_dictate\"" in body, \
|
|
"_setRecording must restore voice_dictate when recording stops."
|
|
|
|
def test_voice_mode_active_tooltip(self):
|
|
"""_activate() should set modeBtn.title to voice_mode_toggle_active."""
|
|
src = _src("boot.js")
|
|
m = re.search(r"function _activate\(\)\{.*?\n \}", src, re.DOTALL)
|
|
assert m, "_activate function must exist"
|
|
body = m.group(0)
|
|
assert "voice_mode_toggle_active" in body, (
|
|
"_activate must flip the tooltip to voice_mode_toggle_active so "
|
|
"the next click obviously exits voice mode."
|
|
)
|
|
|
|
def test_voice_mode_idle_tooltip(self):
|
|
"""_deactivate() should set modeBtn.title back to voice_mode_toggle."""
|
|
src = _src("boot.js")
|
|
m = re.search(r"function _deactivate\(\)\{.*?\n \}", src, re.DOTALL)
|
|
assert m, "_deactivate function must exist"
|
|
body = m.group(0)
|
|
assert re.search(r"voice_mode_toggle['\"]", body), (
|
|
"_deactivate must restore voice_mode_toggle (idle title) when "
|
|
"the user exits voice mode."
|
|
)
|
|
|
|
|
|
class TestAudioLinesIconRegistered:
|
|
"""The audio-lines icon should be in LI_PATHS for any future reuse via li()."""
|
|
|
|
def test_audio_lines_in_li_paths(self):
|
|
src = _src("icons.js")
|
|
assert "'audio-lines'" in src, \
|
|
"audio-lines must be registered in LI_PATHS for li('audio-lines') reuse."
|