Merge pull request #37 from nesquena/feat/voice-input-mic-button
feat: voice input mic button via Web Speech API
This commit is contained in:
40
CHANGELOG.md
40
CHANGELOG.md
@@ -5,6 +5,44 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.22] Sprint 20 -- Voice Input + Send Button Polish
|
||||
*April 3, 2026 | 382 tests*
|
||||
|
||||
### Features
|
||||
- **Voice input via Web Speech API.** Microphone button in the composer.
|
||||
Tap to start recording, tap again (or send) to stop. Live interim
|
||||
transcription appears in the textarea. Auto-stops after ~2s of silence.
|
||||
Final text stays editable before sending. Appends to existing textarea
|
||||
content rather than replacing it. Button hidden when browser doesn't
|
||||
support Web Speech API. No API keys, no external libraries, no server
|
||||
changes. Works in Chrome, Edge, Safari (partial). Firefox unsupported
|
||||
(button stays hidden).
|
||||
- **Send button polish.** Send button redesigned as a 34px icon-only circle
|
||||
with upward arrow SVG. Hidden by default — appears with pop-in spring
|
||||
animation when textarea has content or files are attached. Disappears
|
||||
on send or when content is cleared. Hidden while agent is responding.
|
||||
Blue fill (#7cb9ff) with glow, scale hover/active for tactile feedback.
|
||||
|
||||
### Architecture
|
||||
- Voice input IIFE in `boot.js`: SpeechRecognition lifecycle with
|
||||
`continuous=false`, `interimResults=true`, error handling via `showToast()`.
|
||||
- `_prefix` variable snapshots existing textarea content on recording start
|
||||
so dictation appends rather than overwrites.
|
||||
- `btnSend.onclick` stops active recognition before sending (send guard).
|
||||
- CSS: `.mic-btn`, `.mic-btn.recording` (red pulse), `.mic-status`,
|
||||
`.mic-dot`, `@keyframes mic-pulse`.
|
||||
- `updateSendBtn()` in `ui.js` tracks textarea content, pending files,
|
||||
and busy state. Hooked into `setBusy()`, `renderTray()`, `autoResize()`,
|
||||
and input event listener.
|
||||
- CSS: `.send-btn` redesigned (circle, glow), `.send-btn.visible` +
|
||||
`@keyframes send-pop-in` (spring animation).
|
||||
|
||||
### Tests
|
||||
- 52 new tests in `test_sprint20.py`: voice input HTML, CSS, JS, append
|
||||
behaviour, error handling, regressions. Total: **382 tests**.
|
||||
|
||||
---
|
||||
|
||||
## [v0.21] Sprint 19 -- Auth + Security Hardening
|
||||
*April 3, 2026 | 328 tests*
|
||||
|
||||
@@ -676,4 +714,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.21, April 3, 2026 | Tests: 328*
|
||||
*Last updated: v0.22, April 3, 2026 | Tests: 382*
|
||||
|
||||
39
SPRINTS.md
39
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.21 | 328 tests | Daily driver ready
|
||||
> Current state: v0.22 | 382 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
@@ -390,19 +390,34 @@ hardening feature before the app is safe to expose to a network.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 20 -- Voice + TTS (PLANNED)
|
||||
## Sprint 20 -- Voice Input + Send Button Polish (COMPLETED)
|
||||
|
||||
**Theme:** Input and output beyond the keyboard.
|
||||
**Theme:** Input refinements — voice and visual polish.
|
||||
|
||||
**Why now:** Voice works in the Hermes CLI. Mirror that capability in the web UI.
|
||||
TTS playback makes long responses more accessible. Both are achievable with
|
||||
existing Whisper and TTS APIs.
|
||||
**Why now:** Voice input was the next feature on the roadmap. The send button
|
||||
UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||
|
||||
### Track A: Bugs
|
||||
- **Send button always visible.** The old pill-shaped "Send" button was always
|
||||
visible even with an empty textarea, wasting space. Now hidden by default,
|
||||
appears only when there is content to send.
|
||||
|
||||
### Track B: Features
|
||||
- **Voice input (Whisper).** Microphone icon in composer. Hold to record,
|
||||
release to transcribe. Transcribed text editable before send.
|
||||
- **TTS playback.** Speaker icon on assistant messages. Audio playback via
|
||||
OpenAI TTS or ElevenLabs API. Optional auto-play in settings.
|
||||
- **Voice input (Web Speech API).** Microphone button in composer. Tap to
|
||||
record, tap again to stop. Live interim transcription in textarea. Auto-stops
|
||||
after ~2s of silence. Appends to existing text. Hidden when browser doesn't
|
||||
support Web Speech API. No API keys, no server changes.
|
||||
- **Send button polish.** Icon-only 34px circle with upward arrow SVG. Pop-in
|
||||
spring animation on appear. Scale hover/active for tactile feedback. Hidden
|
||||
while agent is responding.
|
||||
|
||||
### Track C: Architecture
|
||||
- Voice input IIFE in `boot.js` with SpeechRecognition lifecycle.
|
||||
- `updateSendBtn()` in `ui.js` hooked into setBusy, renderTray, autoResize.
|
||||
|
||||
**Tests:** 52 new (voice) + 33 new (send button). Total: 413.
|
||||
**Hermes CLI parity impact:** Medium (voice not in CLI, but adds capability)
|
||||
**Claude parity impact:** High (Claude has native voice mode)
|
||||
|
||||
---
|
||||
|
||||
@@ -525,5 +540,5 @@ existing Whisper and TTS APIs.
|
||||
---
|
||||
|
||||
*Last updated: April 3, 2026*
|
||||
*Current version: v0.21 | 328 tests*
|
||||
*Next sprint: Sprint 20 (Voice + TTS)*
|
||||
*Current version: v0.22 | 382 tests*
|
||||
*Next sprint: Sprint 21 (Mobile Responsive)*
|
||||
|
||||
@@ -8,8 +8,90 @@ async function cancelStream(){
|
||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||
}
|
||||
|
||||
$('btnSend').onclick=send;
|
||||
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
|
||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
||||
|
||||
// ── Voice input (Web Speech API) ─────────────────────────────────────────
|
||||
(function(){
|
||||
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||||
if(!SpeechRecognition) return; // Browser unsupported — mic button stays hidden
|
||||
|
||||
const btn=$('btnMic');
|
||||
const status=$('micStatus');
|
||||
const ta=$('msg');
|
||||
btn.style.display=''; // Show button — browser supports speech
|
||||
|
||||
const recognition=new SpeechRecognition();
|
||||
recognition.continuous=false;
|
||||
recognition.interimResults=true;
|
||||
recognition.lang='en-US';
|
||||
|
||||
let _finalText='';
|
||||
let _prefix='';
|
||||
|
||||
function _setRecording(on){
|
||||
window._micActive=on;
|
||||
btn.classList.toggle('recording',on);
|
||||
status.style.display=on?'':'none';
|
||||
if(!on){ _finalText=''; _prefix=''; }
|
||||
}
|
||||
|
||||
recognition.onstart=()=>{ _finalText=''; };
|
||||
|
||||
recognition.onresult=(event)=>{
|
||||
let interim='';
|
||||
let final=_finalText;
|
||||
for(let i=event.resultIndex;i<event.results.length;i++){
|
||||
const t=event.results[i][0].transcript;
|
||||
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
||||
else{ interim+=t; }
|
||||
}
|
||||
// Append to whatever was already in the textarea before mic started
|
||||
ta.value=_prefix+(final||interim);
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onend=()=>{
|
||||
// Commit: prefix + final transcription; trim trailing space if prefix was non-empty
|
||||
const committed=_finalText
|
||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||
? _prefix+' '+_finalText.trimStart()
|
||||
: _prefix+_finalText)
|
||||
: ta.value; // no speech detected — leave whatever is there
|
||||
_setRecording(false);
|
||||
ta.value=committed;
|
||||
autoResize();
|
||||
};
|
||||
|
||||
recognition.onerror=(event)=>{
|
||||
_setRecording(false);
|
||||
const msgs={
|
||||
'not-allowed':'Microphone access denied. Check browser permissions.',
|
||||
'no-speech':'No speech detected. Try again.',
|
||||
'network':'Speech recognition unavailable.',
|
||||
};
|
||||
showToast(msgs[event.error]||'Voice input error: '+event.error);
|
||||
};
|
||||
|
||||
function _stopMic(){
|
||||
if(window._micActive){ recognition.stop(); }
|
||||
}
|
||||
window._stopMic=_stopMic; // expose for send-guard above
|
||||
|
||||
btn.onclick=()=>{
|
||||
if(window._micActive){
|
||||
recognition.stop();
|
||||
// _setRecording(false) will be called by onend
|
||||
} else {
|
||||
_finalText='';
|
||||
// Snapshot existing textarea content so we append rather than replace
|
||||
_prefix=ta.value;
|
||||
recognition.start();
|
||||
_setRecording(true);
|
||||
}
|
||||
};
|
||||
})();
|
||||
window._micActive=window._micActive||false;
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
||||
$('btnDownload').onclick=()=>{
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.21</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.22</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
@@ -213,6 +213,7 @@
|
||||
Drop files to upload to workspace
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
@@ -220,6 +221,14 @@
|
||||
<button class="icon-btn" id="btnAttach" title="Attach files">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
||||
<path d="M5 10a7 7 0 0 0 14 0"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<button class="send-btn" id="btnSend">
|
||||
|
||||
@@ -187,6 +187,11 @@
|
||||
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
|
||||
.icon-btn{opacity:.75;}
|
||||
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
|
||||
.mic-btn{transition:color .15s,background .15s;}
|
||||
.mic-btn.recording{color:#e94560;background:rgba(233,69,96,.12);animation:mic-pulse 1.2s ease-in-out infinite;}
|
||||
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(233,69,96,.3);}50%{box-shadow:0 0 0 6px rgba(233,69,96,0);}}
|
||||
.mic-status{font-size:11px;color:#e94560;padding:4px 12px;display:flex;align-items:center;gap:6px;}
|
||||
.mic-dot{width:6px;height:6px;border-radius:50%;background:#e94560;animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
|
||||
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
|
||||
.send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;}
|
||||
.send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}
|
||||
|
||||
422
tests/test_sprint20.py
Normal file
422
tests/test_sprint20.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Sprint 20 Tests: Voice input (mic button) via Web Speech API.
|
||||
|
||||
These tests verify the static assets contain the correct HTML structure,
|
||||
CSS rules, and JS logic for the mic feature — all of which runs purely in
|
||||
the browser with no server-side component.
|
||||
"""
|
||||
import re
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get_text(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return r.read().decode(), r.status
|
||||
|
||||
|
||||
# ── index.html ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mic_button_present_in_html():
|
||||
"""index.html must contain the mic button with id='btnMic'."""
|
||||
html, status = get_text("/")
|
||||
assert status == 200
|
||||
assert 'id="btnMic"' in html
|
||||
|
||||
|
||||
def test_mic_button_has_mic_btn_class():
|
||||
"""btnMic must carry the mic-btn CSS class for styling hooks."""
|
||||
html, _ = get_text("/")
|
||||
assert 'class="icon-btn mic-btn"' in html
|
||||
|
||||
|
||||
def test_mic_button_hidden_by_default():
|
||||
"""btnMic starts hidden (display:none) — JS shows it only if supported."""
|
||||
html, _ = get_text("/")
|
||||
# The button element should have display:none in its style attribute
|
||||
assert 'id="btnMic"' in html
|
||||
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
||||
assert btn_match, "btnMic element not found"
|
||||
assert 'display:none' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_mic_button_has_title():
|
||||
"""btnMic should have a descriptive title for accessibility."""
|
||||
html, _ = get_text("/")
|
||||
btn_match = re.search(r'id="btnMic"[^>]*>', html)
|
||||
assert btn_match
|
||||
assert 'title=' in btn_match.group(0)
|
||||
|
||||
|
||||
def test_mic_status_div_present():
|
||||
"""index.html must contain the #micStatus listening indicator."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="micStatus"' in html
|
||||
|
||||
|
||||
def test_mic_status_hidden_by_default():
|
||||
"""#micStatus starts hidden — only shown during active recording."""
|
||||
html, _ = get_text("/")
|
||||
status_match = re.search(r'id="micStatus"[^>]*>', html)
|
||||
assert status_match, "#micStatus element not found"
|
||||
assert 'display:none' in status_match.group(0)
|
||||
|
||||
|
||||
def test_mic_status_has_mic_dot():
|
||||
"""#micStatus must contain a .mic-dot element for the pulse animation."""
|
||||
html, _ = get_text("/")
|
||||
# mic-dot should appear after micStatus
|
||||
idx_status = html.find('id="micStatus"')
|
||||
idx_dot = html.find('mic-dot', idx_status)
|
||||
assert idx_status != -1 and idx_dot != -1
|
||||
assert idx_dot > idx_status
|
||||
|
||||
|
||||
def test_mic_status_has_listening_text():
|
||||
"""#micStatus should display a 'Listening' label."""
|
||||
html, _ = get_text("/")
|
||||
assert 'Listening' in html
|
||||
|
||||
|
||||
def test_mic_button_svg_microphone_shape():
|
||||
"""btnMic SVG must include the rect (mic body) and path (mic arc)."""
|
||||
html, _ = get_text("/")
|
||||
# Find mic button section
|
||||
btn_start = html.find('id="btnMic"')
|
||||
btn_end = html.find('</button>', btn_start) + len('</button>')
|
||||
btn_html = html[btn_start:btn_end]
|
||||
assert '<rect' in btn_html, "mic SVG missing rect (mic body)"
|
||||
assert '<path' in btn_html, "mic SVG missing path (arc)"
|
||||
assert '<line' in btn_html, "mic SVG missing line (stand)"
|
||||
|
||||
|
||||
def test_mic_button_inside_composer_left():
|
||||
"""btnMic must be inside .composer-left, next to the attach button."""
|
||||
html, _ = get_text("/")
|
||||
composer_left_start = html.find('class="composer-left"')
|
||||
composer_left_end = html.find('</div>', composer_left_start)
|
||||
section = html[composer_left_start:composer_left_end]
|
||||
assert 'btnAttach' in section
|
||||
assert 'btnMic' in section
|
||||
|
||||
|
||||
# ── style.css ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mic_btn_css_rule_exists():
|
||||
"""style.css must define .mic-btn rule."""
|
||||
css, status = get_text("/static/style.css")
|
||||
assert status == 200
|
||||
assert '.mic-btn' in css
|
||||
|
||||
|
||||
def test_mic_btn_recording_state_css():
|
||||
""".mic-btn.recording must be defined for active recording visual state."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-btn.recording' in css
|
||||
|
||||
|
||||
def test_mic_recording_color_red():
|
||||
""".mic-btn.recording must use the red accent color #e94560."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
recording_idx = css.find('.mic-btn.recording')
|
||||
# Find the rule block after the selector
|
||||
brace_open = css.find('{', recording_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert '#e94560' in rule or 'e94560' in rule
|
||||
|
||||
|
||||
def test_mic_recording_has_animation():
|
||||
""".mic-btn.recording must use an animation for the pulse effect."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
recording_idx = css.find('.mic-btn.recording')
|
||||
brace_open = css.find('{', recording_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'animation' in rule
|
||||
|
||||
|
||||
def test_mic_pulse_keyframes_defined():
|
||||
"""@keyframes mic-pulse must be defined for the pulsing animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert 'mic-pulse' in css
|
||||
assert '@keyframes' in css
|
||||
|
||||
|
||||
def test_mic_status_css_rule_exists():
|
||||
"""style.css must define .mic-status rule."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-status' in css
|
||||
|
||||
|
||||
def test_mic_dot_css_rule_exists():
|
||||
"""style.css must define .mic-dot rule with animation."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
assert '.mic-dot' in css
|
||||
dot_idx = css.find('.mic-dot')
|
||||
brace_open = css.find('{', dot_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'animation' in rule
|
||||
|
||||
|
||||
def test_mic_btn_has_transition():
|
||||
""".mic-btn must define a transition for smooth state changes."""
|
||||
css, _ = get_text("/static/style.css")
|
||||
mic_btn_idx = css.find('.mic-btn{')
|
||||
if mic_btn_idx == -1:
|
||||
mic_btn_idx = css.find('.mic-btn ')
|
||||
brace_open = css.find('{', mic_btn_idx)
|
||||
brace_close = css.find('}', brace_open)
|
||||
rule = css[brace_open:brace_close]
|
||||
assert 'transition' in rule
|
||||
|
||||
|
||||
# ── boot.js ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_boot_js_serves_ok():
|
||||
"""boot.js must be served successfully."""
|
||||
_, status = get_text("/static/boot.js")
|
||||
assert status == 200
|
||||
|
||||
|
||||
def test_boot_js_speech_recognition_check():
|
||||
"""boot.js must check for SpeechRecognition (with webkit fallback)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'SpeechRecognition' in js
|
||||
assert 'webkitSpeechRecognition' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_config():
|
||||
"""boot.js must configure recognition.continuous, interimResults, and lang."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.continuous' in js
|
||||
assert 'recognition.interimResults' in js
|
||||
assert 'recognition.lang' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_not_continuous():
|
||||
"""recognition.continuous must be false (auto-stop after silence)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.continuous=false' in js or 'recognition.continuous = false' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_interim_results():
|
||||
"""recognition.interimResults must be true (live transcription preview)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.interimResults=true' in js or 'recognition.interimResults = true' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_lang_en():
|
||||
"""recognition.lang must be set to en-US."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "recognition.lang='en-US'" in js or 'recognition.lang = "en-US"' in js or "recognition.lang='en-US'" in js
|
||||
|
||||
|
||||
def test_boot_js_onresult_handler():
|
||||
"""boot.js must define recognition.onresult to handle transcription."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onresult' in js
|
||||
|
||||
|
||||
def test_boot_js_onend_handler():
|
||||
"""boot.js must define recognition.onend to reset state when recording stops."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onend' in js
|
||||
|
||||
|
||||
def test_boot_js_onerror_handler():
|
||||
"""boot.js must define recognition.onerror for graceful error handling."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.onerror' in js
|
||||
|
||||
|
||||
def test_boot_js_not_allowed_error_message():
|
||||
"""onerror must handle 'not-allowed' with a user-friendly message."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'not-allowed' in js
|
||||
assert 'permission' in js.lower() or 'denied' in js.lower() or 'access' in js.lower()
|
||||
|
||||
|
||||
def test_boot_js_no_speech_error_message():
|
||||
"""onerror must handle 'no-speech' with a user-friendly message."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'no-speech' in js
|
||||
|
||||
|
||||
def test_boot_js_network_error_message():
|
||||
"""onerror must handle 'network' error."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "'network'" in js or '"network"' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_active_flag():
|
||||
"""boot.js must track recording state via _micActive flag."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert '_micActive' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_recording_class_toggle():
|
||||
"""boot.js must toggle 'recording' CSS class on the mic button."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "'recording'" in js or '"recording"' in js
|
||||
|
||||
|
||||
def test_boot_js_mic_status_toggle():
|
||||
"""boot.js must show/hide #micStatus during recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'micStatus' in js
|
||||
|
||||
|
||||
def test_boot_js_send_stops_mic():
|
||||
"""btnSend onclick must stop mic before sending (send guard)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# The send button onclick should check _micActive and stop recording
|
||||
send_onclick_idx = js.find("$('btnSend').onclick")
|
||||
assert send_onclick_idx != -1
|
||||
# Find the handler code — check that _micActive check appears near send assignment
|
||||
handler_end = js.find(';', send_onclick_idx)
|
||||
handler = js[send_onclick_idx:handler_end + 1]
|
||||
assert '_micActive' in handler or 'stopMic' in handler.lower()
|
||||
|
||||
|
||||
def test_boot_js_btn_mic_onclick():
|
||||
"""boot.js must attach an onclick handler to btnMic."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'btn.onclick' in js or "btnMic.onclick" in js or "$('btnMic').onclick" in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_start():
|
||||
"""boot.js must call recognition.start() to begin recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.start()' in js
|
||||
|
||||
|
||||
def test_boot_js_recognition_stop():
|
||||
"""boot.js must call recognition.stop() to end recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'recognition.stop()' in js
|
||||
|
||||
|
||||
def test_boot_js_iife_guard():
|
||||
"""Mic logic must be wrapped in an IIFE so it doesn't pollute global scope."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# IIFE pattern: (function(){...})() or (() => {...})()
|
||||
assert '(function(){' in js or '(function () {' in js
|
||||
|
||||
|
||||
def test_boot_js_browser_unsupported_return():
|
||||
"""boot.js must bail out (return) early when SpeechRecognition is unavailable."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# The IIFE should have an early return when SpeechRecognition is falsy
|
||||
assert 'if(!SpeechRecognition)' in js or 'if (!SpeechRecognition)' in js
|
||||
|
||||
|
||||
def test_boot_js_shows_mic_button_when_supported():
|
||||
"""boot.js must set display='' on btnMic when SpeechRecognition is available."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "btn.style.display=''" in js or 'btn.style.display = ""' in js
|
||||
|
||||
|
||||
def test_boot_js_show_toast_on_error():
|
||||
"""boot.js must call showToast() for mic errors."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'showToast' in js
|
||||
|
||||
|
||||
def test_boot_js_autoresize_called():
|
||||
"""boot.js must call autoResize() after updating textarea from transcript."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert 'autoResize()' in js
|
||||
|
||||
|
||||
# ── Append behaviour (fix: mic appends to existing text, not replace) ────
|
||||
|
||||
|
||||
def test_boot_js_prefix_variable_declared():
|
||||
"""boot.js must declare _prefix variable to snapshot pre-existing textarea content."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "_prefix" in js
|
||||
|
||||
|
||||
def test_boot_js_prefix_captured_on_start():
|
||||
"""_prefix must be set from ta.value when the user starts recording."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# _prefix assignment must happen in the btn.onclick else branch (before recognition.start)
|
||||
btn_onclick_idx = js.find("btn.onclick")
|
||||
btn_onclick_end = js.find("};", btn_onclick_idx)
|
||||
onclick_body = js[btn_onclick_idx:btn_onclick_end]
|
||||
assert "_prefix=ta.value" in onclick_body or "_prefix = ta.value" in onclick_body
|
||||
|
||||
|
||||
def test_boot_js_onresult_prepends_prefix():
|
||||
"""onresult must include _prefix when writing to textarea (append, not replace)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onresult_idx = js.find("recognition.onresult")
|
||||
onresult_end = js.find("};", onresult_idx)
|
||||
onresult_body = js[onresult_idx:onresult_end]
|
||||
# ta.value must be set to _prefix + something, not just the transcript alone
|
||||
assert "_prefix" in onresult_body
|
||||
|
||||
|
||||
def test_boot_js_onend_commits_with_prefix():
|
||||
"""onend must commit _prefix + _finalText so appended text survives after recognition ends."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onend_idx = js.find("recognition.onend")
|
||||
onend_end = js.find("};", onend_idx)
|
||||
onend_body = js[onend_idx:onend_end]
|
||||
assert "_prefix" in onend_body
|
||||
|
||||
|
||||
def test_boot_js_prefix_reset_on_stop():
|
||||
"""_prefix must be reset when recording stops so next session starts clean."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
# _setRecording(false) clears both _finalText and _prefix
|
||||
set_rec_idx = js.find("function _setRecording")
|
||||
set_rec_end = js.find("}", set_rec_idx) + 1
|
||||
fn_body = js[set_rec_idx:set_rec_end]
|
||||
assert "_prefix" in fn_body
|
||||
|
||||
|
||||
def test_boot_js_auto_space_between_prefix_and_transcript():
|
||||
"""onend must insert a space between existing text and new transcript when needed."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
onend_idx = js.find("recognition.onend")
|
||||
onend_end = js.find("};", onend_idx)
|
||||
onend_body = js[onend_idx:onend_end]
|
||||
# Should handle spacing — look for trimStart or endsWith(' ') check
|
||||
has_spacing = ("trimStart" in onend_body or "endsWith(' ')" in onend_body
|
||||
or "endsWith(\" \")" in onend_body or "endsWith('\\n')" in onend_body)
|
||||
assert has_spacing, "onend should handle spacing between prefix and new transcript"
|
||||
|
||||
|
||||
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
||||
|
||||
|
||||
def test_attach_button_still_wired():
|
||||
"""btnAttach onclick must still be wired up (no regression)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "$('btnAttach').onclick" in js
|
||||
|
||||
|
||||
def test_file_input_onchange_still_wired():
|
||||
"""fileInput onchange must still be wired up (no regression)."""
|
||||
js, _ = get_text("/static/boot.js")
|
||||
assert "$('fileInput').onchange" in js
|
||||
|
||||
|
||||
def test_index_html_still_has_send_button():
|
||||
"""btnSend must still be present in index.html (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnSend"' in html
|
||||
|
||||
|
||||
def test_index_html_still_has_attach_button():
|
||||
"""btnAttach must still be present in index.html (no regression)."""
|
||||
html, _ = get_text("/")
|
||||
assert 'id="btnAttach"' in html
|
||||
Reference in New Issue
Block a user