// @vitest-environment jsdom /** * test/voice.test.ts — Unit tests for voice.ts (N-voice, A2). * * Tests the Web Speech API wrapper (isSpeechSupported / createVoiceInput) * and the keybar mic-button integration (mountKeybar opts.onVoiceTrigger). * Runs under jsdom so window / document / TouchEvent are available; the real * SpeechRecognition is replaced by a lightweight self-registering mock class. * * AC-A2.1 final transcript calls onTranscript * AC-A2.2 touch preventDefault keeps soft keyboard hidden * AC-A2.3 unsupported → hidden, no crash * AC-A2.4 autoSend appends \r; off → no \r * AC-A2.5 audio never reaches server (pure-FE — no server stub needed here) * SEC-L2 disclosure via title attribute on mic button */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { isSpeechSupported, createVoiceInput } from '../public/voice.js' import { mountKeybar } from '../public/keybar.js' // ── Mock SpeechRecognition ──────────────────────────────────────────────────── // Using a self-registering class pattern: each `new MockRecognition()` stores // itself in MockRecognition.last, so tests can reference the instance created // by createVoiceInput() without needing vi.fn() as the constructor wrapper. /** Minimal SpeechRecognitionEvent-like shape for test usage. */ interface MockResultEvent { resultIndex: number results: { length: number [index: number]: { isFinal: boolean length: number [index: number]: { transcript: string; confidence: number } } } } class MockRecognition { /** The most-recently constructed instance — set in the constructor. */ static last: MockRecognition | null = null continuous = false interimResults = false lang = '' onresult: ((ev: MockResultEvent) => void) | null = null onend: (() => void) | null = null onerror: ((ev: { error: string }) => void) | null = null // Arrow-function class fields create fresh vi.fn() per instance readonly start = vi.fn() readonly stop = vi.fn() readonly abort = vi.fn() constructor() { MockRecognition.last = this } } /** Build a single-result event for testing. */ function makeResultEvent(transcript: string, isFinal: boolean, resultIndex = 0): MockResultEvent { const alt = { transcript, confidence: 1.0 } const result = Object.assign([alt], { isFinal, length: 1 }) const results = Object.assign([result], { length: 1 }) return { resultIndex, results } as unknown as MockResultEvent } // ───────────────────────────────────────────────────────────────────────────── // isSpeechSupported // ───────────────────────────────────────────────────────────────────────────── describe('isSpeechSupported', () => { afterEach(() => { vi.unstubAllGlobals() }) it('returns false when SpeechRecognition is not in window', () => { // jsdom does not provide SpeechRecognition by default expect(isSpeechSupported()).toBe(false) }) it('returns true when SpeechRecognition is present in window', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) expect(isSpeechSupported()).toBe(true) }) it('returns true when webkitSpeechRecognition is present in window', () => { vi.stubGlobal('webkitSpeechRecognition', MockRecognition) expect(isSpeechSupported()).toBe(true) }) }) // ───────────────────────────────────────────────────────────────────────────── // createVoiceInput // ───────────────────────────────────────────────────────────────────────────── describe('createVoiceInput', () => { beforeEach(() => { MockRecognition.last = null vi.stubGlobal('SpeechRecognition', MockRecognition) }) afterEach(() => { vi.unstubAllGlobals() }) // --- null when unsupported --- it('returns null when speech is not supported', () => { vi.unstubAllGlobals() const voice = createVoiceInput(vi.fn()) expect(voice).toBeNull() }) // --- object shape --- it('returns a VoiceInput object with required methods', () => { const voice = createVoiceInput(vi.fn()) expect(voice).not.toBeNull() expect(typeof voice?.start).toBe('function') expect(typeof voice?.stop).toBe('function') expect(typeof voice?.dispose).toBe('function') expect(typeof voice?.isActive).toBe('function') }) // --- isActive --- it('starts in inactive state', () => { const voice = createVoiceInput(vi.fn()) expect(voice?.isActive()).toBe(false) }) it('isActive returns true after start()', () => { const voice = createVoiceInput(vi.fn()) voice?.start() expect(voice?.isActive()).toBe(true) }) it('isActive returns false after stop()', () => { const voice = createVoiceInput(vi.fn()) voice?.start() voice?.stop() expect(voice?.isActive()).toBe(false) }) it('isActive returns false after natural onend fires', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() mock.onend?.() expect(voice?.isActive()).toBe(false) }) it('isActive returns false after onerror fires', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() mock.onerror?.({ error: 'network' }) expect(voice?.isActive()).toBe(false) }) // --- recognition.start / stop calls --- it('calls recognition.start() on start()', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() expect(mock.start).toHaveBeenCalledTimes(1) }) it('does not call recognition.start() twice when already active', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() voice?.start() expect(mock.start).toHaveBeenCalledTimes(1) }) it('calls recognition.stop() on stop() when active', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() voice?.stop() expect(mock.stop).toHaveBeenCalledTimes(1) }) it('does not call recognition.stop() when not active', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.stop() expect(mock.stop).not.toHaveBeenCalled() }) // --- transcript callbacks --- it('calls onTranscript with final transcript text (AC-A2.1)', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript) const mock = MockRecognition.last! voice?.start() mock.onresult?.(makeResultEvent('hello world', true)) expect(onTranscript).toHaveBeenCalledWith('hello world') }) it('autoSend=true appends \\r to the transcript (AC-A2.4)', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript, { autoSend: true }) const mock = MockRecognition.last! voice?.start() mock.onresult?.(makeResultEvent('hello', true)) expect(onTranscript).toHaveBeenCalledWith('hello\r') }) it('autoSend=false does not append \\r (AC-A2.4)', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript, { autoSend: false }) const mock = MockRecognition.last! voice?.start() mock.onresult?.(makeResultEvent('hello', true)) expect(onTranscript).toHaveBeenCalledWith('hello') }) it('omitting autoSend does not append \\r', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript) const mock = MockRecognition.last! voice?.start() mock.onresult?.(makeResultEvent('hello', true)) expect(onTranscript).toHaveBeenCalledWith('hello') }) it('calls onInterim with interim transcript text', () => { const onInterim = vi.fn() const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript, { onInterim }) const mock = MockRecognition.last! voice?.start() mock.onresult?.(makeResultEvent('hel', false)) expect(onInterim).toHaveBeenCalledWith('hel') expect(onTranscript).not.toHaveBeenCalled() }) it('does not crash when interim fires but onInterim is not provided', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript) const mock = MockRecognition.last! voice?.start() expect(() => mock.onresult?.(makeResultEvent('par', false))).not.toThrow() expect(onTranscript).not.toHaveBeenCalled() }) it('does not call onTranscript for interim-only results', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript, { onInterim: vi.fn() }) const mock = MockRecognition.last! voice?.start() mock.onresult?.(makeResultEvent('partial', false)) expect(onTranscript).not.toHaveBeenCalled() }) // --- dispose --- it('does not fire onTranscript after dispose()', () => { const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript) const mock = MockRecognition.last! voice?.start() voice?.dispose() mock.onresult?.(makeResultEvent('late transcript', true)) expect(onTranscript).not.toHaveBeenCalled() }) it('calls recognition.abort() when disposed while active', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() voice?.dispose() expect(mock.abort).toHaveBeenCalledTimes(1) }) it('does not call recognition.abort() when disposed while inactive', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.dispose() expect(mock.abort).not.toHaveBeenCalled() }) it('dispose() is idempotent — double dispose is safe', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.start() expect(() => { voice?.dispose(); voice?.dispose() }).not.toThrow() expect(mock.abort).toHaveBeenCalledTimes(1) }) it('start() after dispose() is a no-op', () => { const voice = createVoiceInput(vi.fn()) const mock = MockRecognition.last! voice?.dispose() voice?.start() expect(mock.start).not.toHaveBeenCalled() }) // --- configuration --- it('sets recognition.lang to the provided lang option', () => { createVoiceInput(vi.fn(), { lang: 'ja-JP' }) expect(MockRecognition.last?.lang).toBe('ja-JP') }) it('uses navigator.language by default', () => { createVoiceInput(vi.fn()) expect(MockRecognition.last?.lang).toBe(navigator.language) }) it('sets interimResults=true when onInterim is provided', () => { createVoiceInput(vi.fn(), { onInterim: vi.fn() }) expect(MockRecognition.last?.interimResults).toBe(true) }) it('sets interimResults=false when onInterim is not provided', () => { createVoiceInput(vi.fn()) expect(MockRecognition.last?.interimResults).toBe(false) }) it('uses webkitSpeechRecognition when SpeechRecognition is absent', () => { vi.unstubAllGlobals() // Re-create MockRecognition.last tracking class WebkitMockRecognition extends MockRecognition {} vi.stubGlobal('webkitSpeechRecognition', WebkitMockRecognition) const onTranscript = vi.fn() const voice = createVoiceInput(onTranscript) expect(voice).not.toBeNull() voice?.start() expect(MockRecognition.last?.start).toHaveBeenCalled() vi.unstubAllGlobals() }) }) // ───────────────────────────────────────────────────────────────────────────── // mountKeybar voice button (onVoiceTrigger integration) // ───────────────────────────────────────────────────────────────────────────── describe('mountKeybar voice button', () => { beforeEach(() => { document.body.innerHTML = '
' }) afterEach(() => { document.body.innerHTML = '' vi.unstubAllGlobals() }) it('does not append a mic button when speech is not supported (AC-A2.3)', () => { // No SpeechRecognition in window by default in jsdom mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) expect(document.querySelector('[data-key="voice"]')).toBeNull() }) it('does not append a mic button when onVoiceTrigger is not provided', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) mountKeybar(vi.fn()) expect(document.querySelector('[data-key="voice"]')).toBeNull() }) it('appends a mic button when supported and onVoiceTrigger is provided', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) expect(document.querySelector('[data-key="voice"]')).not.toBeNull() }) it('mic button displays 🎤 key label', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) const keyEl = document.querySelector('[data-key="voice"] .kb-key') expect(keyEl?.textContent).toBe('🎤') }) it('mic button has a caption label', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) const capEl = document.querySelector('[data-key="voice"] .kb-cap') expect(capEl?.textContent).toBeTruthy() }) it('touchstart calls onVoiceTrigger("start") and prevents default (AC-A2.2)', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) const onVoiceTrigger = vi.fn() mountKeybar(vi.fn(), { onVoiceTrigger }) const btn = document.querySelector('[data-key="voice"]')! const ev = new Event('touchstart', { cancelable: true, bubbles: true }) btn.dispatchEvent(ev) expect(onVoiceTrigger).toHaveBeenCalledWith('start') }) it('touchend calls onVoiceTrigger("stop") and prevents default', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) const onVoiceTrigger = vi.fn() mountKeybar(vi.fn(), { onVoiceTrigger }) const btn = document.querySelector('[data-key="voice"]')! const ev = new Event('touchend', { cancelable: true, bubbles: true }) btn.dispatchEvent(ev) expect(onVoiceTrigger).toHaveBeenCalledWith('stop') }) it('mousedown calls onVoiceTrigger("start") for desktop fallback', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) const onVoiceTrigger = vi.fn() mountKeybar(vi.fn(), { onVoiceTrigger }) const btn = document.querySelector('[data-key="voice"]')! btn.dispatchEvent(new MouseEvent('mousedown', { cancelable: true, bubbles: true })) expect(onVoiceTrigger).toHaveBeenCalledWith('start') }) it('mouseup calls onVoiceTrigger("stop") for desktop fallback', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) const onVoiceTrigger = vi.fn() mountKeybar(vi.fn(), { onVoiceTrigger }) const btn = document.querySelector('[data-key="voice"]')! btn.dispatchEvent(new MouseEvent('mouseup', { cancelable: true, bubbles: true })) expect(onVoiceTrigger).toHaveBeenCalledWith('stop') }) it('mic button title discloses audio privacy (SEC-L2)', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) const btn = document.querySelector('[data-key="voice"]')! expect(btn.title.toLowerCase()).toMatch(/audio/) }) it('existing key buttons are still rendered when voice opts provided', () => { vi.stubGlobal('SpeechRecognition', MockRecognition) mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) expect(document.querySelector('[data-key="esc"]')).not.toBeNull() }) it('mountKeybar works with no opts (backward compat)', () => { expect(() => mountKeybar(vi.fn())).not.toThrow() }) it('mountKeybar returns without error when #keybar element is absent', () => { document.body.innerHTML = '' // no #keybar expect(() => mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })).not.toThrow() }) })