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
|
## [v0.21] Sprint 19 -- Auth + Security Hardening
|
||||||
*April 3, 2026 | 328 tests*
|
*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
|
# 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:
|
> 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
|
> 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.
|
**Why now:** Voice input was the next feature on the roadmap. The send button
|
||||||
TTS playback makes long responses more accessible. Both are achievable with
|
UX was a low-effort high-impact polish opportunity that pairs naturally.
|
||||||
existing Whisper and TTS APIs.
|
|
||||||
|
### 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
|
### Track B: Features
|
||||||
- **Voice input (Whisper).** Microphone icon in composer. Hold to record,
|
- **Voice input (Web Speech API).** Microphone button in composer. Tap to
|
||||||
release to transcribe. Transcribed text editable before send.
|
record, tap again to stop. Live interim transcription in textarea. Auto-stops
|
||||||
- **TTS playback.** Speaker icon on assistant messages. Audio playback via
|
after ~2s of silence. Appends to existing text. Hidden when browser doesn't
|
||||||
OpenAI TTS or ElevenLabs API. Optional auto-play in settings.
|
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*
|
*Last updated: April 3, 2026*
|
||||||
*Current version: v0.21 | 328 tests*
|
*Current version: v0.22 | 382 tests*
|
||||||
*Next sprint: Sprint 20 (Voice + TTS)*
|
*Next sprint: Sprint 21 (Mobile Responsive)*
|
||||||
|
|||||||
@@ -8,8 +8,90 @@ async function cancelStream(){
|
|||||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('btnSend').onclick=send;
|
$('btnSend').onclick=()=>{if(window._micActive)_stopMic();send();};
|
||||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
$('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='';};
|
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
||||||
$('btnDownload').onclick=()=>{
|
$('btnDownload').onclick=()=>{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<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">
|
<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 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>
|
<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
|
Drop files to upload to workspace
|
||||||
</div>
|
</div>
|
||||||
<div class="attach-tray" id="attachTray"></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>
|
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||||
<div class="composer-footer">
|
<div class="composer-footer">
|
||||||
<div class="composer-left">
|
<div class="composer-left">
|
||||||
@@ -220,6 +221,14 @@
|
|||||||
<button class="icon-btn" id="btnAttach" title="Attach files">
|
<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>
|
<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>
|
||||||
|
<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>
|
||||||
<div class="composer-right">
|
<div class="composer-right">
|
||||||
<button class="send-btn" id="btnSend">
|
<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{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{opacity:.75;}
|
||||||
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
|
.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;}
|
.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{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);}
|
.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