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

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);
});
});
});