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>
179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
/**
|
|
* @file AudioPlayer.test.tsx
|
|
* @description Tests for the AudioPlayer component that provides inline TTS audio playback
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { AudioPlayer } from "./AudioPlayer";
|
|
|
|
// Mock HTMLAudioElement
|
|
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(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);
|
|
|
|
describe("AudioPlayer", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("rendering", () => {
|
|
it("should render play button", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
const playButton = screen.getByRole("button", { name: "Play audio" });
|
|
expect(playButton).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render download button", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
const downloadButton = screen.getByRole("button", { name: /download/i });
|
|
expect(downloadButton).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render time display showing 0:00", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
expect(screen.getByText("0:00")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render speed control", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
const speedButton = screen.getByRole("button", { name: "Playback speed" });
|
|
expect(speedButton).toBeInTheDocument();
|
|
});
|
|
|
|
it("should render progress bar", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
const progressBar = screen.getByRole("progressbar");
|
|
expect(progressBar).toBeInTheDocument();
|
|
});
|
|
|
|
it("should not render when src is null", () => {
|
|
const { container } = render(<AudioPlayer src={null} />);
|
|
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("play/pause", () => {
|
|
it("should toggle to pause button when playing", async () => {
|
|
const user = userEvent.setup();
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
const playButton = screen.getByRole("button", { name: "Play audio" });
|
|
await user.click(playButton);
|
|
|
|
expect(screen.getByRole("button", { name: "Pause audio" })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("speed control", () => {
|
|
it("should cycle through speed options on click", async () => {
|
|
const user = userEvent.setup();
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
const speedButton = screen.getByRole("button", { name: "Playback speed" });
|
|
|
|
// Default should be 1x
|
|
expect(speedButton).toHaveTextContent("1x");
|
|
|
|
// Click to go to 1.5x
|
|
await user.click(speedButton);
|
|
expect(speedButton).toHaveTextContent("1.5x");
|
|
|
|
// Click to go to 2x
|
|
await user.click(speedButton);
|
|
expect(speedButton).toHaveTextContent("2x");
|
|
|
|
// Click to go to 0.5x
|
|
await user.click(speedButton);
|
|
expect(speedButton).toHaveTextContent("0.5x");
|
|
|
|
// Click to go back to 1x
|
|
await user.click(speedButton);
|
|
expect(speedButton).toHaveTextContent("1x");
|
|
});
|
|
});
|
|
|
|
describe("accessibility", () => {
|
|
it("should have proper aria labels on controls", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
expect(screen.getByRole("button", { name: "Play audio" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /download/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Playback speed" })).toBeInTheDocument();
|
|
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-label");
|
|
});
|
|
|
|
it("should have region role on the player container", () => {
|
|
render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
expect(screen.getByRole("region", { name: /audio player/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("design", () => {
|
|
it("should not use aggressive red colors", () => {
|
|
const { container } = render(<AudioPlayer src="blob:test-audio" />);
|
|
|
|
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-/);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("callbacks", () => {
|
|
it("should call onPlayStateChange when play state changes", async () => {
|
|
const onPlayStateChange = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(<AudioPlayer src="blob:test-audio" onPlayStateChange={onPlayStateChange} />);
|
|
|
|
const playButton = screen.getByRole("button", { name: "Play audio" });
|
|
await user.click(playButton);
|
|
|
|
expect(onPlayStateChange).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
});
|