HERMES-GIELIE/tests/test_issue4761_voice_mode_config.py
nesquena-hermes 0aea4c7e18 harden(#5176): clamp configurable voice silence timeout (maintainer hardening)
Apply the warm-review polish: parseInt radix 10, fall back to 1800 for
missing/NaN/non-positive values, and floor at 200ms so a mistyped tiny/negative
hermes-voice-silence-ms can't make the recognizer auto-send instantly. De-brittle
the source-string test to assert behavior (reads key / 1800 fallback / guards
non-positive) instead of one literal expression. Default behavior unchanged.
2026-06-29 20:21:22 +00:00

96 lines
4 KiB
Python

"""Tests for #4761 — configurable voice-mode silence timeout and continuous recognition.
The voice-mode loop currently hardcodes:
- SILENCE_MS = 1800 (1.8s pause before auto-send)
- _recognition.continuous = false (mic closes after each utterance)
This module pins the fix: both values are now configurable via localStorage keys
(hermes-voice-silence-ms, hermes-voice-continuous) with sensible defaults.
"""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
def _boot_src() -> str:
return (REPO / "static" / "boot.js").read_text(encoding="utf-8")
class TestVoiceModeSilenceMsConfig:
"""SILENCE_MS must read from localStorage with 1800 fallback."""
def test_silence_ms_reads_local_storage_with_fallback(self):
src = _boot_src()
# Assert the BEHAVIOR (read the key, parse as int, fall back to 1800 for
# missing/invalid values) rather than one exact expression, so the impl
# can be hardened (e.g. a min-floor clamp) without a brittle test break.
assert re.search(
r"parseInt\s*\(\s*localStorage\.getItem\s*\(\s*'hermes-voice-silence-ms'\s*\)",
src,
), "SILENCE_MS must read the 'hermes-voice-silence-ms' localStorage key via parseInt."
# The 1800 default must remain the fallback for missing/invalid values.
assert re.search(r"SILENCE_MS\s*=.*\b1800\b", src), (
"SILENCE_MS must keep 1800 as the default fallback so behavior is "
"unchanged when the key is unset or invalid."
)
# A non-positive / mistyped value must not be honored verbatim (no instant
# auto-send): the value is guarded by a positivity check and/or a min floor.
assert "_silenceMsRaw>0" in src or "Math.max(" in src or "> 0" in src, (
"SILENCE_MS must guard against non-positive values (positivity check "
"or a Math.max floor) so a mistyped tiny/negative value can't make the "
"recognizer auto-send instantly."
)
def test_silence_ms_used_in_timeout(self):
src = _boot_src()
assert "SILENCE_MS" in src, "SILENCE_MS must still be referenced in the timeout call."
class TestVoiceModeContinuousConfig:
"""_recognition.continuous must read from localStorage."""
def test_continuous_reads_local_storage(self):
src = _boot_src()
assert (
"_recognition.continuous=localStorage.getItem('hermes-voice-continuous')==='true'"
in src
), (
"_recognition.continuous must read from localStorage key "
"'hermes-voice-continuous' with default false. "
"Without this, users with natural mid-sentence pauses get cut off."
)
def test_continuous_true_behavior(self):
"""When hermes-voice-continuous is 'true', the recognition stays open
across pauses, so the silence timer is the sole arbiter of send timing."""
src = _boot_src()
# The continuous flag must not replace or disable the silence timer logic.
assert (
"_silenceTimer=setTimeout" in src
), "The silence timer must still exist for continuous mode send decision."
class TestBootJsVoiceSectionIntegrity:
"""Smoke checks — the surrounding voice-mode infrastructure is intact."""
def test_voice_mode_declares_silence_ms(self):
src = _boot_src()
assert "SILENCE_MS" in src, "SILENCE_MS constant must exist in boot.js"
def test_voice_mode_declares_recognition(self):
src = _boot_src()
assert "_recognition=new SpeechRecognition()" in src
def test_voice_mode_state_machine_present(self):
src = _boot_src()
for state in ("idle", "listening", "thinking", "speaking"):
assert f"'{state}'" in src, f"Voice mode state '{state}' must be referenced."
def test_voice_mode_patches_auto_read(self):
src = _boot_src()
assert "autoReadLastAssistant" in src, (
"voice mode must still override autoReadLastAssistant to pipe "
"response completion into _speakResponse."
)