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:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -0,0 +1,467 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatModal } from "../../../components/chat/ChatModal.tsx";
import type { AuthResponse, ChatMessage } from "../../../model.ts";
import { calls, fail, route } from "../../fetch.ts";
import { makeChatMessage, makeSession, wire } from "../../fixtures.ts";
import { renderApp } from "../../render.tsx";
import { FakeWebSocket } from "../../ws.ts";
const me = makeSession({ id: "me", username: "me" });
async function setup(
history: ChatMessage[] = [],
{ session = me, welcome = {} as Record<string, unknown> } = {},
) {
route("GET /api/chat", () => wire(history));
const onClose = vi.fn();
const user = userEvent.setup();
renderApp(<ChatModal onClose={onClose} />, { session });
const socket = FakeWebSocket.last;
socket.welcome(welcome);
await screen.findByRole("dialog", { name: "Chat" });
await waitFor(() =>
expect(screen.queryByText("Loading messages…")).not.toBeInTheDocument()
);
const composer = screen.getByPlaceholderText("Type a message…");
return { user, socket, onClose, composer };
}
const sent = (socket: FakeWebSocket, type: string) =>
socket.messages.filter((m) => m.type === type);
describe("ChatModal: history", () => {
test("shows the latest messages under a day separator, grouping runs", async () => {
const t0 = Date.now() - 60_000;
await setup([
makeChatMessage({
id: "1",
userId: "ann",
authorUsername: "ann",
body: "one",
createdAt: new Date(t0),
}),
makeChatMessage({
id: "2",
userId: "ann",
authorUsername: "ann",
body: "two",
createdAt: new Date(t0 + 1000),
}),
makeChatMessage({
id: "3",
userId: "bob",
authorUsername: "bob",
body: "three",
createdAt: new Date(t0 + 2000),
}),
]);
expect(screen.getByText("Today")).toBeInTheDocument();
// "two" continues ann's run: no second author link.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
expect(screen.getByText("two").closest(".chat-message")).toHaveClass(
"chat-message--grouped",
);
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
});
test("empty and failed states", async () => {
route("GET /api/chat", () => fail("down", 500), { once: true });
route("GET /api/chat", () => []);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await screen.findByText("Couldn't load the chat.");
await user.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No messages yet. Say hello!");
});
test("a full page offers older messages", async () => {
const page = Array.from(
{ length: 50 },
(_, i) =>
makeChatMessage({
id: `m${i + 10}`,
body: `msg ${i + 10}`,
createdAt: new Date(Date.now() - (50 - i) * 1000),
}),
);
route(
"GET /api/chat",
(req) =>
req.url.searchParams.has("before")
? wire([
makeChatMessage({
id: "m0",
body: "the oldest",
createdAt: new Date(Date.now() - 999_000),
}),
])
: wire(page),
);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await user.click(
await screen.findByRole("button", { name: "Load older messages" }),
);
await screen.findByText("the oldest");
const req = calls("GET /api/chat").find((r) =>
r.url.searchParams.has("before")
)!;
expect(req.url.searchParams.get("beforeId")).toBe("m10");
expect(screen.queryByRole("button", { name: "Load older messages" })).not
.toBeInTheDocument();
});
test("the 'new' divider sits before the first unread message from someone else", async () => {
const readAt = new Date(Date.now() - 30_000);
await setup([
makeChatMessage({
id: "old",
body: "seen",
createdAt: new Date(Date.now() - 60_000),
}),
makeChatMessage({
id: "new1",
body: "unseen",
createdAt: new Date(Date.now() - 10_000),
}),
], { welcome: { chatReadAt: readAt.toISOString() } });
const divider = document.querySelector(".chat-new-sep")!;
expect(divider).toHaveTextContent("New");
expect(divider.nextElementSibling).toHaveTextContent("unseen");
});
test("reading at the bottom moves the read marker", async () => {
const { socket } = await setup([makeChatMessage({ body: "hi" })]);
await waitFor(() => expect(sent(socket, "chat_read")).toHaveLength(1));
});
test("tells the server the chat is open, and closed", async () => {
const { socket } = await setup();
expect(sent(socket, "chat_focus")).toContainEqual({
type: "chat_focus",
open: true,
});
});
});
describe("ChatModal: sending", () => {
test("Enter sends; the message shows as sending until its echo", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "hello there{Enter}");
expect(composer).toHaveValue("");
const [send] = sent(socket, "chat_send");
expect(send).toMatchObject({ body: "hello there" });
expect(screen.getByText("Sending…")).toBeInTheDocument();
socket.receive({
type: "chat_message",
nonce: send.nonce,
message: wire(
makeChatMessage({
id: "echo",
userId: "me",
authorUsername: "me",
body: "hello there",
createdAt: new Date(),
}),
),
});
expect(screen.queryByText("Sending…")).not.toBeInTheDocument();
expect(screen.getByText("hello there")).toBeInTheDocument();
});
test("Shift+Enter is a new line; blank and over-long messages don't send", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "line one{Shift>}{Enter}{/Shift}line two");
expect(composer).toHaveValue("line one\nline two");
await user.clear(composer);
await user.type(composer, " {Enter}");
expect(sent(socket, "chat_send")).toHaveLength(0);
await user.clear(composer);
await user.click(composer);
await user.paste("x".repeat(2001));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText("2001 / 2000")).toBeInTheDocument();
});
test("a failed send can be retried, taken back or dropped", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "oops{Enter}");
const [send] = sent(socket, "chat_send");
socket.receive({
type: "error",
scope: "chat",
nonce: send.nonce,
message: "Slow down",
});
const failed = screen.getByText("Slow down").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(failed).getByRole("button", { name: "Retry" }));
expect(sent(socket, "chat_send")).toHaveLength(2);
const [, retry] = sent(socket, "chat_send");
socket.receive({
type: "error",
scope: "chat",
nonce: retry.nonce,
message: "Still no",
});
await user.type(composer, "draft");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(composer).toHaveValue("draft\n\noops");
expect(screen.queryByText("Still no")).not.toBeInTheDocument();
});
test("typing is announced, and its end too", async () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "Date"],
shouldAdvanceTime: true,
});
try {
const { socket, composer } = await setup();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(composer, "abc");
expect(sent(socket, "chat_typing")).toEqual([{
type: "chat_typing",
typing: true,
}]);
act(() => vi.advanceTimersByTime(3_600));
expect(sent(socket, "chat_typing").at(-1)).toEqual({
type: "chat_typing",
typing: false,
});
} finally {
vi.useRealTimers();
}
});
test("the draft and reply outlive the chat", async () => {
const first = await setup([
makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" }),
]);
await first.user.type(first.composer, "half a thought");
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
expect(stored.draft).toBe("half a thought");
});
});
describe("ChatModal: replies, edits, deletes", () => {
const theirs = () =>
makeChatMessage({
id: "t1",
userId: "ann",
authorUsername: "ann",
body: "ask me **anything**",
createdAt: new Date(Date.now() - 20_000),
});
const mine = () =>
makeChatMessage({
id: "m1",
userId: "me",
authorUsername: "me",
body: "my words",
createdAt: new Date(Date.now() - 10_000),
});
test("replying: the bar, the reference, and Escape cancelling just the reply", async () => {
const { user, socket, composer, onClose } = await setup([theirs()]);
const msg = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(msg).getByRole("button", { name: "Reply" }));
expect(screen.getByText("Replying to")).toBeInTheDocument();
expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent(
"ask me anything",
);
await user.keyboard("{Escape}");
expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(msg).getByRole("button", { name: "Reply" }));
await user.type(composer, "sure{Enter}");
expect(sent(socket, "chat_send")[0]).toMatchObject({
body: "sure",
replyToId: "t1",
});
});
test("only your own messages can be edited and deleted", async () => {
await setup([theirs(), mine()]);
const t = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
expect(within(t).queryByRole("button", { name: "Edit" })).not
.toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
});
test("moderators can delete anyone's", async () => {
await setup([theirs()], {
session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse,
});
const t = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
expect(within(t).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
});
test("Up in an empty composer edits your last message; Enter saves", async () => {
const { user, socket, composer } = await setup([theirs(), mine()]);
await user.click(composer);
await user.keyboard("{ArrowUp}");
const box = await screen.findByDisplayValue("my words");
await user.clear(box);
await user.type(box, "better words{Enter}");
expect(sent(socket, "chat_edit")).toEqual([{
type: "chat_edit",
id: "m1",
body: "better words",
}]);
expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument();
});
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(sent(socket, "chat_edit")).toHaveLength(0);
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Save" })).not
.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
test("deleting asks first; Escape on the question keeps the chat open", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Delete" }));
expect(screen.getByRole("alertdialog")).toHaveTextContent(
"Delete this message?",
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(m).getByRole("button", { name: "Delete" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete",
}),
);
expect(sent(socket, "chat_delete")).toEqual([{
type: "chat_delete",
id: "m1",
}]);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(screen.queryByText("my words")).not.toBeInTheDocument();
});
test("a reply to a deleted message says so", async () => {
const { socket } = await setup([
theirs(),
makeChatMessage({
id: "r",
body: "a reply",
replyToId: "t1",
replyToAuthor: "ann",
replyToBody: "ask me",
}),
]);
socket.receive({ type: "chat_message_deleted", id: "t1" });
expect(screen.getByText("deleted message").closest("button"))
.toBeDisabled();
});
});
describe("ChatModal: live", () => {
test("new messages arrive, and a mention of me stands out", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_message",
message: wire(
makeChatMessage({
id: "x",
body: "hey @me look",
createdAt: new Date(),
}),
),
});
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass(
"chat-message--mention",
);
});
test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo"))
.toHaveTextContent("🎉🎉");
});
test("who's typing", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_typing_update",
users: [{ userId: "ann", username: "ann", hasAvatar: false }],
});
expect(screen.getByText("ann is typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [
{ userId: "ann", username: "ann", hasAvatar: false },
{ userId: "bob", username: "bob", hasAvatar: false },
],
});
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [{ userId: "me", username: "me", hasAvatar: false }],
});
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
});
test("a failed edit or delete is shown until dismissed", async () => {
const { user, socket } = await setup();
socket.receive({ type: "error", scope: "chat", message: "Not allowed" });
expect(screen.getByRole("alert")).toHaveTextContent("Not allowed");
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Not allowed")).not.toBeInTheDocument();
});
test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => {
const old = makeChatMessage({
id: "o",
body: "kept",
createdAt: new Date(Date.now() - 50_000),
});
const edited = makeChatMessage({
id: "e",
body: "before",
createdAt: new Date(Date.now() - 40_000),
});
const { socket } = await setup([old, edited]);
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
socket.drop();
expect(screen.getByText("Disconnected — reconnecting…"))
.toBeInTheDocument();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("after");
const resync = calls("GET /api/chat").at(-1)!;
expect(resync.url.searchParams.get("after")).toBe(
old.createdAt.toISOString(),
);
});
});