/** * @file useTextToSpeech.test.ts * @description Tests for the useTextToSpeech hook that manages TTS API integration */ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { useTextToSpeech } from "./useTextToSpeech"; import * as speechApi from "@/lib/api/speech"; // Mock the speech API module vi.mock("@/lib/api/speech", () => ({ synthesizeSpeech: vi.fn(), getVoices: vi.fn(), })); // Mock URL.createObjectURL and URL.revokeObjectURL const mockCreateObjectURL = vi.fn().mockReturnValue("blob:mock-audio-url"); const mockRevokeObjectURL = vi.fn(); beforeEach(() => { global.URL.createObjectURL = mockCreateObjectURL; global.URL.revokeObjectURL = mockRevokeObjectURL; }); // Mock HTMLAudioElement class MockAudio { src = ""; currentTime = 0; duration = 120; paused = true; playbackRate = 1; volume = 1; onended: (() => void) | null = null; ontimeupdate: (() => void) | null = null; onloadedmetadata: (() => void) | null = null; onerror: ((e: unknown) => void) | null = null; play(): Promise { this.paused = false; return Promise.resolve(); } pause(): void { this.paused = true; } addEventListener(event: string, handler: () => void): void { if (event === "ended") this.onended = handler; if (event === "timeupdate") this.ontimeupdate = handler; if (event === "loadedmetadata") this.onloadedmetadata = handler; if (event === "error") this.onerror = handler; } removeEventListener(): void { // no-op for tests } } vi.stubGlobal("Audio", MockAudio); const mockSynthesizeSpeech = speechApi.synthesizeSpeech as ReturnType; describe("useTextToSpeech", () => { beforeEach(() => { vi.clearAllMocks(); mockCreateObjectURL.mockReturnValue("blob:mock-audio-url"); }); afterEach(() => { vi.restoreAllMocks(); }); describe("initial state", () => { it("should return correct initial interface", () => { const { result } = renderHook(() => useTextToSpeech()); expect(result.current.synthesize).toBeTypeOf("function"); expect(result.current.play).toBeTypeOf("function"); expect(result.current.pause).toBeTypeOf("function"); expect(result.current.stop).toBeTypeOf("function"); expect(result.current.audioUrl).toBeNull(); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); expect(result.current.isPlaying).toBe(false); expect(result.current.duration).toBe(0); expect(result.current.currentTime).toBe(0); }); }); describe("synthesize", () => { it("should call API and return audio blob URL", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValueOnce(mockBlob); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello world"); }); expect(mockSynthesizeSpeech).toHaveBeenCalledWith({ text: "Hello world", }); expect(result.current.audioUrl).toBe("blob:mock-audio-url"); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); }); it("should pass voice and tier options to API", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValueOnce(mockBlob); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello", { voice: "alloy", tier: "premium", speed: 1.5, }); }); expect(mockSynthesizeSpeech).toHaveBeenCalledWith({ text: "Hello", voice: "alloy", tier: "premium", speed: 1.5, }); }); it("should set loading state while synthesizing", async () => { let resolvePromise: ((value: Blob) => void) | undefined; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockSynthesizeSpeech.mockReturnValueOnce(pendingPromise); const { result } = renderHook(() => useTextToSpeech()); act(() => { void result.current.synthesize("Hello"); }); expect(result.current.isLoading).toBe(true); await act(async () => { resolvePromise?.(new Blob(["audio"], { type: "audio/mpeg" })); await pendingPromise; }); expect(result.current.isLoading).toBe(false); }); it("should handle API errors gracefully", async () => { mockSynthesizeSpeech.mockRejectedValueOnce(new Error("Synthesis failed")); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello"); }); expect(result.current.error).toBe("Synthesis failed"); expect(result.current.isLoading).toBe(false); expect(result.current.audioUrl).toBeNull(); }); it("should cache audio for repeated synthesis of same text", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValue(mockBlob); const { result } = renderHook(() => useTextToSpeech()); // First call await act(async () => { await result.current.synthesize("Hello world"); }); // Second call with same text await act(async () => { await result.current.synthesize("Hello world"); }); // API should only be called once due to caching expect(mockSynthesizeSpeech).toHaveBeenCalledTimes(1); }); it("should not cache when options differ", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValue(mockBlob); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello", { voice: "alloy" }); }); await act(async () => { await result.current.synthesize("Hello", { voice: "nova" }); }); expect(mockSynthesizeSpeech).toHaveBeenCalledTimes(2); }); }); describe("playback controls", () => { it("should play audio after synthesis", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValueOnce(mockBlob); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello"); }); await act(async () => { await result.current.play(); }); expect(result.current.isPlaying).toBe(true); }); it("should pause audio playback", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValueOnce(mockBlob); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello"); }); await act(async () => { await result.current.play(); }); act(() => { result.current.pause(); }); expect(result.current.isPlaying).toBe(false); }); it("should stop and reset playback", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValueOnce(mockBlob); const { result } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello"); }); await act(async () => { await result.current.play(); }); act(() => { result.current.stop(); }); expect(result.current.isPlaying).toBe(false); expect(result.current.currentTime).toBe(0); }); }); describe("cleanup", () => { it("should revoke object URLs on unmount", async () => { const mockBlob = new Blob(["audio-data"], { type: "audio/mpeg" }); mockSynthesizeSpeech.mockResolvedValueOnce(mockBlob); const { result, unmount } = renderHook(() => useTextToSpeech()); await act(async () => { await result.current.synthesize("Hello"); }); unmount(); expect(mockRevokeObjectURL).toHaveBeenCalled(); }); }); });