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:
258
src/test/components/comment/CommentThread.test.tsx
Normal file
258
src/test/components/comment/CommentThread.test.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { CommentThread } from "../../../components/comment/CommentThread.tsx";
|
||||
import type { AuthResponse, Comment } from "../../../model.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeComment, makeSession, wire } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const me = makeSession({ id: "me", username: "me" });
|
||||
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
|
||||
|
||||
function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
|
||||
const user = userEvent.setup();
|
||||
function Harness() {
|
||||
const [comments, setComments] = useState(initial);
|
||||
return (
|
||||
<CommentThread
|
||||
dumpId="d1"
|
||||
comments={comments}
|
||||
currentUser={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
|
||||
onCommentDeleted={(id) =>
|
||||
setComments((prev) =>
|
||||
prev.map((c) => c.id === id ? { ...c, deleted: true } : c)
|
||||
)}
|
||||
onCommentUpdated={(c) =>
|
||||
setComments((prev) => prev.map((x) => x.id === c.id ? c : x))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithContexts(<Harness />, { session, ws });
|
||||
return user;
|
||||
}
|
||||
|
||||
const created = (over: Partial<Comment>) => (req: { body: unknown }) =>
|
||||
wire(
|
||||
makeComment({
|
||||
dumpId: "d1",
|
||||
userId: "me",
|
||||
authorUsername: "me",
|
||||
body: (req.body as { body: string }).body,
|
||||
...over,
|
||||
}),
|
||||
);
|
||||
|
||||
describe("CommentThread", () => {
|
||||
test("counts visible comments and nests replies", () => {
|
||||
setup([
|
||||
makeComment({ id: "a", body: "top" }),
|
||||
makeComment({ id: "b", parentId: "a", body: "reply" }),
|
||||
makeComment({ id: "c", deleted: true, body: "" }),
|
||||
]);
|
||||
expect(screen.getByRole("heading", { name: "2 comments" }))
|
||||
.toBeInTheDocument();
|
||||
const top = screen.getByText("top").closest("li")!;
|
||||
expect(within(top).getByText("reply")).toBeInTheDocument();
|
||||
expect(screen.getByText("[deleted]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("guests read but can't write, reply or like", () => {
|
||||
setup([makeComment({ body: "hello" })], null);
|
||||
expect(screen.queryByPlaceholderText("Add a comment…")).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reply" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("posting a comment", async () => {
|
||||
route("POST /api/dumps/:id/comments", created({ id: "new" }));
|
||||
const user = setup([]);
|
||||
const box = screen.getByPlaceholderText("Add a comment…");
|
||||
expect(screen.getByRole("button", { name: "Post comment" })).toBeDisabled();
|
||||
await user.type(box, "First!");
|
||||
await user.click(screen.getByRole("button", { name: "Post comment" }));
|
||||
await screen.findByText("First!", { selector: ".comment-body *" });
|
||||
expect(box).toHaveValue("");
|
||||
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
|
||||
body: "First!",
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "1 comment" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Ctrl+Enter posts too; blank text doesn't", async () => {
|
||||
route("POST /api/dumps/:id/comments", created({}));
|
||||
const user = setup([]);
|
||||
const box = screen.getByPlaceholderText("Add a comment…");
|
||||
await user.type(box, " ");
|
||||
await user.keyboard("{Control>}{Enter}{/Control}");
|
||||
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0);
|
||||
await user.type(box, "ok");
|
||||
await user.keyboard("{Control>}{Enter}{/Control}");
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1)
|
||||
);
|
||||
});
|
||||
|
||||
test("a failed post keeps the text and says why", async () => {
|
||||
route("POST /api/dumps/:id/comments", () => fail("Comment too long", 400));
|
||||
const user = setup([]);
|
||||
const box = screen.getByPlaceholderText("Add a comment…");
|
||||
await user.type(box, "text");
|
||||
await user.click(screen.getByRole("button", { name: "Post comment" }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Failed to post comment");
|
||||
expect(alert).toHaveTextContent("Comment too long");
|
||||
expect(box).toHaveValue("text");
|
||||
});
|
||||
|
||||
test("the top-level Cancel only shows with text, and clears it", async () => {
|
||||
const user = setup([]);
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).not
|
||||
.toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText("Add a comment…"), "draft");
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("replying", async () => {
|
||||
route("POST /api/dumps/:id/comments", created({ id: "r", parentId: "a" }));
|
||||
const user = setup([makeComment({ id: "a", body: "parent" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Reply" }));
|
||||
const box = screen.getByPlaceholderText("Write a reply…");
|
||||
await waitFor(() => expect(box).toHaveFocus());
|
||||
await user.type(box, "child");
|
||||
await user.click(screen.getByRole("button", { name: "Post reply" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText("Write a reply…")).not
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
|
||||
body: "child",
|
||||
parentId: "a",
|
||||
});
|
||||
const parent = screen.getByText("parent").closest("li")!;
|
||||
expect(within(parent).getByText("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Escape backs out of a reply", async () => {
|
||||
const user = setup([makeComment({ id: "a" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Reply" }));
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Write a reply…"),
|
||||
"nah{Escape}",
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Write a reply…")).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("editing your own comment", async () => {
|
||||
route(
|
||||
"PATCH /api/comments/:id",
|
||||
(req) =>
|
||||
wire(
|
||||
makeComment({
|
||||
id: "a",
|
||||
userId: "me",
|
||||
authorUsername: "me",
|
||||
body: (req.body as { body: string }).body,
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = setup([
|
||||
makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" }),
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const box = screen.getByDisplayValue("old");
|
||||
await user.clear(box);
|
||||
await user.type(box, "new");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await screen.findByText("new");
|
||||
expect(screen.getByText(/^edited/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("saving an edit unchanged doesn't stamp it edited", async () => {
|
||||
const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Save" })).not
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("only the author or a moderator can edit and delete", () => {
|
||||
const comments = [
|
||||
makeComment({ id: "mine", userId: "me", body: "mine" }),
|
||||
makeComment({ id: "theirs", userId: "them", body: "theirs" }),
|
||||
];
|
||||
setup(comments);
|
||||
const theirs = screen.getByText("theirs").closest("li")!;
|
||||
expect(within(theirs).queryByRole("button", { name: "Edit" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(within(theirs).queryByRole("button", { name: "Delete" })).not
|
||||
.toBeInTheDocument();
|
||||
const mine = screen.getByText("mine").closest("li")!;
|
||||
expect(within(mine).getByRole("button", { name: "Delete" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a moderator can delete anyone's", () => {
|
||||
setup([makeComment({ userId: "them", body: "theirs" })], mod);
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("deleting asks first", async () => {
|
||||
route(
|
||||
"DELETE /api/comments/:id",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
expect(dialog).toHaveTextContent("Delete this comment?");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||
await screen.findByText("[deleted]");
|
||||
});
|
||||
|
||||
test("a failed delete says so", async () => {
|
||||
route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
|
||||
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
/couldn't be deleted/i,
|
||||
);
|
||||
expect(screen.getByText("stays")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("likes come from the live counts", async () => {
|
||||
const castCommentLike = vi.fn();
|
||||
const user = setup([makeComment({ id: "a", likeCount: 1 })], me, {
|
||||
likeCounts: { a: 5 },
|
||||
castCommentLike,
|
||||
});
|
||||
expect(screen.getByText("5")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Like" }));
|
||||
expect(castCommentLike).toHaveBeenCalledWith("a", 5);
|
||||
});
|
||||
|
||||
test("the timestamp links to the comment", () => {
|
||||
setup([makeComment({ id: "a" })]);
|
||||
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/d1#comment-a",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user