v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,389 +0,0 @@
|
||||
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, renderWithContexts } from "../render.tsx";
|
||||
import { defaultWSValue, 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={{ ...defaultWSValue, 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());
|
||||
});
|
||||
});
|
||||
42
src/test/components/category/CategorySelect.test.tsx
Normal file
42
src/test/components/category/CategorySelect.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CategorySelect } from "../../../components/category/CategorySelect.tsx";
|
||||
import type { Category } from "../../../model.ts";
|
||||
import { makeCategory } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
|
||||
];
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
64
src/test/components/category/CategorySwitcher.test.tsx
Normal file
64
src/test/components/category/CategorySwitcher.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CategorySwitcher } from "../../../components/category/CategorySwitcher.tsx";
|
||||
import type { Category } from "../../../model.ts";
|
||||
import { makeCategory } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
|
||||
];
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
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";
|
||||
import { ChatModal } from "../../../components/chat/ChatModal.tsx";
|
||||
import type { AuthResponse, ChatMessage } from "../../../model.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeChatMessage, makeSession, wire } from "../../fixtures.ts";
|
||||
import { renderApp } from "../../render.tsx";
|
||||
import { FakeWebSocket } from "../../ws.ts";
|
||||
|
||||
const me = makeSession({ id: "me", username: "me" });
|
||||
|
||||
@@ -2,11 +2,11 @@ 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";
|
||||
import { CommentThread } from "../../../components/comment/CommentThread.tsx";
|
||||
import type { AuthResponse, Comment } from "../../../model.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeComment, makeSession, wire } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const me = makeSession({ id: "me", username: "me" });
|
||||
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
|
||||
@@ -1,416 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
119
src/test/components/dump/DumpCard.test.tsx
Normal file
119
src/test/components/dump/DumpCard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DumpCard } from "../../../components/dump/DumpCard.tsx";
|
||||
import { markDumpVisited } from "../../../utils/visited.ts";
|
||||
import { makeCategory, makeDump, makeSession } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("DumpCard", () => {
|
||||
const props = {
|
||||
voteCount: 3,
|
||||
voted: false,
|
||||
canVote: true,
|
||||
castVote: vi.fn(),
|
||||
removeVote: vi.fn(),
|
||||
};
|
||||
|
||||
test("title, teaser, date, comment count and categories", () => {
|
||||
const dump = makeDump({
|
||||
id: "d1",
|
||||
slug: "nice",
|
||||
title: "Nice find",
|
||||
comment: "Look **here**",
|
||||
commentCount: 2,
|
||||
categoryIds: ["c1", "missing"],
|
||||
});
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
{
|
||||
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/nice",
|
||||
);
|
||||
expect(screen.getByText("here").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("2 comments")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
"/music",
|
||||
);
|
||||
});
|
||||
|
||||
test("an unread dot until visited, never on your own", () => {
|
||||
const dump = makeDump({ id: "d2" });
|
||||
const first = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
|
||||
first.unmount();
|
||||
const own = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(own.container.querySelector(".unread-dot")).toBeNull();
|
||||
own.unmount();
|
||||
markDumpVisited("d2");
|
||||
const seen = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(seen.container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("the private badge shows to the owner", () => {
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking the card opens the dump; voting doesn't", async () => {
|
||||
const castVote = vi.fn();
|
||||
const dump = makeDump({ id: "d3" });
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} castVote={castVote} />
|
||||
</ul>,
|
||||
{ session: makeSession() },
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
|
||||
expect(castVote).toHaveBeenCalledWith("d3", 3);
|
||||
expect(currentPath()).toBe("/");
|
||||
await userEvent.click(screen.getByText("1 minute ago"));
|
||||
expect(currentPath()).toBe("/dumps/d3");
|
||||
});
|
||||
|
||||
test("file dumps get a file preview; bare URLs a placeholder", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard
|
||||
dump={makeDump({
|
||||
kind: "file",
|
||||
fileName: "a.pdf",
|
||||
fileMime: "application/pdf",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelectorAll(".thumb-placeholder").length)
|
||||
.toBeGreaterThan(0);
|
||||
const cards = container.querySelectorAll(".dump-card");
|
||||
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
119
src/test/components/dump/FilePreview.test.tsx
Normal file
119
src/test/components/dump/FilePreview.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FilePreview } from "../../../components/dump/FilePreview.tsx";
|
||||
import { makeDump, makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
import {
|
||||
dumpFileUrl,
|
||||
dumpFileVersion,
|
||||
dumpThumbnailUrl,
|
||||
} from "../../../utils/urls.ts";
|
||||
|
||||
describe("FilePreview", () => {
|
||||
const session = makeSession();
|
||||
const file = (mime: string, over = {}) =>
|
||||
makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
fileName: "thing",
|
||||
fileMime: mime,
|
||||
fileSize: 2048,
|
||||
url: undefined,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("images show inline, with the media token", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} />, { session });
|
||||
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||
"src",
|
||||
`http://localhost:8000/api/files/f1?v=${
|
||||
dumpFileVersion(d)
|
||||
}&token=${session.mediaToken}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a compact image shows the server's preview, not the upload", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, { session });
|
||||
const img = screen.getByRole("img", { name: "thing" });
|
||||
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
|
||||
expect(img).toHaveAttribute("loading", "lazy");
|
||||
});
|
||||
|
||||
test("other files are a download link", () => {
|
||||
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
||||
const link = screen.getByRole("link", { name: /Download thing/ });
|
||||
expect(link).toHaveAttribute("download", "thing");
|
||||
expect(link).toHaveTextContent("(2.0 KB)");
|
||||
});
|
||||
|
||||
test("compact audio plays in the global player", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "file",
|
||||
mimeType: "audio/mpeg",
|
||||
title: "Dump f1",
|
||||
subtitle: "thing",
|
||||
dumpHref: "/dumps/f1",
|
||||
artworkUrl: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact video claims its generated still as artwork", async () => {
|
||||
const play = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toMatch(
|
||||
/^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the global video button toggles when it's the one playing", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} global />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "file",
|
||||
fileUrl: dumpFileUrl(d),
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the global audio waveform starts playback", async () => {
|
||||
const play = vi.fn();
|
||||
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
|
||||
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("compact PDFs and text get an icon", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<FilePreview dump={file("application/pdf")} compact />,
|
||||
);
|
||||
expect(container).toHaveTextContent("📄");
|
||||
});
|
||||
});
|
||||
|
||||
import { route } from "../../fetch.ts";
|
||||
105
src/test/components/dump/JournalCard.test.tsx
Normal file
105
src/test/components/dump/JournalCard.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { JournalCard } from "../../../components/dump/JournalCard.tsx";
|
||||
import { makeDump } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
import { dumpThumbnailUrl } from "../../../utils/urls.ts";
|
||||
|
||||
describe("JournalCard", () => {
|
||||
const base = {
|
||||
voteCount: 1,
|
||||
voted: false,
|
||||
canVote: true,
|
||||
castVote: vi.fn(),
|
||||
removeVote: vi.fn(),
|
||||
};
|
||||
|
||||
test("image, quote and text modes", () => {
|
||||
const { container, unmount } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard
|
||||
dump={makeDump({
|
||||
richContent: {
|
||||
type: "link",
|
||||
url: "u",
|
||||
thumbnailUrl: "https://i.test/a.png",
|
||||
},
|
||||
})}
|
||||
shape="feature"
|
||||
{...base}
|
||||
/>
|
||||
<JournalCard
|
||||
dump={makeDump({ comment: "A thought" })}
|
||||
shape="square"
|
||||
{...base}
|
||||
/>
|
||||
<JournalCard dump={makeDump()} shape="square" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
const cards = container.querySelectorAll("li");
|
||||
expect(cards[0]).toHaveClass(
|
||||
"journal-card--image",
|
||||
"journal-card--feature",
|
||||
);
|
||||
expect(cards[1]).toHaveClass("journal-card--quote");
|
||||
expect(within(cards[1] as HTMLElement).getByText("A thought"))
|
||||
.toBeInTheDocument();
|
||||
expect(cards[2]).toHaveClass("journal-card--text");
|
||||
expect(cards[2]).toHaveTextContent("🔗");
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("a playable image card plays; an unplayable one navigates", async () => {
|
||||
const play = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const yt = makeDump({
|
||||
id: "y",
|
||||
richContent: {
|
||||
type: "youtube",
|
||||
url: "https://youtu.be/y",
|
||||
thumbnailUrl: "https://i.test/y.png",
|
||||
embedUrl: "https://www.youtube.com/embed/y",
|
||||
},
|
||||
});
|
||||
const pic = makeDump({
|
||||
id: "p",
|
||||
kind: "file",
|
||||
fileMime: "image/png",
|
||||
fileName: "p.png",
|
||||
url: undefined,
|
||||
});
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard dump={yt} shape="wide" {...base} />
|
||||
<JournalCard dump={pic} shape="square" {...base} />
|
||||
</ul>,
|
||||
{ player: { play } },
|
||||
);
|
||||
const cards = container.querySelectorAll("li");
|
||||
await user.click(cards[0].querySelector(".journal-card-image")!);
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }),
|
||||
);
|
||||
expect(currentPath()).toBe("/");
|
||||
await user.click(cards[1].querySelector(".journal-card-image")!);
|
||||
expect(currentPath()).toBe("/dumps/p");
|
||||
});
|
||||
|
||||
test("a video file shows its generated still", () => {
|
||||
const dump = makeDump({
|
||||
id: "v",
|
||||
kind: "file",
|
||||
fileMime: "video/mp4",
|
||||
url: undefined,
|
||||
});
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard dump={dump} shape="tall" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelector("img")!.getAttribute("src")).toBe(
|
||||
dumpThumbnailUrl(dump),
|
||||
);
|
||||
});
|
||||
});
|
||||
108
src/test/components/dump/RichContentCard.test.tsx
Normal file
108
src/test/components/dump/RichContentCard.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RichContentCard } from "../../../components/dump/RichContentCard.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("RichContentCard", () => {
|
||||
const yt = {
|
||||
type: "youtube",
|
||||
url: "https://youtube.com/watch?v=x",
|
||||
title: "A video",
|
||||
siteName: "YouTube",
|
||||
description: "About it",
|
||||
thumbnailUrl: "https://i.ytimg.com/x.jpg",
|
||||
embedUrl: "https://www.youtube.com/embed/x",
|
||||
};
|
||||
|
||||
test("full card: play button, source link and details", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(
|
||||
<RichContentCard richContent={yt} dumpHref="/dumps/d" />,
|
||||
{
|
||||
player: { play },
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute(
|
||||
"href",
|
||||
yt.url,
|
||||
);
|
||||
expect(screen.getByText("About it")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "embed",
|
||||
embedUrl: yt.embedUrl,
|
||||
dumpHref: "/dumps/d",
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact: shows Pause while this card plays", () => {
|
||||
renderWithContexts(<RichContentCard richContent={yt} compact />, {
|
||||
player: {
|
||||
playing: true,
|
||||
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass(
|
||||
"is-playing",
|
||||
);
|
||||
});
|
||||
|
||||
test("compact and unplayable: a link to the source", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={{
|
||||
type: "link",
|
||||
url: "https://blog.test/p",
|
||||
siteName: "Blog",
|
||||
}}
|
||||
compact
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://blog.test/p",
|
||||
);
|
||||
});
|
||||
|
||||
test("a dump thumbnail overrides the provider's", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={yt}
|
||||
thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/thumbnails/d",
|
||||
);
|
||||
});
|
||||
|
||||
test("a native stream that is the active item toggles instead of restarting", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const play = vi.fn();
|
||||
const bc = {
|
||||
type: "bandcamp",
|
||||
url: "https://a.bandcamp.com/album/x",
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/1",
|
||||
};
|
||||
renderWithContexts(<RichContentCard richContent={bc} compact />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
play,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "stream",
|
||||
type: "bandcamp",
|
||||
streamUrl: "s",
|
||||
resolveUrl: bc.url,
|
||||
resolveIndex: 0,
|
||||
resolvedAt: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,16 +7,16 @@ import {
|
||||
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 { DumpCreateModal } from "../../../../components/dump/create/DumpCreateModal.tsx";
|
||||
import { calls, fail, route } from "../../../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
} from "../../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../../render.tsx";
|
||||
|
||||
const session = makeSession({ username: "me" });
|
||||
const categories = [makeCategory({ id: "c1", name: "Music" })];
|
||||
104
src/test/components/editor/Markdown.test.tsx
Normal file
104
src/test/components/editor/Markdown.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Markdown } from "../../../components/editor/Markdown.tsx";
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,11 @@ 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";
|
||||
} from "../../../components/editor/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>>;
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
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 { defaultWSValue, WSContext } from "../../contexts/WSContext.ts";
|
||||
import type { AuthResponse, Category } from "../../model.ts";
|
||||
import { makeCategory, makeSession, makeUser } from "../fixtures.ts";
|
||||
import { currentPath, 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={defaultWSValue}>
|
||||
<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();
|
||||
});
|
||||
});
|
||||
125
src/test/components/layout/AppHeader.test.tsx
Normal file
125
src/test/components/layout/AppHeader.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { 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 { AppHeader } from "../../../components/layout/AppHeader.tsx";
|
||||
import { AuthContext } from "../../../contexts/AuthContext.ts";
|
||||
import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts";
|
||||
import type { AuthResponse } from "../../../model.ts";
|
||||
import { makeSession } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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={defaultWSValue}>
|
||||
<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("/");
|
||||
});
|
||||
});
|
||||
24
src/test/components/layout/ChatButton.test.tsx
Normal file
24
src/test/components/layout/ChatButton.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatButton } from "../../../components/layout/ChatButton.tsx";
|
||||
import { ChatContext } from "../../../contexts/ChatContext.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("ChatButton", () => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
69
src/test/components/layout/Fabs.test.tsx
Normal file
69
src/test/components/layout/Fabs.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { ChatFab } from "../../../components/layout/ChatFab.tsx";
|
||||
import { DumpFab } from "../../../components/layout/DumpFab.tsx";
|
||||
import { ChatContext } from "../../../contexts/ChatContext.ts";
|
||||
import { makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
47
src/test/components/layout/NotificationBell.test.tsx
Normal file
47
src/test/components/layout/NotificationBell.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NotificationBell } from "../../../components/layout/NotificationBell.tsx";
|
||||
import { makeNotification } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts";
|
||||
|
||||
describe("NotificationBell", () => {
|
||||
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={{ ...defaultWSValue, 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
13
src/test/components/layout/PageError.test.tsx
Normal file
13
src/test/components/layout/PageError.test.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { PageError } from "../../../components/layout/PageError.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("PageError", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
60
src/test/components/layout/SearchBar.test.tsx
Normal file
60
src/test/components/layout/SearchBar.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { SearchBar } from "../../../components/layout/SearchBar.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
38
src/test/components/layout/UserMenu.test.tsx
Normal file
38
src/test/components/layout/UserMenu.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UserMenu } from "../../../components/layout/UserMenu.tsx";
|
||||
import { makeUser } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,14 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } 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 { GlobalPlayer } from "../../../components/player/GlobalPlayer.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";
|
||||
import { dumpThumbnailUrl } from "../../utils/urls.ts";
|
||||
} from "../../../contexts/PlayerContext.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
let player!: React.ContextType<typeof PlayerContext>;
|
||||
function Probe() {
|
||||
@@ -184,183 +179,10 @@ describe("GlobalPlayer", () => {
|
||||
renderPlayer();
|
||||
act(() => player.play(track(0)));
|
||||
// Force a re-resolve that never lands.
|
||||
const { route, deferred } = await import("../fetch.ts");
|
||||
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 dump = makeDump({
|
||||
id: "v",
|
||||
kind: "file",
|
||||
fileMime: "video/mp4",
|
||||
url: undefined,
|
||||
});
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<JournalCard dump={dump} shape="tall" {...base} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelector("img")!.getAttribute("src")).toBe(
|
||||
dumpThumbnailUrl(dump),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,8 @@
|
||||
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";
|
||||
import {
|
||||
dumpFileUrl,
|
||||
dumpFileVersion,
|
||||
dumpThumbnailUrl,
|
||||
} from "../../utils/urls.ts";
|
||||
import { MediaPlayer } from "../../../components/player/MediaPlayer.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
function media(container: HTMLElement) {
|
||||
return container.querySelector<HTMLMediaElement>("audio, video")!;
|
||||
@@ -211,111 +204,3 @@ describe("MediaPlayer", () => {
|
||||
.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", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} />, { session });
|
||||
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||
"src",
|
||||
`http://localhost:8000/api/files/f1?v=${
|
||||
dumpFileVersion(d)
|
||||
}&token=${session.mediaToken}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a compact image shows the server's preview, not the upload", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, { session });
|
||||
const img = screen.getByRole("img", { name: "thing" });
|
||||
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
|
||||
expect(img).toHaveAttribute("loading", "lazy");
|
||||
});
|
||||
|
||||
test("other files are a download link", () => {
|
||||
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
||||
const link = screen.getByRole("link", { name: /Download thing/ });
|
||||
expect(link).toHaveAttribute("download", "thing");
|
||||
expect(link).toHaveTextContent("(2.0 KB)");
|
||||
});
|
||||
|
||||
test("compact audio plays in the global player", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "file",
|
||||
mimeType: "audio/mpeg",
|
||||
title: "Dump f1",
|
||||
subtitle: "thing",
|
||||
dumpHref: "/dumps/f1",
|
||||
artworkUrl: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact video claims its generated still as artwork", async () => {
|
||||
const play = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toMatch(
|
||||
/^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the global video button toggles when it's the one playing", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} global />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "file",
|
||||
fileUrl: dumpFileUrl(d),
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the global audio waveform starts playback", async () => {
|
||||
const play = vi.fn();
|
||||
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
|
||||
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("compact PDFs and text get an icon", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<FilePreview dump={file("application/pdf")} compact />,
|
||||
);
|
||||
expect(container).toHaveTextContent("📄");
|
||||
});
|
||||
});
|
||||
|
||||
import { route } from "../fetch.ts";
|
||||
82
src/test/components/playlist/PlaylistCard.test.tsx
Normal file
82
src/test/components/playlist/PlaylistCard.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
|
||||
import { makePlaylist } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
import { markPlaylistVisited } from "../../../utils/visited.ts";
|
||||
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
90
src/test/components/social/FollowButton.test.tsx
Normal file
90
src/test/components/social/FollowButton.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
FollowPlaylistButton,
|
||||
FollowUserButton,
|
||||
} from "../../../components/social/FollowButton.tsx";
|
||||
import { makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
40
src/test/components/social/LikeButton.test.tsx
Normal file
40
src/test/components/social/LikeButton.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LikeButton } from "../../../components/social/LikeButton.tsx";
|
||||
import { route } from "../../fetch.ts";
|
||||
import { page } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("LikeButton", () => {
|
||||
test("like and unlike, passing the count on screen; the count opens who liked", 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);
|
||||
});
|
||||
});
|
||||
96
src/test/components/social/UserListPopover.test.tsx
Normal file
96
src/test/components/social/UserListPopover.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRef } from "react";
|
||||
import { UserListPopover } from "../../../components/social/UserListPopover.tsx";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeSession, makeUser, page } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
78
src/test/components/social/VoteButton.test.tsx
Normal file
78
src/test/components/social/VoteButton.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { VoteButton } from "../../../components/social/VoteButton.tsx";
|
||||
import { route } from "../../fetch.ts";
|
||||
import { makeUser, page } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("VoteButton", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
47
src/test/components/ui/Avatar.test.tsx
Normal file
47
src/test/components/ui/Avatar.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { Avatar } from "../../../components/ui/Avatar.tsx";
|
||||
import { AvatarOverrideContext } from "../../../contexts/AvatarOverrideContext.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,62 +4,12 @@ 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";
|
||||
import { Modal } from "../../../components/ui/Modal.tsx";
|
||||
import { ConfirmModal } from "../../../components/ui/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();
|
||||
19
src/test/components/ui/CountedInput.test.tsx
Normal file
19
src/test/components/ui/CountedInput.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { CountedInput } from "../../../components/ui/CountedInput.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("CountedInput", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
59
src/test/components/ui/Modal.test.tsx
Normal file
59
src/test/components/ui/Modal.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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 { Modal } from "../../../components/ui/Modal.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();
|
||||
});
|
||||
});
|
||||
22
src/test/components/ui/TabBar.test.tsx
Normal file
22
src/test/components/ui/TabBar.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TabBar } from "../../../components/ui/TabBar.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("TabBar", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
56
src/test/components/ui/Thumbnail.test.tsx
Normal file
56
src/test/components/ui/Thumbnail.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { Thumbnail } from "../../../components/ui/Thumbnail.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
43
src/test/components/ui/ThumbnailPlaceholder.test.tsx
Normal file
43
src/test/components/ui/ThumbnailPlaceholder.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { ThumbnailPlaceholder } from "../../../components/ui/ThumbnailPlaceholder.tsx";
|
||||
import { hueFromString } from "../../../utils/thumbnailTint.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
20
src/test/components/ui/Tooltip.test.tsx
Normal file
20
src/test/components/ui/Tooltip.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Tooltip } from "../../../components/ui/Tooltip.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("Tooltip", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
39
src/test/pages/index/FeedTabBar.test.tsx
Normal file
39
src/test/pages/index/FeedTabBar.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FeedTabBar } from "../../../pages/index/FeedTabBar.tsx";
|
||||
import type { Category } from "../../../model.ts";
|
||||
import { makeCategory, makeSession } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
|
||||
];
|
||||
|
||||
describe("FeedTabBar", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
52
src/test/pages/index/PresenceRow.test.tsx
Normal file
52
src/test/pages/index/PresenceRow.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { PresenceRow } from "../../../pages/index/PresenceRow.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user