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; let socketEventHandlers: Record 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).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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { // 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); }); }); });