import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player"; import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider"; let playSpy: ReturnType; let pauseSpy: ReturnType; let loadSpy: ReturnType; beforeEach(() => { playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined); loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined); }); afterEach(() => { playSpy.mockRestore(); pauseSpy.mockRestore(); loadSpy.mockRestore(); }); test("AdminAudioPlayer wraps native audio with controls and no download or autoplay", () => { render(); const audio = document.querySelector("audio"); expect(audio).toHaveAttribute("src", "https://cdn.example.com/signed/audio.m4a?token=secret"); expect(audio).not.toHaveAttribute("autoplay"); expect(audio).toHaveAttribute("controlsList", "nodownload"); expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument(); expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument(); expect(screen.getByRole("slider", { name: "볼륨" })).toHaveValue("1"); expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument(); }); test("AdminAudioPlayer matches the compact reference control anatomy", () => { render(); const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" }); const controls = within(player).getByRole("group", { name: "재생 제어" }); expect(within(controls).getByRole("button", { name: "재생" })).toBeInTheDocument(); const seek = within(controls).getByRole("slider", { name: "재생 위치" }); const speed = within(controls).getByRole("combobox", { name: "재생 속도" }); const volume = within(controls).getByRole("slider", { name: "볼륨" }); expect.soft(player).toHaveClass("px-1", "sm:px-3"); expect(seek).toHaveClass("min-w-11"); expect.soft(speed).toHaveClass("w-11"); expect(speed).toHaveClass("text-base"); expect.soft(volume).toHaveClass("basis-11"); expect(volume).toHaveClass("min-w-11"); expect(volume).toBeInTheDocument(); expect(volume.className).not.toContain("hidden"); expect.soft(within(controls).queryByText("0:00/0:00")).toBeInTheDocument(); expect(within(player).queryByText("볼륨")).not.toBeInTheDocument(); expect(within(player).queryByText("재생 속도")).not.toBeInTheDocument(); expect(player.querySelector("img")).not.toBeInTheDocument(); expect(player.querySelector("video")).not.toBeInTheDocument(); }); test("AudioPlaybackProvider keeps only one player active by player id", () => { render( , ); const playButtons = screen.getAllByRole("button", { name: "재생" }); const firstPlayButton = playButtons[0]; const secondPlayButton = playButtons[1]; if (firstPlayButton === undefined || secondPlayButton === undefined) { throw new Error("expected two play buttons"); } fireEvent.click(firstPlayButton); fireEvent.click(secondPlayButton); expect(playSpy).toHaveBeenCalledTimes(2); expect(pauseSpy).toHaveBeenCalled(); }); test("AdminAudioPlayer supports keyboard play, seek, volume, speed, generic error, and manual retry only", () => { render(); const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" }); fireEvent.keyDown(player, { key: " " }); fireEvent.change(screen.getByRole("slider", { name: "재생 위치" }), { target: { value: "12" } }); fireEvent.change(screen.getByRole("slider", { name: "볼륨" }), { target: { value: "0.5" } }); fireEvent.change(screen.getByRole("combobox", { name: "재생 속도" }), { target: { value: "1.5" } }); const audio = document.querySelector("audio"); if (!(audio instanceof HTMLAudioElement)) { throw new Error("expected native audio element"); } fireEvent.error(audio); expect(playSpy).toHaveBeenCalledTimes(1); expect(screen.getByRole("alert")).toHaveTextContent("오디오를 재생할 수 없습니다"); fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" })); expect(loadSpy).toHaveBeenCalledTimes(1); expect(playSpy).toHaveBeenCalledTimes(1); }); test("AdminAudioPlayer ignores Enter and Space from descendant controls", () => { render(); fireEvent.keyDown(screen.getByRole("combobox", { name: "재생 속도" }), { key: "Enter" }); fireEvent.keyDown(screen.getByRole("button", { name: "재생" }), { key: " " }); expect(playSpy).not.toHaveBeenCalled(); }); test("AudioPlaybackProvider and AdminAudioPlayer do not log or persist signed URLs", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const localStorageSpy = vi.spyOn(Storage.prototype, "setItem"); const signedUrl = "https://cdn.example.com/signed/audio.m4a?token=secret"; render( , ); fireEvent.click(screen.getByRole("button", { name: "재생" })); const audio = document.querySelector("audio"); if (!(audio instanceof HTMLAudioElement)) { throw new Error("expected native audio element"); } fireEvent.error(audio); expect(logSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); expect(errorSpy).not.toHaveBeenCalled(); expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl)); logSpy.mockRestore(); warnSpy.mockRestore(); errorSpy.mockRestore(); localStorageSpy.mockRestore(); });