/** * @file TextToSpeechButton.test.tsx * @description Tests for the TextToSpeechButton "Read aloud" component */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TextToSpeechButton } from "./TextToSpeechButton"; // Mock the useTextToSpeech hook const mockSynthesize = vi.fn(); const mockPlay = vi.fn(); const mockPause = vi.fn(); const mockStop = vi.fn(); vi.mock("@/hooks/useTextToSpeech", () => ({ useTextToSpeech: vi.fn(() => ({ synthesize: mockSynthesize, play: mockPlay, pause: mockPause, stop: mockStop, audioUrl: null, isLoading: false, error: null, isPlaying: false, duration: 0, currentTime: 0, })), })); // Import after mocking import { useTextToSpeech } from "@/hooks/useTextToSpeech"; const mockUseTextToSpeech = useTextToSpeech as ReturnType; // Mock HTMLAudioElement for AudioPlayer used inside TextToSpeechButton class MockAudio { src = ""; currentTime = 0; duration = 60; 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(): void { // no-op } removeEventListener(): void { // no-op } } vi.stubGlobal("Audio", MockAudio); describe("TextToSpeechButton", () => { beforeEach(() => { vi.clearAllMocks(); mockUseTextToSpeech.mockReturnValue({ synthesize: mockSynthesize, play: mockPlay, pause: mockPause, stop: mockStop, audioUrl: null, isLoading: false, error: null, isPlaying: false, duration: 0, currentTime: 0, }); }); describe("rendering", () => { it("should render a read aloud button", () => { render(); const button = screen.getByRole("button", { name: /read aloud/i }); expect(button).toBeInTheDocument(); }); it("should not render AudioPlayer initially when no audio is synthesized", () => { render(); expect(screen.queryByRole("region", { name: /audio player/i })).not.toBeInTheDocument(); }); }); describe("click behavior", () => { it("should call synthesize with text on click", async () => { const user = userEvent.setup(); mockSynthesize.mockResolvedValueOnce(undefined); render(); const button = screen.getByRole("button", { name: /read aloud/i }); await user.click(button); expect(mockSynthesize).toHaveBeenCalledWith("Hello world", undefined); }); it("should pass voice and tier options when provided", async () => { const user = userEvent.setup(); mockSynthesize.mockResolvedValueOnce(undefined); render(); const button = screen.getByRole("button", { name: /read aloud/i }); await user.click(button); expect(mockSynthesize).toHaveBeenCalledWith("Hello", { voice: "alloy", tier: "premium", }); }); }); describe("loading state", () => { it("should show loading indicator while synthesizing", () => { mockUseTextToSpeech.mockReturnValue({ synthesize: mockSynthesize, play: mockPlay, pause: mockPause, stop: mockStop, audioUrl: null, isLoading: true, error: null, isPlaying: false, duration: 0, currentTime: 0, }); render(); const button = screen.getByRole("button", { name: /synthesizing/i }); expect(button).toBeInTheDocument(); expect(button).toBeDisabled(); }); }); describe("audio player integration", () => { it("should show AudioPlayer when audio is available", () => { mockUseTextToSpeech.mockReturnValue({ synthesize: mockSynthesize, play: mockPlay, pause: mockPause, stop: mockStop, audioUrl: "blob:mock-url", isLoading: false, error: null, isPlaying: false, duration: 30, currentTime: 0, }); render(); expect(screen.getByRole("region", { name: /audio player/i })).toBeInTheDocument(); }); }); describe("error state", () => { it("should display error message when synthesis fails", () => { mockUseTextToSpeech.mockReturnValue({ synthesize: mockSynthesize, play: mockPlay, pause: mockPause, stop: mockStop, audioUrl: null, isLoading: false, error: "Synthesis failed", isPlaying: false, duration: 0, currentTime: 0, }); render(); expect(screen.getByText(/synthesis failed/i)).toBeInTheDocument(); }); }); describe("accessibility", () => { it("should have proper aria label on button", () => { render(); const button = screen.getByRole("button", { name: /read aloud/i }); expect(button).toBeInTheDocument(); }); }); describe("design", () => { it("should not use aggressive colors", () => { const { container } = render(); const allElements = container.querySelectorAll("*"); allElements.forEach((el) => { const className = el.className; if (typeof className === "string") { expect(className).not.toMatch(/bg-red-|text-red-|border-red-/); } }); }); }); });