All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
219 lines
5.8 KiB
TypeScript
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-/);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|