Files
stack/apps/web/src/hooks/useVoiceInput.test.ts
Jason Woltje 74d6c1092e
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(#403): add audio playback component for TTS output
Implements AudioPlayer inline component with play/pause, progress bar,
speed control (0.5x-2x), download, and duration display. Adds
TextToSpeechButton "Read aloud" component that synthesizes text via
the speech API and integrates AudioPlayer for playback. Includes
useTextToSpeech hook with API integration, audio caching, and
playback state management. All 32 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 03:05:39 -06:00

363 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useVoiceInput } from "./useVoiceInput";
import type { Socket } from "socket.io-client";
import { io } from "socket.io-client";
// Mock socket.io-client
vi.mock("socket.io-client");
// Mock MediaRecorder
const mockMediaRecorder = {
start: vi.fn(),
stop: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
state: "inactive" as RecordingState,
ondataavailable: null as ((event: BlobEvent) => void) | null,
onstop: null as (() => void) | null,
onerror: null as ((event: Event) => void) | null,
addEventListener: vi.fn((event: string, handler: EventListenerOrEventListenerObject) => {
if (event === "dataavailable") {
mockMediaRecorder.ondataavailable = handler as (event: BlobEvent) => void;
} else if (event === "stop") {
mockMediaRecorder.onstop = handler as () => void;
} else if (event === "error") {
mockMediaRecorder.onerror = handler as (event: Event) => void;
}
}),
removeEventListener: vi.fn(),
stream: {
getTracks: vi.fn(() => [{ stop: vi.fn() }]),
},
};
// Mock MediaStream with getByteFrequencyData for audio level
const mockAnalyserNode = {
fftSize: 256,
frequencyBinCount: 128,
getByteFrequencyData: vi.fn((array: Uint8Array) => {
// Simulate some audio data
for (let i = 0; i < array.length; i++) {
array[i] = 128;
}
}),
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockMediaStreamSource = {
connect: vi.fn(),
disconnect: vi.fn(),
};
const mockAudioContext = {
createAnalyser: vi.fn(() => mockAnalyserNode),
createMediaStreamSource: vi.fn(() => mockMediaStreamSource),
close: vi.fn(),
state: "running",
};
// Mock getUserMedia
const mockGetUserMedia = vi.fn();
// Set up global mocks
Object.defineProperty(global.navigator, "mediaDevices", {
value: {
getUserMedia: mockGetUserMedia,
},
writable: true,
configurable: true,
});
// Mock AudioContext
vi.stubGlobal(
"AudioContext",
vi.fn(() => mockAudioContext)
);
// Mock MediaRecorder constructor
vi.stubGlobal(
"MediaRecorder",
vi.fn(() => mockMediaRecorder)
);
// Add isTypeSupported static method
(
global.MediaRecorder as unknown as { isTypeSupported: (type: string) => boolean }
).isTypeSupported = vi.fn(() => true);
describe("useVoiceInput", (): void => {
let mockSocket: Partial<Socket>;
let socketEventHandlers: Record<string, (data: unknown) => void>;
beforeEach((): void => {
socketEventHandlers = {};
mockSocket = {
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
socketEventHandlers[event] = handler;
return mockSocket;
}) as unknown as Socket["on"],
off: vi.fn(() => mockSocket) as unknown as Socket["off"],
emit: vi.fn() as unknown as Socket["emit"],
connect: vi.fn(),
disconnect: vi.fn(),
connected: true,
};
(io as unknown as ReturnType<typeof vi.fn>).mockReturnValue(mockSocket);
// Reset MediaRecorder mock state
mockMediaRecorder.state = "inactive";
mockMediaRecorder.ondataavailable = null;
mockMediaRecorder.onstop = null;
mockMediaRecorder.onerror = null;
// Default: getUserMedia succeeds
const mockStream = {
getTracks: vi.fn(() => [{ stop: vi.fn() }]),
} as unknown as MediaStream;
mockGetUserMedia.mockResolvedValue(mockStream);
});
afterEach((): void => {
vi.clearAllMocks();
});
it("should return the correct interface", (): void => {
const { result } = renderHook(() => useVoiceInput());
expect(result.current).toHaveProperty("isRecording");
expect(result.current).toHaveProperty("startRecording");
expect(result.current).toHaveProperty("stopRecording");
expect(result.current).toHaveProperty("transcript");
expect(result.current).toHaveProperty("partialTranscript");
expect(result.current).toHaveProperty("error");
expect(result.current).toHaveProperty("audioLevel");
});
it("should start with default state", (): void => {
const { result } = renderHook(() => useVoiceInput());
expect(result.current.isRecording).toBe(false);
expect(result.current.transcript).toBe("");
expect(result.current.partialTranscript).toBe("");
expect(result.current.error).toBeNull();
expect(result.current.audioLevel).toBe(0);
});
it("should start recording when startRecording is called", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
expect(result.current.isRecording).toBe(true);
expect(mockGetUserMedia).toHaveBeenCalledWith({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
},
});
});
it("should stop recording when stopRecording is called", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
expect(result.current.isRecording).toBe(true);
act(() => {
result.current.stopRecording();
});
expect(result.current.isRecording).toBe(false);
});
it("should set error when microphone access is denied", async (): Promise<void> => {
mockGetUserMedia.mockRejectedValueOnce(
new DOMException("Permission denied", "NotAllowedError")
);
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
expect(result.current.isRecording).toBe(false);
expect(result.current.error).toBeTruthy();
expect(result.current.error).toContain("microphone");
});
it("should connect to speech WebSocket namespace", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
expect(io).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
path: "/socket.io",
})
);
});
it("should emit start-transcription when recording begins", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
expect(mockSocket.emit).toHaveBeenCalledWith(
"start-transcription",
expect.objectContaining({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
format: expect.any(String),
})
);
});
it("should emit stop-transcription when recording stops", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
act(() => {
result.current.stopRecording();
});
expect(mockSocket.emit).toHaveBeenCalledWith("stop-transcription");
});
it("should handle partial transcription events", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
act(() => {
socketEventHandlers["transcription-partial"]?.({
text: "hello world",
});
});
await waitFor(() => {
expect(result.current.partialTranscript).toBe("hello world");
});
});
it("should handle final transcription events", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
act(() => {
socketEventHandlers["transcription-final"]?.({
text: "hello world final",
});
});
await waitFor(() => {
expect(result.current.transcript).toBe("hello world final");
});
});
it("should handle transcription error events", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
act(() => {
socketEventHandlers["transcription-error"]?.({
message: "Transcription failed",
});
});
await waitFor(() => {
expect(result.current.error).toBe("Transcription failed");
});
});
it("should call onTranscript callback when final transcription received", async (): Promise<void> => {
const onTranscript = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onTranscript }));
await act(async () => {
await result.current.startRecording();
});
act(() => {
socketEventHandlers["transcription-final"]?.({
text: "final text",
});
});
await waitFor(() => {
expect(onTranscript).toHaveBeenCalledWith("final text");
});
});
it("should clean up on unmount", async (): Promise<void> => {
const { result, unmount } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
unmount();
expect(mockSocket.disconnect).toHaveBeenCalled();
});
it("should not start recording if already recording", async (): Promise<void> => {
const { result } = renderHook(() => useVoiceInput());
await act(async () => {
await result.current.startRecording();
});
// Reset the call count
mockGetUserMedia.mockClear();
await act(async () => {
await result.current.startRecording();
});
// Should not have called getUserMedia again
expect(mockGetUserMedia).not.toHaveBeenCalled();
});
describe("REST fallback", (): void => {
it("should fall back to REST when WebSocket is unavailable", async (): Promise<void> => {
// Simulate socket not connecting
(mockSocket as { connected: boolean }).connected = false;
const { result } = renderHook(() => useVoiceInput({ useWebSocket: false }));
// Should still be able to start recording (REST mode)
await act(async () => {
await result.current.startRecording();
});
expect(result.current.isRecording).toBe(true);
});
});
});