v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
304
src/test/components/ChatModal.test.tsx
Normal file
304
src/test/components/ChatModal.test.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
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/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());
|
||||
});
|
||||
});
|
||||
200
src/test/components/CommentThread.test.tsx
Normal file
200
src/test/components/CommentThread.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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/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");
|
||||
});
|
||||
});
|
||||
|
||||
336
src/test/components/DumpCreateModal.test.tsx
Normal file
336
src/test/components/DumpCreateModal.test.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
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/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();
|
||||
});
|
||||
});
|
||||
105
src/test/components/Modal.test.tsx
Normal file
105
src/test/components/Modal.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useState } from "react";
|
||||
import { Modal } from "../../components/Modal.tsx";
|
||||
import { ConfirmModal } from "../../components/ConfirmModal.tsx";
|
||||
|
||||
const wrap = (ui: React.ReactNode) =>
|
||||
render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
|
||||
|
||||
describe("Modal", () => {
|
||||
test("is a labelled dialog that locks the page's scroll", () => {
|
||||
wrap(<Modal title="Settings" onClose={() => {}}>body</Modal>);
|
||||
expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent(
|
||||
"body",
|
||||
);
|
||||
expect(document.documentElement.style.overflowY).toBe("hidden");
|
||||
});
|
||||
|
||||
test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(<Modal title="T" onClose={onClose}><button type="button">inside</button></Modal>);
|
||||
await user.click(screen.getByText("inside"));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
await user.click(document.querySelector(".modal-backdrop")!);
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("onBeforeClose can refuse", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>);
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an Escape something inside already handled is left alone", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(
|
||||
<Modal title="T" onClose={onClose}>
|
||||
<input onKeyDown={(e) => e.key === "Escape" && e.preventDefault()} />
|
||||
</Modal>,
|
||||
);
|
||||
await user.click(screen.getByRole("textbox"));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmModal", () => {
|
||||
test("asks, confirms and cancels", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(
|
||||
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />,
|
||||
);
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
expect(dialog).toHaveAccessibleDescription("Really?");
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(document.querySelector(".modal-backdrop")!);
|
||||
expect(onCancel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("takes a custom label", () => {
|
||||
wrap(
|
||||
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Escape on a confirm over a modal closes only the confirm", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
function Stacked() {
|
||||
const [confirming, setConfirming] = useState(true);
|
||||
return (
|
||||
<Modal title="Chat" onClose={onClose}>
|
||||
{confirming && (
|
||||
<ConfirmModal
|
||||
message="Delete this message?"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
wrap(<Stacked />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog", { name: "Chat" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
295
src/test/components/TextEditor.test.tsx
Normal file
295
src/test/components/TextEditor.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, useState } from "react";
|
||||
import { TextEditor, type TextEditorHandle } from "../../components/TextEditor.tsx";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { makeSession } from "../fixtures.ts";
|
||||
import { renderWithContexts } from "../render.tsx";
|
||||
import { serveEmojibase } from "../emojibase.ts";
|
||||
|
||||
type Props = Partial<React.ComponentProps<typeof TextEditor>>;
|
||||
|
||||
function setup(props: Props = {}, initial = "") {
|
||||
const onChange = vi.fn();
|
||||
function Harness() {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<TextEditor
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />, { session: makeSession() });
|
||||
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
return { user, box, onChange };
|
||||
}
|
||||
|
||||
function imageFile(name = "a.png", type = "image/png") {
|
||||
return new File([new Uint8Array([1, 2, 3])], name, { type });
|
||||
}
|
||||
|
||||
describe("TextEditor basics", () => {
|
||||
test("types through to onChange", async () => {
|
||||
const { user, box, onChange } = setup({ placeholder: "Say something" });
|
||||
expect(box).toHaveAttribute("placeholder", "Say something");
|
||||
await user.type(box, "hello");
|
||||
expect(onChange).toHaveBeenLastCalledWith("hello");
|
||||
expect(box).toHaveValue("hello");
|
||||
});
|
||||
|
||||
test("Ctrl/Cmd+Enter submits; plain Enter goes to onKeyDown", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
const { user, box } = setup({ onSubmit, onKeyDown });
|
||||
await user.type(box, "x");
|
||||
await user.keyboard("{Control>}{Enter}{/Control}");
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
await user.keyboard("{Meta>}{Enter}{/Meta}");
|
||||
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
|
||||
});
|
||||
|
||||
test("an Enter confirming an IME candidate does nothing else", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
const { box } = setup({ onSubmit, onKeyDown });
|
||||
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true });
|
||||
fireEvent.keyDown(box, { key: "Enter", keyCode: 229 });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(onKeyDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Escape cancels when there's nothing of its own to close", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const { user, box } = setup({ onCancel });
|
||||
await user.click(box);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the counter shows from 80% of the limit and flags going over", async () => {
|
||||
const { user, box } = setup({ maxLength: 10 }, "1234567");
|
||||
expect(screen.queryByText(/\/ 10/)).not.toBeInTheDocument();
|
||||
await user.type(box, "8");
|
||||
expect(screen.getByText("8 / 10")).toBeInTheDocument();
|
||||
await user.type(box, "901");
|
||||
expect(screen.getByText("11 / 10")).toHaveClass("text-editor-count--danger");
|
||||
expect(box).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
test("countFrom overrides when the counter appears", () => {
|
||||
setup({ maxLength: 100, countFrom: 0 });
|
||||
expect(screen.getByText("0 / 100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the handle focuses at the end", () => {
|
||||
const ref = createRef<TextEditorHandle>();
|
||||
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />);
|
||||
act(() => ref.current!.focus({ atEnd: true }));
|
||||
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(box).toHaveFocus();
|
||||
expect(box.selectionStart).toBe(3);
|
||||
});
|
||||
|
||||
test("disabled", () => {
|
||||
setup({ disabled: true });
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Insert emoji" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentions", () => {
|
||||
const users = [
|
||||
{ id: "1", username: "alice", avatarMime: null },
|
||||
{ id: "2", username: "alex", avatarMime: null },
|
||||
];
|
||||
|
||||
test("@ opens a listbox; Enter picks and adds a space", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "hi @al");
|
||||
const list = await screen.findByRole("listbox");
|
||||
expect(within(list).getAllByRole("option")).toHaveLength(2);
|
||||
expect(box).toHaveAttribute("aria-activedescendant");
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(box).toHaveValue("hi @alex ");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking a suggestion picks it", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "@ali");
|
||||
await user.click(await screen.findByRole("option", { name: /alice/ }));
|
||||
expect(box).toHaveValue("@alice ");
|
||||
});
|
||||
|
||||
test("Escape closes the list, and only a second Escape cancels", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
const onCancel = vi.fn();
|
||||
const { user, box } = setup({ onCancel });
|
||||
await user.type(box, "@al");
|
||||
await screen.findByRole("listbox");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Enter with the list open picks rather than submitting", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
const onKeyDown = vi.fn();
|
||||
const { user, box } = setup({ onKeyDown });
|
||||
await user.type(box, "@al");
|
||||
await screen.findByRole("listbox");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
|
||||
expect(box).toHaveValue("@alice ");
|
||||
});
|
||||
|
||||
test("leaving the field closes the list", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "@al");
|
||||
await screen.findByRole("listbox");
|
||||
fireEvent.blur(box);
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("image upload", () => {
|
||||
test("a pasted image is uploaded and inserted as markdown", async () => {
|
||||
route("POST /api/attachments", () => ({ id: "att1" }));
|
||||
const { user, box } = setup({}, "look: ");
|
||||
await user.click(box);
|
||||
box.setSelectionRange(6, 6);
|
||||
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
|
||||
await waitFor(() =>
|
||||
expect(box).toHaveValue("look: ")
|
||||
);
|
||||
const [req] = calls("POST /api/attachments");
|
||||
expect(req.body).toBeInstanceOf(FormData);
|
||||
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
});
|
||||
|
||||
test("a paste carrying text pastes the text", () => {
|
||||
const { box } = setup();
|
||||
const event = fireEvent.paste(box, {
|
||||
clipboardData: { types: ["text/plain", "Files"], files: [imageFile()] },
|
||||
});
|
||||
expect(event).toBe(true); // not prevented
|
||||
expect(calls("POST /api/attachments")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("while uploading: read-only, says so, and holds Enter", async () => {
|
||||
const pending = deferred();
|
||||
route("POST /api/attachments", () => pending.promise);
|
||||
const onKeyDown = vi.fn();
|
||||
const { box } = setup({ onKeyDown });
|
||||
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…");
|
||||
expect(box).toHaveAttribute("readonly");
|
||||
expect(box).toHaveAttribute("aria-busy", "true");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(onKeyDown).not.toHaveBeenCalled();
|
||||
await act(async () =>
|
||||
pending.resolve(new Response(JSON.stringify({ success: true, data: { id: "z" } })))
|
||||
);
|
||||
await waitFor(() => expect(box).not.toHaveAttribute("readonly"));
|
||||
});
|
||||
|
||||
test("a failed upload is reported", async () => {
|
||||
route("POST /api/attachments", () => fail("Too big", 413));
|
||||
const { box } = setup();
|
||||
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded.");
|
||||
expect(box).toHaveValue("");
|
||||
expect(box).toHaveAttribute("aria-describedby");
|
||||
});
|
||||
|
||||
test("dropping a non-image is caught and explained", () => {
|
||||
const { box } = setup();
|
||||
const dataTransfer = { types: ["Files"], files: [imageFile("a.pdf", "application/pdf")], dropEffect: "" };
|
||||
const over = fireEvent.dragOver(box, { dataTransfer });
|
||||
expect(over).toBe(false); // prevented: the browser mustn't open the file
|
||||
fireEvent.drop(box, { dataTransfer });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Only images can be dropped here.");
|
||||
expect(calls("POST /api/attachments")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("dropped images upload; dragged text is left to the browser", async () => {
|
||||
route("POST /api/attachments", (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` }));
|
||||
const { box } = setup();
|
||||
const textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } });
|
||||
expect(textDrag).toBe(true);
|
||||
fireEvent.drop(box, {
|
||||
dataTransfer: { types: ["Files"], files: [imageFile("a.png"), imageFile("b.png")] },
|
||||
});
|
||||
await waitFor(() => expect(box.value).toContain("attachments/n1"));
|
||||
expect(box.value.split("\n")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emoji", () => {
|
||||
test("':' and two letters opens the picker with that query; Escape closes only it", async () => {
|
||||
serveEmojibase();
|
||||
const onCancel = vi.fn();
|
||||
const { user, box } = setup({ onCancel });
|
||||
await user.type(box, "hi :astro");
|
||||
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
|
||||
expect(search).toHaveValue("astro");
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(box).toHaveValue("hi :astro");
|
||||
});
|
||||
|
||||
test("Enter takes the best match and replaces the :query", async () => {
|
||||
serveEmojibase();
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "go :astronaut");
|
||||
await screen.findByRole("gridcell", { name: "Astronaut" });
|
||||
await user.keyboard("{Enter}");
|
||||
expect(box).toHaveValue("go 🧑🚀 ");
|
||||
});
|
||||
|
||||
test("emoticons and times don't open it", async () => {
|
||||
serveEmojibase();
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "ok :D at 12:30");
|
||||
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the button browses and inserts at the caret", async () => {
|
||||
serveEmojibase();
|
||||
const { user, box } = setup({}, "ab");
|
||||
await user.click(box);
|
||||
box.setSelectionRange(1, 1);
|
||||
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
|
||||
expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true");
|
||||
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
|
||||
await waitFor(() => expect(search).toHaveFocus());
|
||||
await user.type(search, "astronaut");
|
||||
await user.click(await screen.findByRole("gridcell", { name: "Astronaut" }));
|
||||
expect(box).toHaveValue("a🧑🚀b");
|
||||
});
|
||||
|
||||
test("a press outside puts the picker away", async () => {
|
||||
serveEmojibase();
|
||||
const { user } = setup();
|
||||
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
|
||||
await screen.findByRole("searchbox", { name: "Search emoji" });
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
292
src/test/components/buttons.test.tsx
Normal file
292
src/test/components/buttons.test.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRef } from "react";
|
||||
import { Avatar } from "../../components/Avatar.tsx";
|
||||
import { VoteButton } from "../../components/VoteButton.tsx";
|
||||
import { LikeButton } from "../../components/LikeButton.tsx";
|
||||
import { FollowPlaylistButton, FollowUserButton } from "../../components/FollowButton.tsx";
|
||||
import { UserListPopover } from "../../components/UserListPopover.tsx";
|
||||
import { NotificationBell } from "../../components/NotificationBell.tsx";
|
||||
import { ChatButton } from "../../components/ChatButton.tsx";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
import { ChatContext } from "../../contexts/ChatContext.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
|
||||
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
|
||||
import { WSContext } from "../../contexts/WSContext.ts";
|
||||
|
||||
describe("Avatar", () => {
|
||||
test("an image when the user has one, versioned", () => {
|
||||
renderWithContexts(<Avatar userId="u1" username="ann" hasAvatar version={7} />);
|
||||
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/u1?v=7",
|
||||
);
|
||||
});
|
||||
|
||||
test("initials otherwise, and after the image fails", () => {
|
||||
renderWithContexts(<Avatar userId="u1" username="bob" hasAvatar />);
|
||||
fireEvent.error(screen.getByRole("img"));
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("bob")).toHaveTextContent("B");
|
||||
});
|
||||
|
||||
test("a live override wins over stale props, and retries a failed image", () => {
|
||||
const { rerender } = renderWithContexts(
|
||||
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: false, version: 1 } }}>
|
||||
<Avatar userId="u1" username="cy" hasAvatar />
|
||||
</AvatarOverrideContext.Provider>,
|
||||
);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: true, version: 2 } }}>
|
||||
<Avatar userId="u1" username="cy" hasAvatar={false} />
|
||||
</AvatarOverrideContext.Provider>,
|
||||
);
|
||||
// rerender replaces the tree under the same router wrapper
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("?v=2"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoteButton / LikeButton", () => {
|
||||
test("upvote and remove, passing the count on screen", async () => {
|
||||
const onCast = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithContexts(
|
||||
<VoteButton dumpId="d" count={0} voted={false} onCast={onCast} onRemove={onRemove} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Upvote" }));
|
||||
expect(onCast).toHaveBeenCalledWith("d", 0);
|
||||
rerender(<VoteButton dumpId="d" count={3} voted onCast={onCast} onRemove={onRemove} />);
|
||||
await user.click(screen.getByRole("button", { name: "Remove vote" }));
|
||||
expect(onRemove).toHaveBeenCalledWith("d", 3);
|
||||
});
|
||||
|
||||
test("disabled for guests, with a hint", () => {
|
||||
renderWithContexts(
|
||||
<VoteButton dumpId="d" count={1} voted={false} disabled onCast={() => {}} onRemove={() => {}} />,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Upvote" });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute("title", "Log in to vote");
|
||||
});
|
||||
|
||||
test("the count opens who voted, without voting", async () => {
|
||||
const onCast = vi.fn();
|
||||
route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })]));
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<VoteButton dumpId="d1" count={1} voted={false} onCast={onCast} onRemove={() => {}} />,
|
||||
);
|
||||
await user.click(screen.getByText("1"));
|
||||
expect(onCast).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/fan",
|
||||
);
|
||||
});
|
||||
|
||||
test("likes work the same way", async () => {
|
||||
const onLike = vi.fn();
|
||||
const onUnlike = vi.fn();
|
||||
route("GET /api/comments/:id/likers", () => page([]));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithContexts(
|
||||
<LikeButton commentId="c" count={2} liked={false} onLike={onLike} onUnlike={onUnlike} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Like" }));
|
||||
expect(onLike).toHaveBeenCalledWith("c", 2);
|
||||
await user.click(screen.getByText("2"));
|
||||
expect(await screen.findByText("No one yet.")).toBeInTheDocument();
|
||||
rerender(<LikeButton commentId="c" count={2} liked onLike={onLike} onUnlike={onUnlike} />);
|
||||
await user.click(screen.getByRole("button", { name: "Remove like" }));
|
||||
expect(onUnlike).toHaveBeenCalledWith("c", 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserListPopover", () => {
|
||||
function Harness({ onClose = () => {} }: { onClose?: () => void }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<>
|
||||
<span ref={ref}>anchor</span>
|
||||
<button type="button">outside</button>
|
||||
<UserListPopover anchorRef={ref} onClose={onClose} fetchUrl="http://localhost:8000/api/x/voters" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test("pages through the list", async () => {
|
||||
route("GET /api/x/voters", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "a" })], true)
|
||||
: page([makeUser({ username: "b" })], false));
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />, { session: makeSession() });
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
await screen.findByText("a");
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("b");
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
|
||||
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
});
|
||||
|
||||
test("shows the server's error, and a network one", async () => {
|
||||
route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true });
|
||||
const first = renderWithContexts(<Harness />);
|
||||
await screen.findByText("Dump not found");
|
||||
first.unmount();
|
||||
route("GET /api/x/voters", () => new TypeError("offline"));
|
||||
renderWithContexts(<Harness />);
|
||||
await screen.findByText("Could not load.");
|
||||
});
|
||||
|
||||
test("a failed next page lets you try again", async () => {
|
||||
route("GET /api/x/voters", () => page([makeUser({ username: "a" })], true), { once: true });
|
||||
route("GET /api/x/voters", () => new TypeError("offline"), { once: true });
|
||||
route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false));
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />);
|
||||
await screen.findByText("a");
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
const retry = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(retry).toBeEnabled();
|
||||
await user.click(retry);
|
||||
await screen.findByText("b");
|
||||
});
|
||||
|
||||
test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
|
||||
route("GET /api/x/voters", () => page([]));
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness onClose={onClose} />);
|
||||
await screen.findByText("No one yet.");
|
||||
await user.click(screen.getByText("anchor"));
|
||||
await user.click(screen.getByText("No one yet."));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByText("outside"));
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowButton", () => {
|
||||
const me = makeSession({ id: "me" });
|
||||
|
||||
test("hidden for guests and on yourself", () => {
|
||||
const { unmount } = renderWithContexts(
|
||||
<FollowUserButton targetUserId="x" targetUsername="x" />,
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me });
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("follow and unfollow a user", async () => {
|
||||
const followUser = vi.fn();
|
||||
const unfollowUser = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="u" targetUsername="ann" />,
|
||||
{ session: me, follows: { followUser, unfollowUser } },
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Follow ann" });
|
||||
expect(btn).toHaveTextContent("Follow");
|
||||
await user.click(btn);
|
||||
expect(followUser).toHaveBeenCalledWith("u");
|
||||
expect(unfollowUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows Following, and unfollows", async () => {
|
||||
const unfollowUser = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
|
||||
session: me,
|
||||
follows: { followedUserIds: new Set(["u"]), unfollowUser },
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: "Unfollow ann" });
|
||||
expect(btn).toHaveTextContent("Following");
|
||||
await user.click(btn);
|
||||
expect(unfollowUser).toHaveBeenCalledWith("u");
|
||||
});
|
||||
|
||||
test("disabled until follows have loaded", () => {
|
||||
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
|
||||
session: me,
|
||||
follows: { isLoaded: false },
|
||||
});
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("playlists: only public ones can be followed", async () => {
|
||||
const followPlaylist = vi.fn();
|
||||
const unfollowPlaylist = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const priv = renderWithContexts(
|
||||
<FollowPlaylistButton targetPlaylistId="p" isPublic={false} />,
|
||||
{ session: me },
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
priv.unmount();
|
||||
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
|
||||
session: me,
|
||||
follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
|
||||
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationBell / ChatButton", () => {
|
||||
test("the bell shows the unread count and goes to notifications", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<NotificationBell />, { ws: { unreadNotificationCount: 120 } });
|
||||
const bell = screen.getByRole("button", { name: "Notifications (120 unread)" });
|
||||
expect(bell).toHaveTextContent("99+");
|
||||
await user.click(bell);
|
||||
expect(currentPath()).toBe("/notifications");
|
||||
});
|
||||
|
||||
test("the bell rings for a new notification, not for one already there", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const n1 = makeNotification({ id: "n1" });
|
||||
const withLast = (n: ReturnType<typeof makeNotification>) => (
|
||||
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}>
|
||||
<NotificationBell />
|
||||
</WSContext.Provider>
|
||||
);
|
||||
const { rerender } = renderWithContexts(withLast(n1));
|
||||
const bell = () => screen.getByRole("button", { name: "Notifications" });
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||
rerender(withLast(makeNotification({ id: "n2" })));
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(bell()).toHaveClass("notification-bell--ringing");
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the chat button opens the chat and shows unread", async () => {
|
||||
const openChat = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
|
||||
<ChatButton />
|
||||
</ChatContext.Provider>,
|
||||
{ ws: { unreadChatCount: 4 } },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
|
||||
expect(openChat).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
307
src/test/components/content.test.tsx
Normal file
307
src/test/components/content.test.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Markdown } from "../../components/Markdown.tsx";
|
||||
import Thumbnail from "../../components/Thumbnail.tsx";
|
||||
import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx";
|
||||
import RichContentCard from "../../components/RichContentCard.tsx";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { hueFromString } from "../../utils/thumbnailTint.ts";
|
||||
import { markDumpVisited } from "../../utils/visited.ts";
|
||||
import { makeCategory, makeDump, makeSession } from "../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
|
||||
describe("Markdown", () => {
|
||||
const md = (text: string, inline = false) =>
|
||||
renderWithContexts(<Markdown inline={inline}>{text}</Markdown>);
|
||||
|
||||
test("renders GFM", () => {
|
||||
md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done");
|
||||
expect(screen.getByText("bold").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("gone").tagName).toBe("DEL");
|
||||
expect(screen.getByRole("table").parentElement).toHaveClass("md-table-wrap");
|
||||
expect(screen.getByRole("checkbox")).toBeChecked();
|
||||
});
|
||||
|
||||
test("@mentions link to the profile, in-app", async () => {
|
||||
md("hi @ann and bob@mail.com");
|
||||
const link = screen.getByRole("link", { name: "@ann" });
|
||||
expect(link).toHaveAttribute("href", "/users/ann");
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
// GFM autolinks the address itself; it isn't a mention of "mail".
|
||||
expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:bob@mail.com",
|
||||
);
|
||||
expect(document.querySelector('a[href="/users/mail"]')).toBeNull();
|
||||
await userEvent.click(link);
|
||||
expect(currentPath()).toBe("/users/ann");
|
||||
});
|
||||
|
||||
test("mentions in code and inside URLs are left alone", () => {
|
||||
md("`@ann` and https://mastodon.social/@bob");
|
||||
expect(screen.getByText("@ann").tagName).toBe("CODE");
|
||||
expect(screen.getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"https://mastodon.social/@bob",
|
||||
);
|
||||
});
|
||||
|
||||
test("external links open in a new tab; internal and same-origin ones don't", () => {
|
||||
md(
|
||||
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute("target");
|
||||
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute("href", "/playlists/p?x=1#y");
|
||||
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("a single newline is a line break", () => {
|
||||
const { container } = md("one\ntwo\nthree");
|
||||
expect(container.querySelectorAll("br")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("code blocks keep their newlines", () => {
|
||||
const { container } = md("```\na\nb\n```");
|
||||
expect(container.querySelector("pre")!.textContent).toBe("a\nb\n");
|
||||
expect(container.querySelectorAll("br")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("images load lazily; inline teasers drop them", () => {
|
||||
const full = md("");
|
||||
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy");
|
||||
full.unmount();
|
||||
md(" text", true);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("raw HTML is not rendered", () => {
|
||||
const { container } = md("<script>alert(1)</script><b>x</b>");
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector("b")).toBeNull();
|
||||
});
|
||||
|
||||
test("javascript: links are neutralised", () => {
|
||||
md("[click](javascript:alert(1))");
|
||||
const link = screen.getByText("click").closest("a");
|
||||
expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Thumbnail", () => {
|
||||
const placeholder = { url: "https://site.test/page", siteName: "Site" };
|
||||
|
||||
test("direct, then proxied, then the placeholder", () => {
|
||||
renderWithContexts(
|
||||
<Thumbnail src="https://img.test/a.png" alt="art" placeholder={placeholder} />,
|
||||
);
|
||||
const img = screen.getByRole("img", { name: "art" });
|
||||
expect(img).toHaveAttribute("src", "https://img.test/a.png");
|
||||
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain(
|
||||
"/api/proxy-image?url=",
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "art" }));
|
||||
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("our own images skip the proxy retry", () => {
|
||||
renderWithContexts(
|
||||
<Thumbnail src="http://localhost:8000/api/thumbnails/d" alt="t" placeholder={placeholder} />,
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "t" }));
|
||||
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("no src: straight to the placeholder; a new src tries again", () => {
|
||||
const { rerender } = renderWithContexts(<Thumbnail placeholder={placeholder} alt="t" />);
|
||||
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
|
||||
rerender(<Thumbnail src="https://img.test/b.png" placeholder={placeholder} alt="t" />);
|
||||
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThumbnailPlaceholder", () => {
|
||||
test("tints by the page colour, or by hostname", () => {
|
||||
const { container, rerender } = renderWithContexts(
|
||||
<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="#123456" />,
|
||||
);
|
||||
const box = () => container.querySelector<HTMLElement>(".thumb-placeholder")!;
|
||||
expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456");
|
||||
rerender(<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="url(evil)" />);
|
||||
expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test"));
|
||||
});
|
||||
|
||||
test("favicon, then the initial when it fails; a glyph wins over the initial", () => {
|
||||
const { container, rerender } = renderWithContexts(
|
||||
<ThumbnailPlaceholder url="https://site.test" faviconUrl="https://site.test/f.ico" />,
|
||||
);
|
||||
const icon = container.querySelector("img")!;
|
||||
fireEvent.error(icon);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
rerender(<ThumbnailPlaceholder seed="clip.mp4" glyph="🎬" />);
|
||||
expect(screen.getByText("🎬")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
430
src/test/components/header.test.tsx
Normal file
430
src/test/components/header.test.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { SearchBar } from "../../components/SearchBar.tsx";
|
||||
import { UserMenu } from "../../components/UserMenu.tsx";
|
||||
import { PresenceRow } from "../../components/PresenceRow.tsx";
|
||||
import { CategorySwitcher } from "../../components/CategorySwitcher.tsx";
|
||||
import { CategorySelect } from "../../components/CategorySelect.tsx";
|
||||
import { TabBar } from "../../components/TabBar.tsx";
|
||||
import { FeedTabBar } from "../../components/FeedTabBar.tsx";
|
||||
import { AppHeader } from "../../components/AppHeader.tsx";
|
||||
import { ChatFab } from "../../components/ChatFab.tsx";
|
||||
import { DumpFab } from "../../components/DumpFab.tsx";
|
||||
import { Tooltip } from "../../components/Tooltip.tsx";
|
||||
import { CountedInput } from "../../components/CountedInput.tsx";
|
||||
import { PageError } from "../../components/PageError.tsx";
|
||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||
import { ChatContext } from "../../contexts/ChatContext.ts";
|
||||
import { WSContext } from "../../contexts/WSContext.ts";
|
||||
import type { AuthResponse, Category } from "../../model.ts";
|
||||
import { makeCategory, makeSession, makeUser } from "../fixtures.ts";
|
||||
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
|
||||
];
|
||||
|
||||
describe("SearchBar", () => {
|
||||
function Harness(props: Partial<Parameters<typeof SearchBar>[0]>) {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<SearchBar
|
||||
expanded
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test("submits the trimmed query; ignores a blank one", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness onSubmit={onSubmit} />);
|
||||
const input = screen.getByRole("searchbox", { name: "Search" });
|
||||
await user.type(input, " {Enter}");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await user.type(input, " cats {Enter}");
|
||||
expect(onSubmit).toHaveBeenCalledWith("cats");
|
||||
expect(input).not.toHaveFocus();
|
||||
});
|
||||
|
||||
test("Escape cancels instead of clearing", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness onCancel={onCancel} />);
|
||||
await user.type(screen.getByRole("searchbox"), "dogs{Escape}");
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(screen.getByRole("searchbox")).toHaveValue("dogs");
|
||||
});
|
||||
|
||||
test("takes focus when it opens, not when it mounts open", () => {
|
||||
const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} };
|
||||
const { rerender } = renderWithContexts(<SearchBar expanded {...props} />);
|
||||
expect(screen.getByRole("searchbox")).not.toHaveFocus();
|
||||
rerender(<SearchBar expanded={false} {...props} />);
|
||||
expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1");
|
||||
rerender(<SearchBar expanded {...props} />);
|
||||
expect(screen.getByRole("searchbox")).toHaveFocus();
|
||||
rerender(<SearchBar expanded={false} {...props} />);
|
||||
expect(screen.getByRole("searchbox")).not.toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserMenu", () => {
|
||||
test("opens, links to profile and playlists, closes on Escape and outside", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<>
|
||||
<UserMenu user={makeUser({ username: "ann" })} />
|
||||
<p>outside</p>
|
||||
</>,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: "User menu" });
|
||||
await user.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
|
||||
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann/playlists",
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("outside"));
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("menuitem", { name: "@ann" }));
|
||||
expect(currentPath()).toBe("/users/ann");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PresenceRow", () => {
|
||||
const online = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
userId: `u${i}`,
|
||||
username: `user${i}`,
|
||||
hasAvatar: false,
|
||||
}));
|
||||
|
||||
test("nothing when nobody's online", () => {
|
||||
renderWithContexts(<PresenceRow />);
|
||||
expect(document.querySelector(".index-presence")).toBeNull();
|
||||
});
|
||||
|
||||
test("up to six seats, the rest folded into a counter", () => {
|
||||
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(9) } });
|
||||
expect(screen.getAllByRole("link")).toHaveLength(5);
|
||||
const more = screen.getByRole("img", { name: /4 more online/ });
|
||||
expect(more).toHaveTextContent("+4");
|
||||
expect(more).toHaveAttribute("title", "user5, user6, user7, user8");
|
||||
});
|
||||
|
||||
test("six or fewer all show", () => {
|
||||
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(6) } });
|
||||
expect(screen.getAllByRole("link")).toHaveLength(6);
|
||||
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
|
||||
renderWithContexts(
|
||||
<div className="bar">
|
||||
<div className="tabs"><span>a</span></div>
|
||||
<PresenceRow fitWithin=".bar" fitBeside=".tabs" />
|
||||
</div>,
|
||||
{ ws: { onlineUsers: online(9) } },
|
||||
);
|
||||
expect(screen.getAllByRole("link")).toHaveLength(3);
|
||||
width.mockReturnValue(0);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
width.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
test("hidden with no categories", () => {
|
||||
renderWithContexts(<CategorySwitcher />, { route: "/~/hot", path: "/:x/:feedTab" });
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches scope, keeping the tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
categories,
|
||||
route: "/music/new",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: "Music" });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true");
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("All is the ~ scope; an unknown slug shows as itself", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
categories,
|
||||
route: "/gone/hot",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/~/hot");
|
||||
});
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, { categories, route: "/~/hot", path: "/~/:feedTab" });
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CategorySelect", () => {
|
||||
test("toggle chips reflect the selection", async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />, {
|
||||
categories,
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
expect(onToggle).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
test("nothing without categories", () => {
|
||||
renderWithContexts(<CategorySelect selected={new Set()} onToggle={() => {}} />);
|
||||
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabBar / FeedTabBar", () => {
|
||||
test("TabBar marks the active tab and reports clicks", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<TabBar
|
||||
tabs={[{ key: "a", label: "A" }, { key: "b", label: "B" }]}
|
||||
activeTab="b"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "B" })).toHaveClass("active");
|
||||
await user.click(screen.getByRole("button", { name: "A" }));
|
||||
expect(onChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
test("guests don't get Followed; tabs navigate within the scope", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar />, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories });
|
||||
expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
|
||||
await user.click(screen.getByRole("button", { name: "Journal" }));
|
||||
expect(currentPath()).toBe("/music/journal");
|
||||
});
|
||||
|
||||
test("signed in, Followed is there", () => {
|
||||
renderWithContexts(<FeedTabBar />, {
|
||||
session: makeSession(),
|
||||
route: "/~/followed",
|
||||
path: "/~/:feedTab",
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppHeader", () => {
|
||||
test("guests get a login button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<AppHeader />);
|
||||
await user.click(screen.getByRole("button", { name: "Log in" }));
|
||||
expect(currentPath()).toBe("/login");
|
||||
});
|
||||
|
||||
test("members get their links, chat, bell and the new-dump button", () => {
|
||||
renderWithContexts(<AppHeader disableNew />, { session: makeSession({ username: "ann" }) });
|
||||
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann");
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument();
|
||||
const dump = screen.getByRole("button", { name: /Dump/ });
|
||||
expect(dump).toBeDisabled();
|
||||
expect(dump).toHaveAttribute("title", "Server unreachable");
|
||||
});
|
||||
|
||||
test("shows the live-updates banner when disconnected", () => {
|
||||
renderWithContexts(<AppHeader />, {
|
||||
ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" },
|
||||
});
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Live updates unavailable. Trying to reconnect…",
|
||||
);
|
||||
});
|
||||
|
||||
test("the search toggle opens and closes, giving focus back", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<AppHeader />);
|
||||
const toggle = screen.getByRole("button", { name: "Search" });
|
||||
await user.click(toggle);
|
||||
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
const [input] = screen.getAllByRole("searchbox");
|
||||
await user.type(input, "x");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByRole("button", { name: "Search" })).toHaveFocus();
|
||||
expect(screen.getAllByRole("searchbox")[0]).toHaveValue("");
|
||||
});
|
||||
|
||||
// Leaving search needs the real history stack (React Router keeps its
|
||||
// index in history.state), so these use a BrowserRouter.
|
||||
function Browser({ session }: { session?: AuthResponse }) {
|
||||
const [authResponse, setAuthResponse] = useState(session ?? null);
|
||||
function Loc() {
|
||||
const l = useLocation();
|
||||
return <output data-testid="loc">{l.pathname + l.search}</output>;
|
||||
}
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
<WSContext.Provider value={defaultOf(WSContext)}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<AppHeader />} />
|
||||
</Routes>
|
||||
<Loc />
|
||||
</BrowserRouter>
|
||||
</WSContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
const loc = () => screen.getByTestId("loc").textContent;
|
||||
afterEach(() => window.history.replaceState(null, "", "/"));
|
||||
|
||||
test("several searches, then ✕, return to where search began", async () => {
|
||||
window.history.replaceState(null, "", "/start");
|
||||
const user = userEvent.setup();
|
||||
render(<Browser />);
|
||||
await user.click(screen.getByRole("button", { name: "Search" }));
|
||||
await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}");
|
||||
expect(loc()).toBe("/search?q=cats");
|
||||
const bar = () => screen.getAllByRole("searchbox")[0];
|
||||
expect(bar()).toHaveValue("cats");
|
||||
await user.clear(bar());
|
||||
await user.type(bar(), "dogs{Enter}");
|
||||
expect(loc()).toBe("/search?q=dogs");
|
||||
await user.click(screen.getByRole("button", { name: "Cancel search" }));
|
||||
await waitFor(() => expect(loc()).toBe("/start"));
|
||||
});
|
||||
|
||||
test("Escape on the results page reverts the query instead of leaving", async () => {
|
||||
window.history.replaceState(null, "", "/search?q=cats");
|
||||
const user = userEvent.setup();
|
||||
render(<Browser />);
|
||||
const bar = screen.getAllByRole("searchbox")[0];
|
||||
expect(bar).toHaveValue("cats");
|
||||
await user.type(bar, "zzz{Escape}");
|
||||
expect(bar).toHaveValue("cats");
|
||||
expect(loc()).toBe("/search?q=cats");
|
||||
});
|
||||
|
||||
test("✕ on results that opened the session goes home", async () => {
|
||||
window.history.replaceState(null, "", "/search?q=cats");
|
||||
const user = userEvent.setup();
|
||||
render(<Browser />);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel search" }));
|
||||
expect(loc()).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("floating buttons", () => {
|
||||
function withChat(openChat = vi.fn()) {
|
||||
return (ui: React.ReactNode) => (
|
||||
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
|
||||
{ui}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
test("guests get neither", () => {
|
||||
renderWithContexts(withChat()(<><ChatFab /><DumpFab /></>));
|
||||
expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("they show once the header scrolls away", () => {
|
||||
const openChat = vi.fn();
|
||||
renderWithContexts(
|
||||
withChat(openChat)(
|
||||
<>
|
||||
<header className="app-header" />
|
||||
<ChatFab />
|
||||
<DumpFab />
|
||||
</>,
|
||||
),
|
||||
{ session: makeSession(), ws: { unreadChatCount: 2 } },
|
||||
);
|
||||
const header = document.querySelector(".app-header")!;
|
||||
// jsdom lays nothing out: put the header on screen first.
|
||||
header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect;
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
const chat = document.querySelector<HTMLButtonElement>(".chat-fab")!;
|
||||
expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)");
|
||||
expect(chat).toHaveAttribute("aria-hidden", "true");
|
||||
expect(chat).toHaveAttribute("tabindex", "-1");
|
||||
expect(document.body).toHaveClass("has-fab");
|
||||
|
||||
header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect;
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(chat).toHaveAttribute("aria-hidden", "false");
|
||||
expect(chat).toHaveClass("chat-fab--visible");
|
||||
act(() => chat.click());
|
||||
expect(openChat).toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("small pieces", () => {
|
||||
test("Tooltip shows on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Tooltip text="Explained"><span>hover me</span></Tooltip>);
|
||||
await user.hover(screen.getByText("hover me"));
|
||||
expect(screen.getByText("Explained")).toBeInTheDocument();
|
||||
await user.unhover(screen.getByText("hover me"));
|
||||
expect(screen.queryByText("Explained")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("CountedInput counts toward its limit", () => {
|
||||
renderWithContexts(<CountedInput value={"x".repeat(90)} maxLength={100} onChange={() => {}} />);
|
||||
const count = screen.getByText("90 / 100");
|
||||
expect(count).toHaveClass("text-editor-count--warn");
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");
|
||||
});
|
||||
|
||||
test("PageError wraps an alert in the page shell", () => {
|
||||
renderWithContexts(<PageError message="Gone" />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Gone");
|
||||
expect(document.querySelector(".app-header")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
225
src/test/components/media.test.tsx
Normal file
225
src/test/components/media.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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/MediaPlayer.tsx";
|
||||
import FilePreview from "../../components/FilePreview.tsx";
|
||||
import { makeDump, makeSession } from "../fixtures.ts";
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
|
||||
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||
"src",
|
||||
`http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`,
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, { player: { play } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toBe("http://localhost:8000/api/thumbnails/f1");
|
||||
});
|
||||
|
||||
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: "http://localhost:8000/api/files/f1?v=2048", 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";
|
||||
242
src/test/components/player.test.tsx
Normal file
242
src/test/components/player.test.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useContext } from "react";
|
||||
import { GlobalPlayer } from "../../components/GlobalPlayer.tsx";
|
||||
import { JournalCard } from "../../components/JournalCard.tsx";
|
||||
import { PlaylistCard } from "../../components/PlaylistCard.tsx";
|
||||
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
|
||||
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
|
||||
import { makeDump, makePlaylist } from "../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
import { markPlaylistVisited } from "../../utils/visited.ts";
|
||||
|
||||
let player!: React.ContextType<typeof PlayerContext>;
|
||||
function Probe() {
|
||||
player = useContext(PlayerContext);
|
||||
return null;
|
||||
}
|
||||
function renderPlayer() {
|
||||
return renderWithContexts(
|
||||
<PlayerProvider>
|
||||
<Probe />
|
||||
<GlobalPlayer />
|
||||
</PlayerProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const track = (i: number): PlayerItem => ({
|
||||
kind: "stream",
|
||||
type: "bandcamp",
|
||||
streamUrl: `https://s/${i}`,
|
||||
resolveUrl: "https://a.bandcamp.com/album/x",
|
||||
resolveIndex: i,
|
||||
resolvedAt: Date.now(),
|
||||
title: `Song ${i}`,
|
||||
trackNum: i + 1,
|
||||
duration: 61,
|
||||
subtitle: "Band",
|
||||
dumpHref: "/dumps/album",
|
||||
});
|
||||
|
||||
describe("GlobalPlayer", () => {
|
||||
test("hidden until something plays; the body makes room for it", () => {
|
||||
renderPlayer();
|
||||
expect(document.querySelector(".global-player")).toBeNull();
|
||||
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" }));
|
||||
expect(document.querySelector(".global-player--soundcloud")).not.toBeNull();
|
||||
expect(document.body).toHaveClass("has-player");
|
||||
act(() => player.stop());
|
||||
expect(document.querySelector(".global-player")).toBeNull();
|
||||
expect(document.body).not.toHaveClass("has-player");
|
||||
});
|
||||
|
||||
test("an embed autoplays on a fresh play only", () => {
|
||||
localStorage.setItem("player", JSON.stringify({
|
||||
v: 2,
|
||||
queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }],
|
||||
index: 0,
|
||||
time: 0,
|
||||
owner: null,
|
||||
}));
|
||||
renderPlayer();
|
||||
const iframe = () => document.querySelector("iframe")!;
|
||||
expect(iframe().src).toBe("https://www.youtube.com/embed/abc");
|
||||
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" }));
|
||||
expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1");
|
||||
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" }));
|
||||
expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true");
|
||||
act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" }));
|
||||
expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1");
|
||||
});
|
||||
|
||||
test("a YouTube playlist embed gets the JS API parameters", () => {
|
||||
renderPlayer();
|
||||
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" }));
|
||||
const src = new URL(document.querySelector("iframe")!.src);
|
||||
expect(src.searchParams.get("enablejsapi")).toBe("1");
|
||||
expect(src.searchParams.get("list")).toBe("PL");
|
||||
});
|
||||
|
||||
test("a queue: title links to the dump, tracklist, transport", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPlayer();
|
||||
act(() => player.playQueue([track(0), track(1), track(2)], 0));
|
||||
expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album");
|
||||
expect(screen.getByText("Band")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 / 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled();
|
||||
|
||||
const list = screen.getByRole("list");
|
||||
expect(within(list).getAllByRole("button")).toHaveLength(3);
|
||||
expect(within(list).getAllByText("1:01")).toHaveLength(3);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Next track" }));
|
||||
expect(screen.getByText("2 / 3")).toBeInTheDocument();
|
||||
await user.click(within(list).getByRole("button", { name: /Song 2/ }));
|
||||
expect(screen.getByText("3 / 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Next track" })).toBeDisabled();
|
||||
expect(list.querySelectorAll(".is-current")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("collapse hides the tracklist; a new item opens it again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPlayer();
|
||||
act(() => player.playQueue([track(0), track(1)]));
|
||||
await user.click(screen.getByRole("button", { name: "Collapse player" }));
|
||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument();
|
||||
act(() => player.playQueue([track(2), track(3)]));
|
||||
expect(screen.getByRole("list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the close button stops playback", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPlayer();
|
||||
act(() => player.play(track(0)));
|
||||
await user.click(screen.getByRole("button", { name: "Close player" }));
|
||||
expect(player.current).toBeNull();
|
||||
});
|
||||
|
||||
test("a file plays in the built-in player", () => {
|
||||
renderPlayer();
|
||||
act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" }));
|
||||
expect(document.querySelector(".global-player--file-video video")).not.toBeNull();
|
||||
expect(screen.getByText("Clip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the resolving notice", async () => {
|
||||
renderPlayer();
|
||||
act(() => player.play(track(0)));
|
||||
// Force a re-resolve that never lands.
|
||||
const { route, deferred } = await import("../fetch.ts");
|
||||
const pending = deferred();
|
||||
route("GET /api/bandcamp/tracks", () => pending.promise);
|
||||
act(() => player.onError());
|
||||
expect(screen.getByText("Refreshing stream…")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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 { container } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard dump={makeDump({ id: "v", kind: "file", fileMime: "video/mp4", url: undefined })} shape="tall" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlaylistCard", () => {
|
||||
test("title, badge, owner, count; clicking opens it and marks it seen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" });
|
||||
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
|
||||
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute("href", "/playlists/mix");
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
|
||||
expect(screen.getByText("1 dump")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tunes")).toBeInTheDocument();
|
||||
await user.click(container.querySelector(".playlist-card-inner")!);
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
});
|
||||
|
||||
test("the owner doesn't see their own name, or an unread dot", () => {
|
||||
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
|
||||
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} isOwner /></ul>);
|
||||
expect(screen.queryByText("@me")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("a new playlist has a dot until visited", () => {
|
||||
const p = makePlaylist({ id: "fresh", createdAt: new Date() });
|
||||
const first = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
|
||||
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
|
||||
first.unmount();
|
||||
markPlaylistVisited("fresh");
|
||||
const again = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
|
||||
expect(again.container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("delete doesn't open the playlist", async () => {
|
||||
const onDelete = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<ul><PlaylistCard playlist={makePlaylist()} onDelete={onDelete} /></ul>);
|
||||
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
expect(currentPath()).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user