Files
stack/apps/web/src/components/speech/TextToSpeechButton.test.tsx
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

219 lines
5.8 KiB
TypeScript

/**
* @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<typeof vi.fn>;
// 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<void> {
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(<TextToSpeechButton text="Hello world" />);
const button = screen.getByRole("button", { name: /read aloud/i });
expect(button).toBeInTheDocument();
});
it("should not render AudioPlayer initially when no audio is synthesized", () => {
render(<TextToSpeechButton text="Hello world" />);
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(<TextToSpeechButton text="Hello world" />);
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(<TextToSpeechButton text="Hello" voice="alloy" tier="premium" />);
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(<TextToSpeechButton text="Hello world" />);
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(<TextToSpeechButton text="Hello world" />);
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(<TextToSpeechButton text="Hello world" />);
expect(screen.getByText(/synthesis failed/i)).toBeInTheDocument();
});
});
describe("accessibility", () => {
it("should have proper aria label on button", () => {
render(<TextToSpeechButton text="Hello world" />);
const button = screen.getByRole("button", { name: /read aloud/i });
expect(button).toBeInTheDocument();
});
});
describe("design", () => {
it("should not use aggressive colors", () => {
const { container } = render(<TextToSpeechButton text="Hello world" />);
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-/);
}
});
});
});
});