v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
206
src/test/components/player/MediaPlayer.test.tsx
Normal file
206
src/test/components/player/MediaPlayer.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MediaPlayer } from "../../../components/player/MediaPlayer.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
function media(container: HTMLElement) {
|
||||
return container.querySelector<HTMLMediaElement>("audio, video")!;
|
||||
}
|
||||
function setTime(el: HTMLMediaElement, currentTime: number, duration: number) {
|
||||
Object.defineProperty(el, "duration", {
|
||||
configurable: true,
|
||||
value: duration,
|
||||
});
|
||||
el.currentTime = currentTime;
|
||||
fireEvent(el, new Event("durationchange"));
|
||||
fireEvent(el, new Event("timeupdate"));
|
||||
}
|
||||
|
||||
describe("MediaPlayer", () => {
|
||||
test("play and pause through the button, reporting state", async () => {
|
||||
const onPlayStateChange = vi.fn();
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play");
|
||||
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause");
|
||||
renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalled();
|
||||
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(pause).toHaveBeenCalled();
|
||||
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
test("autoplays when asked", async () => {
|
||||
const onPlayStateChange = vi.fn();
|
||||
renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
autoplay
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true));
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a play refused by the browser stays paused", async () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(
|
||||
new Error("NotAllowedError"),
|
||||
);
|
||||
renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
autoplay
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a pause from outside the page (a media key) is reflected", async () => {
|
||||
const onPlayStateChange = vi.fn();
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
fireEvent(media(container), new Event("pause"));
|
||||
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
|
||||
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
|
||||
fireEvent(media(container), new Event("play"));
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
|
||||
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
test("time, duration, seeking and the end", async () => {
|
||||
const onTimeUpdate = vi.fn();
|
||||
const onEnded = vi.fn();
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onEnded={onEnded}
|
||||
/>,
|
||||
);
|
||||
const el = media(container);
|
||||
setTime(el, 65, 200);
|
||||
expect(screen.getByText("1:05")).toBeInTheDocument();
|
||||
expect(screen.getByText("3:20")).toBeInTheDocument();
|
||||
expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200);
|
||||
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), {
|
||||
target: { value: "120" },
|
||||
});
|
||||
expect(el.currentTime).toBe(120);
|
||||
fireEvent(el, new Event("ended"));
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("while the seek bar is held, playback doesn't move the thumb — by touch too", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
|
||||
);
|
||||
const el = media(container);
|
||||
setTime(el, 10, 100);
|
||||
const seek = screen.getByRole("slider", { name: "Seek" });
|
||||
fireEvent.pointerDown(seek, { pointerType: "touch" });
|
||||
fireEvent.change(seek, { target: { value: "50" } });
|
||||
setTime(el, 11, 100);
|
||||
expect(screen.getByText("0:50")).toBeInTheDocument();
|
||||
fireEvent.pointerUp(seek, { pointerType: "touch" });
|
||||
setTime(el, 51, 100);
|
||||
expect(screen.getByText("0:51")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an error is reported for re-resolving", () => {
|
||||
const onError = vi.fn();
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
fireEvent(media(container), new Event("error"));
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("volume and mute are remembered", async () => {
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
|
||||
);
|
||||
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), {
|
||||
target: { value: "0.4" },
|
||||
});
|
||||
expect(media(container).volume).toBeCloseTo(0.4);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mute" }));
|
||||
expect(media(container).muted).toBe(true);
|
||||
expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({
|
||||
volume: 0.4,
|
||||
muted: true,
|
||||
});
|
||||
expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0");
|
||||
// Raising the volume unmutes.
|
||||
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), {
|
||||
target: { value: "0.7" },
|
||||
});
|
||||
expect(media(container).muted).toBe(false);
|
||||
});
|
||||
|
||||
test("registers seek and toggle handles for the global player", async () => {
|
||||
const seekRef = { current: null as ((t: number) => void) | null };
|
||||
const toggleRef = { current: null as (() => void) | null };
|
||||
const { container, unmount } = renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
seekRef={seekRef}
|
||||
toggleRef={toggleRef}
|
||||
/>,
|
||||
);
|
||||
act(() => seekRef.current!(33));
|
||||
expect(media(container).currentTime).toBe(33);
|
||||
await act(async () => toggleRef.current!());
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
|
||||
unmount();
|
||||
expect(seekRef.current).toBeNull();
|
||||
});
|
||||
|
||||
test("resumes at a start offset once metadata is in", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
startTime={42}
|
||||
/>,
|
||||
);
|
||||
fireEvent(media(container), new Event("loadedmetadata"));
|
||||
expect(media(container).currentTime).toBe(42);
|
||||
});
|
||||
|
||||
test("video has a fullscreen button", () => {
|
||||
renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />);
|
||||
expect(screen.getByRole("button", { name: "Fullscreen" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user