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:
119
src/test/components/dump/DumpCard.test.tsx
Normal file
119
src/test/components/dump/DumpCard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DumpCard } from "../../../components/dump/DumpCard.tsx";
|
||||
import { markDumpVisited } from "../../../utils/visited.ts";
|
||||
import { makeCategory, makeDump, makeSession } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("DumpCard", () => {
|
||||
const props = {
|
||||
voteCount: 3,
|
||||
voted: false,
|
||||
canVote: true,
|
||||
castVote: vi.fn(),
|
||||
removeVote: vi.fn(),
|
||||
};
|
||||
|
||||
test("title, teaser, date, comment count and categories", () => {
|
||||
const dump = makeDump({
|
||||
id: "d1",
|
||||
slug: "nice",
|
||||
title: "Nice find",
|
||||
comment: "Look **here**",
|
||||
commentCount: 2,
|
||||
categoryIds: ["c1", "missing"],
|
||||
});
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
{
|
||||
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/nice",
|
||||
);
|
||||
expect(screen.getByText("here").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("2 comments")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
"/music",
|
||||
);
|
||||
});
|
||||
|
||||
test("an unread dot until visited, never on your own", () => {
|
||||
const dump = makeDump({ id: "d2" });
|
||||
const first = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
|
||||
first.unmount();
|
||||
const own = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(own.container.querySelector(".unread-dot")).toBeNull();
|
||||
own.unmount();
|
||||
markDumpVisited("d2");
|
||||
const seen = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(seen.container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("the private badge shows to the owner", () => {
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking the card opens the dump; voting doesn't", async () => {
|
||||
const castVote = vi.fn();
|
||||
const dump = makeDump({ id: "d3" });
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} castVote={castVote} />
|
||||
</ul>,
|
||||
{ session: makeSession() },
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
|
||||
expect(castVote).toHaveBeenCalledWith("d3", 3);
|
||||
expect(currentPath()).toBe("/");
|
||||
await userEvent.click(screen.getByText("1 minute ago"));
|
||||
expect(currentPath()).toBe("/dumps/d3");
|
||||
});
|
||||
|
||||
test("file dumps get a file preview; bare URLs a placeholder", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard
|
||||
dump={makeDump({
|
||||
kind: "file",
|
||||
fileName: "a.pdf",
|
||||
fileMime: "application/pdf",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelectorAll(".thumb-placeholder").length)
|
||||
.toBeGreaterThan(0);
|
||||
const cards = container.querySelectorAll(".dump-card");
|
||||
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
119
src/test/components/dump/FilePreview.test.tsx
Normal file
119
src/test/components/dump/FilePreview.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FilePreview } from "../../../components/dump/FilePreview.tsx";
|
||||
import { makeDump, makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
import {
|
||||
dumpFileUrl,
|
||||
dumpFileVersion,
|
||||
dumpThumbnailUrl,
|
||||
} from "../../../utils/urls.ts";
|
||||
|
||||
describe("FilePreview", () => {
|
||||
const session = makeSession();
|
||||
const file = (mime: string, over = {}) =>
|
||||
makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
fileName: "thing",
|
||||
fileMime: mime,
|
||||
fileSize: 2048,
|
||||
url: undefined,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("images show inline, with the media token", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} />, { session });
|
||||
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||
"src",
|
||||
`http://localhost:8000/api/files/f1?v=${
|
||||
dumpFileVersion(d)
|
||||
}&token=${session.mediaToken}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a compact image shows the server's preview, not the upload", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, { session });
|
||||
const img = screen.getByRole("img", { name: "thing" });
|
||||
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
|
||||
expect(img).toHaveAttribute("loading", "lazy");
|
||||
});
|
||||
|
||||
test("other files are a download link", () => {
|
||||
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
||||
const link = screen.getByRole("link", { name: /Download thing/ });
|
||||
expect(link).toHaveAttribute("download", "thing");
|
||||
expect(link).toHaveTextContent("(2.0 KB)");
|
||||
});
|
||||
|
||||
test("compact audio plays in the global player", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "file",
|
||||
mimeType: "audio/mpeg",
|
||||
title: "Dump f1",
|
||||
subtitle: "thing",
|
||||
dumpHref: "/dumps/f1",
|
||||
artworkUrl: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact video claims its generated still as artwork", async () => {
|
||||
const play = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toMatch(
|
||||
/^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the global video button toggles when it's the one playing", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} global />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "file",
|
||||
fileUrl: dumpFileUrl(d),
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the global audio waveform starts playback", async () => {
|
||||
const play = vi.fn();
|
||||
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
|
||||
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("compact PDFs and text get an icon", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<FilePreview dump={file("application/pdf")} compact />,
|
||||
);
|
||||
expect(container).toHaveTextContent("📄");
|
||||
});
|
||||
});
|
||||
|
||||
import { route } from "../../fetch.ts";
|
||||
105
src/test/components/dump/JournalCard.test.tsx
Normal file
105
src/test/components/dump/JournalCard.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { JournalCard } from "../../../components/dump/JournalCard.tsx";
|
||||
import { makeDump } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
import { dumpThumbnailUrl } from "../../../utils/urls.ts";
|
||||
|
||||
describe("JournalCard", () => {
|
||||
const base = {
|
||||
voteCount: 1,
|
||||
voted: false,
|
||||
canVote: true,
|
||||
castVote: vi.fn(),
|
||||
removeVote: vi.fn(),
|
||||
};
|
||||
|
||||
test("image, quote and text modes", () => {
|
||||
const { container, unmount } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard
|
||||
dump={makeDump({
|
||||
richContent: {
|
||||
type: "link",
|
||||
url: "u",
|
||||
thumbnailUrl: "https://i.test/a.png",
|
||||
},
|
||||
})}
|
||||
shape="feature"
|
||||
{...base}
|
||||
/>
|
||||
<JournalCard
|
||||
dump={makeDump({ comment: "A thought" })}
|
||||
shape="square"
|
||||
{...base}
|
||||
/>
|
||||
<JournalCard dump={makeDump()} shape="square" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
const cards = container.querySelectorAll("li");
|
||||
expect(cards[0]).toHaveClass(
|
||||
"journal-card--image",
|
||||
"journal-card--feature",
|
||||
);
|
||||
expect(cards[1]).toHaveClass("journal-card--quote");
|
||||
expect(within(cards[1] as HTMLElement).getByText("A thought"))
|
||||
.toBeInTheDocument();
|
||||
expect(cards[2]).toHaveClass("journal-card--text");
|
||||
expect(cards[2]).toHaveTextContent("🔗");
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("a playable image card plays; an unplayable one navigates", async () => {
|
||||
const play = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const yt = makeDump({
|
||||
id: "y",
|
||||
richContent: {
|
||||
type: "youtube",
|
||||
url: "https://youtu.be/y",
|
||||
thumbnailUrl: "https://i.test/y.png",
|
||||
embedUrl: "https://www.youtube.com/embed/y",
|
||||
},
|
||||
});
|
||||
const pic = makeDump({
|
||||
id: "p",
|
||||
kind: "file",
|
||||
fileMime: "image/png",
|
||||
fileName: "p.png",
|
||||
url: undefined,
|
||||
});
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard dump={yt} shape="wide" {...base} />
|
||||
<JournalCard dump={pic} shape="square" {...base} />
|
||||
</ul>,
|
||||
{ player: { play } },
|
||||
);
|
||||
const cards = container.querySelectorAll("li");
|
||||
await user.click(cards[0].querySelector(".journal-card-image")!);
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }),
|
||||
);
|
||||
expect(currentPath()).toBe("/");
|
||||
await user.click(cards[1].querySelector(".journal-card-image")!);
|
||||
expect(currentPath()).toBe("/dumps/p");
|
||||
});
|
||||
|
||||
test("a video file shows its generated still", () => {
|
||||
const dump = makeDump({
|
||||
id: "v",
|
||||
kind: "file",
|
||||
fileMime: "video/mp4",
|
||||
url: undefined,
|
||||
});
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard dump={dump} shape="tall" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelector("img")!.getAttribute("src")).toBe(
|
||||
dumpThumbnailUrl(dump),
|
||||
);
|
||||
});
|
||||
});
|
||||
108
src/test/components/dump/RichContentCard.test.tsx
Normal file
108
src/test/components/dump/RichContentCard.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RichContentCard } from "../../../components/dump/RichContentCard.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("RichContentCard", () => {
|
||||
const yt = {
|
||||
type: "youtube",
|
||||
url: "https://youtube.com/watch?v=x",
|
||||
title: "A video",
|
||||
siteName: "YouTube",
|
||||
description: "About it",
|
||||
thumbnailUrl: "https://i.ytimg.com/x.jpg",
|
||||
embedUrl: "https://www.youtube.com/embed/x",
|
||||
};
|
||||
|
||||
test("full card: play button, source link and details", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(
|
||||
<RichContentCard richContent={yt} dumpHref="/dumps/d" />,
|
||||
{
|
||||
player: { play },
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute(
|
||||
"href",
|
||||
yt.url,
|
||||
);
|
||||
expect(screen.getByText("About it")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "embed",
|
||||
embedUrl: yt.embedUrl,
|
||||
dumpHref: "/dumps/d",
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact: shows Pause while this card plays", () => {
|
||||
renderWithContexts(<RichContentCard richContent={yt} compact />, {
|
||||
player: {
|
||||
playing: true,
|
||||
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass(
|
||||
"is-playing",
|
||||
);
|
||||
});
|
||||
|
||||
test("compact and unplayable: a link to the source", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={{
|
||||
type: "link",
|
||||
url: "https://blog.test/p",
|
||||
siteName: "Blog",
|
||||
}}
|
||||
compact
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://blog.test/p",
|
||||
);
|
||||
});
|
||||
|
||||
test("a dump thumbnail overrides the provider's", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={yt}
|
||||
thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/thumbnails/d",
|
||||
);
|
||||
});
|
||||
|
||||
test("a native stream that is the active item toggles instead of restarting", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const play = vi.fn();
|
||||
const bc = {
|
||||
type: "bandcamp",
|
||||
url: "https://a.bandcamp.com/album/x",
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/1",
|
||||
};
|
||||
renderWithContexts(<RichContentCard richContent={bc} compact />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
play,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "stream",
|
||||
type: "bandcamp",
|
||||
streamUrl: "s",
|
||||
resolveUrl: bc.url,
|
||||
resolveIndex: 0,
|
||||
resolvedAt: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
437
src/test/components/dump/create/DumpCreateModal.test.tsx
Normal file
437
src/test/components/dump/create/DumpCreateModal.test.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DumpCreateModal } from "../../../../components/dump/create/DumpCreateModal.tsx";
|
||||
import { calls, fail, route } from "../../../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
wire,
|
||||
} from "../../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../../render.tsx";
|
||||
|
||||
const session = makeSession({ username: "me" });
|
||||
const categories = [makeCategory({ id: "c1", name: "Music" })];
|
||||
|
||||
function setup(
|
||||
props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {},
|
||||
) {
|
||||
const onClose = vi.fn();
|
||||
const injectDump = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<DumpCreateModal onClose={onClose} {...props} />, {
|
||||
session,
|
||||
categories,
|
||||
ws: { injectDump },
|
||||
});
|
||||
return { user, onClose, injectDump };
|
||||
}
|
||||
|
||||
function previewRoutes(
|
||||
{ title = "Page title", reached = true, dupes = [] as unknown[] } = {},
|
||||
) {
|
||||
route("GET /api/preview", (req) => ({
|
||||
reached,
|
||||
richContent: reached
|
||||
? {
|
||||
type: "link",
|
||||
url: req.url.searchParams.get("url"),
|
||||
title,
|
||||
siteName: "Site",
|
||||
}
|
||||
: { type: "link", url: req.url.searchParams.get("url") },
|
||||
}));
|
||||
route("GET /api/dumps/by-url", () => dupes);
|
||||
}
|
||||
|
||||
const nextBtn = () => screen.getByRole("button", { name: "Next →" });
|
||||
const linkBox = () => screen.getByPlaceholderText("Paste a link…");
|
||||
|
||||
describe("DumpCreateModal: a link", () => {
|
||||
test("Next stays disabled until there is a link or a file", async () => {
|
||||
const { user } = setup();
|
||||
expect(screen.getByRole("dialog", { name: "New dump" }))
|
||||
.toBeInTheDocument();
|
||||
expect(nextBtn()).toBeDisabled();
|
||||
await user.type(linkBox(), "not a url at all");
|
||||
expect(nextBtn()).toBeDisabled();
|
||||
});
|
||||
|
||||
test("previews the page and suggests its title", async () => {
|
||||
previewRoutes();
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "example.com/a");
|
||||
expect(await screen.findByText("Fetching preview…")).toBeInTheDocument();
|
||||
// The suggestion is written in an effect just after the field appears.
|
||||
const title = await screen.findByLabelText("Title");
|
||||
await waitFor(() => expect(title).toHaveValue("Page title"));
|
||||
expect(title).toBeDisabled();
|
||||
const [req] = calls("GET /api/preview");
|
||||
expect(req.url.searchParams.get("url")).toBe("https://example.com/a");
|
||||
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
});
|
||||
|
||||
test("an unreachable page says so, but can still be posted", async () => {
|
||||
previewRoutes({ reached: false });
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://down.test/");
|
||||
expect(await screen.findByText(/Couldn't load a preview/))
|
||||
.toBeInTheDocument();
|
||||
expect(nextBtn()).toBeEnabled();
|
||||
});
|
||||
|
||||
test("warns about an earlier dump of the same link", async () => {
|
||||
previewRoutes({
|
||||
dupes: [
|
||||
{
|
||||
id: "old",
|
||||
slug: "old-one",
|
||||
title: "Old one",
|
||||
username: "ann",
|
||||
createdAt: new Date(Date.now() - 3600_000).toISOString(),
|
||||
voteCount: 1,
|
||||
commentCount: 0,
|
||||
},
|
||||
{
|
||||
id: "older",
|
||||
title: "Older",
|
||||
username: "bob",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const { user, onClose } = setup();
|
||||
await user.type(linkBox(), "https://x.test/");
|
||||
const notice = await screen.findByText(/Already dumped by ann/);
|
||||
expect(notice.closest("[role=status]")).toHaveTextContent(
|
||||
"and 1 earlier dump",
|
||||
);
|
||||
await user.click(screen.getByRole("link", { name: "Old one" }));
|
||||
expect(currentPath()).toBe("/dumps/old-one");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the whole way through: post, then add to a playlist", async () => {
|
||||
previewRoutes();
|
||||
const created = makeDump({
|
||||
id: "new",
|
||||
slug: "page-title",
|
||||
title: "Page title",
|
||||
userId: session.user.id,
|
||||
});
|
||||
route("POST /api/dumps", () => wire(created));
|
||||
const mix = makePlaylist({ id: "p1", title: "Mix" });
|
||||
route(
|
||||
"GET /api/playlists/by-dump/:id/memberships",
|
||||
() => [{ playlist: wire(mix), hasDump: false }],
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
const { user, injectDump, onClose } = setup();
|
||||
|
||||
await user.type(linkBox(), "example.com/a");
|
||||
await screen.findByLabelText("Title");
|
||||
await user.click(nextBtn());
|
||||
|
||||
expect(screen.getByText(/example\.com\/a|Page title/)).toBeInTheDocument();
|
||||
const why = screen.getByLabelText("Why?");
|
||||
await waitFor(() => expect(why).toHaveFocus());
|
||||
await user.type(why, " Because ");
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("button", { name: "Private" }));
|
||||
await user.click(screen.getByRole("button", { name: "Dump it" }));
|
||||
|
||||
await screen.findByRole("dialog", { name: "Add to playlist" });
|
||||
expect(calls("POST /api/dumps")[0].body).toEqual({
|
||||
url: "https://example.com/a",
|
||||
comment: "Because",
|
||||
isPrivate: true,
|
||||
categoryIds: ["c1"],
|
||||
});
|
||||
expect(injectDump).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "new" }),
|
||||
);
|
||||
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
|
||||
|
||||
const row = await screen.findByRole("checkbox", { name: /Mix/ });
|
||||
expect(row).toHaveAttribute("aria-checked", "false");
|
||||
row.focus();
|
||||
await user.keyboard(" ");
|
||||
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true"));
|
||||
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/p1/dumps/new",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a refused playlist toggle leaves the box as it was", async () => {
|
||||
previewRoutes();
|
||||
route("POST /api/dumps", () => wire(makeDump({ id: "new" })));
|
||||
route(
|
||||
"GET /api/playlists/by-dump/:id/memberships",
|
||||
() => [{
|
||||
playlist: wire(makePlaylist({ title: "Mix" })),
|
||||
hasDump: false,
|
||||
}],
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://a.test/{Enter}");
|
||||
await user.click(await screen.findByRole("button", { name: "Dump it" }));
|
||||
const row = await screen.findByRole("checkbox", { name: /Mix/ });
|
||||
await user.click(row);
|
||||
await act(async () => {});
|
||||
expect(row).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
test("a title typed by the poster is sent; a suggested one isn't", async () => {
|
||||
previewRoutes();
|
||||
route("POST /api/dumps", () => wire(makeDump()));
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://a.test/");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Title")).toHaveValue("Page title")
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Edit title" }));
|
||||
const title = screen.getByLabelText("Title");
|
||||
await waitFor(() => expect(title).toHaveFocus());
|
||||
await user.clear(title);
|
||||
await user.type(title, "My title");
|
||||
// The suggestion can be had back.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Reset to the suggested title" }),
|
||||
);
|
||||
expect(title).toHaveValue("Page title");
|
||||
await user.click(screen.getByRole("button", { name: "Edit title" }));
|
||||
await user.clear(title);
|
||||
await user.type(title, "Mine after all");
|
||||
await user.click(nextBtn());
|
||||
await user.click(screen.getByRole("button", { name: "Dump it" }));
|
||||
await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1));
|
||||
expect(calls("POST /api/dumps")[0].body).toMatchObject({
|
||||
title: "Mine after all",
|
||||
});
|
||||
});
|
||||
|
||||
test("Enter in the link field goes to the next step rather than posting", async () => {
|
||||
previewRoutes();
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://a.test/{Enter}");
|
||||
expect(screen.getByLabelText("Why?")).toBeInTheDocument();
|
||||
expect(calls("POST /api/dumps")).toHaveLength(0);
|
||||
await user.click(screen.getByRole("button", { name: "← Back" }));
|
||||
expect(linkBox()).toHaveValue("https://a.test/");
|
||||
});
|
||||
|
||||
test("a failed post shows the server's message and stays put", async () => {
|
||||
previewRoutes();
|
||||
route("POST /api/dumps", () => fail("That URL isn't allowed", 400));
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://a.test/{Enter}");
|
||||
await user.click(screen.getByRole("button", { name: "Dump it" }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Failed to post");
|
||||
expect(alert).toHaveTextContent("That URL isn't allowed");
|
||||
expect(screen.getByRole("dialog", { name: "New dump" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("starts from a shared URL", async () => {
|
||||
previewRoutes();
|
||||
setup({ initialUrl: "https://shared.test/x" });
|
||||
expect(linkBox()).toHaveValue("https://shared.test/x");
|
||||
await screen.findByLabelText("Title");
|
||||
});
|
||||
|
||||
test("a link pasted anywhere in the modal lands in the link field", async () => {
|
||||
previewRoutes();
|
||||
setup();
|
||||
act(() => {
|
||||
const e = new Event("paste", { bubbles: true }) as ClipboardEvent;
|
||||
Object.defineProperty(e, "clipboardData", {
|
||||
value: { files: [], getData: () => "pasted.test/page" },
|
||||
});
|
||||
screen.getByRole("dialog").dispatchEvent(e);
|
||||
});
|
||||
expect(linkBox()).toHaveValue("https://pasted.test/page");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DumpCreateModal: drafts", () => {
|
||||
test("a dismissed draft comes back, and can be dropped", async () => {
|
||||
localStorage.setItem(
|
||||
"gerbeur.dumpDraft",
|
||||
JSON.stringify({
|
||||
url: "https://draft.test/",
|
||||
title: "",
|
||||
titleFromUser: false,
|
||||
comment: "half-written",
|
||||
isPublic: false,
|
||||
categoryIds: ["c1"],
|
||||
}),
|
||||
);
|
||||
previewRoutes();
|
||||
const { user } = setup();
|
||||
expect(screen.getByText("Picked up where you left off."))
|
||||
.toBeInTheDocument();
|
||||
expect(linkBox()).toHaveValue("https://draft.test/");
|
||||
await user.click(nextBtn());
|
||||
expect(screen.getByLabelText("Why?")).toHaveValue("half-written");
|
||||
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "← Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Start fresh" }));
|
||||
expect(linkBox()).toHaveValue("");
|
||||
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
|
||||
});
|
||||
|
||||
test("what's typed is kept as it's typed", async () => {
|
||||
previewRoutes();
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://keep.test/");
|
||||
expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe(
|
||||
"https://keep.test/",
|
||||
);
|
||||
});
|
||||
|
||||
test("a shared URL starts clean rather than merging an old draft", () => {
|
||||
localStorage.setItem(
|
||||
"gerbeur.dumpDraft",
|
||||
JSON.stringify({ url: "https://old.test/", comment: "old why" }),
|
||||
);
|
||||
previewRoutes();
|
||||
setup({ initialUrl: "https://new.test/" });
|
||||
expect(screen.queryByText("Picked up where you left off.")).not
|
||||
.toBeInTheDocument();
|
||||
expect(linkBox()).toHaveValue("https://new.test/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DumpCreateModal: a file", () => {
|
||||
class FakeXHR {
|
||||
static last: FakeXHR;
|
||||
status = 0;
|
||||
statusText = "";
|
||||
responseText = "";
|
||||
body: FormData | null = null;
|
||||
private l: Record<string, Array<(e: unknown) => void>> = {};
|
||||
private u: Record<string, Array<(e: unknown) => void>> = {};
|
||||
upload = {
|
||||
addEventListener: (t: string, f: (e: unknown) => void) =>
|
||||
(this.u[t] ??= []).push(f),
|
||||
};
|
||||
constructor() {
|
||||
FakeXHR.last = this;
|
||||
}
|
||||
open() {}
|
||||
setRequestHeader() {}
|
||||
addEventListener(t: string, f: (e: unknown) => void) {
|
||||
(this.l[t] ??= []).push(f);
|
||||
}
|
||||
send(body: FormData) {
|
||||
this.body = body;
|
||||
}
|
||||
progress(f: number) {
|
||||
this.u.progress?.forEach((fn) =>
|
||||
fn({ loaded: f * 100, total: 100, lengthComputable: true })
|
||||
);
|
||||
}
|
||||
respond(status: number, data: unknown) {
|
||||
this.status = status;
|
||||
this.responseText = JSON.stringify(data);
|
||||
this.l.load?.forEach((fn) => fn({}));
|
||||
}
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const song = () =>
|
||||
new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" });
|
||||
|
||||
test("a dropped file names the dump and uploads with progress", async () => {
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR);
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
const { user } = setup();
|
||||
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
|
||||
dataTransfer: { files: [song()] },
|
||||
});
|
||||
expect(screen.queryByPlaceholderText("Paste a link…")).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Title")).toHaveValue("my song-final");
|
||||
await user.click(nextBtn());
|
||||
await user.click(screen.getByRole("button", { name: "Dump it" }));
|
||||
act(() => FakeXHR.last.progress(0.42));
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"42",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Uploading 42%" }))
|
||||
.toBeDisabled();
|
||||
const fd = FakeXHR.last.body!;
|
||||
expect(fd.get("title")).toBe("my song-final");
|
||||
expect(fd.get("isPrivate")).toBe("false");
|
||||
expect(fd.get("categoryIds")).toBe("[]");
|
||||
await act(async () =>
|
||||
FakeXHR.last.respond(201, {
|
||||
success: true,
|
||||
data: wire(makeDump({ id: "f" })),
|
||||
})
|
||||
);
|
||||
await screen.findByRole("dialog", { name: "Add to playlist" });
|
||||
});
|
||||
|
||||
test("closing with a file attached asks first", async () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
const { user, onClose } = setup();
|
||||
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
|
||||
dataTransfer: { files: [song()] },
|
||||
});
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
const confirm = screen.getByText(/The attached file won't be kept/).closest(
|
||||
"[role=alert]",
|
||||
)!;
|
||||
await user.click(
|
||||
within(confirm as HTMLElement).getByRole("button", {
|
||||
name: "Keep editing",
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText(/The attached file won't be kept/)).not
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByRole("button", { name: "Close anyway" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("without a file, closing just closes", async () => {
|
||||
const { user, onClose } = setup();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("removing the file offers the link field again", async () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
const { user } = setup();
|
||||
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
|
||||
dataTransfer: { files: [song()] },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Remove file" }));
|
||||
expect(linkBox()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user