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(
,
);
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();
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(
,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
rerender(
,
);
// 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(
,
);
await user.click(screen.getByRole("button", { name: "Upvote" }));
expect(onCast).toHaveBeenCalledWith("d", 0);
rerender(
,
);
await user.click(screen.getByRole("button", { name: "Remove vote" }));
expect(onRemove).toHaveBeenCalledWith("d", 3);
});
test("disabled for guests, with a hint", () => {
renderWithContexts(
{}}
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(
{}}
/>,
);
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(
,
);
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(
,
);
await user.click(screen.getByRole("button", { name: "Remove like" }));
expect(onUnlike).toHaveBeenCalledWith("c", 2);
});
});
describe("UserListPopover", () => {
function Harness({ onClose = () => {} }: { onClose?: () => void }) {
const ref = useRef(null);
return (
<>
anchor
>
);
}
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(, { 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();
await screen.findByText("Dump not found");
first.unmount();
route("GET /api/x/voters", () => new TypeError("offline"));
renderWithContexts();
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();
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();
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(
,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
unmount();
renderWithContexts(
,
{ 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(
,
{ 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(
,
{
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(
,
{
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(
,
{ session: me },
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
priv.unmount();
renderWithContexts(, {
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(, {
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) => (
);
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(
{} }}
>
,
{ ws: { unreadChatCount: 4 } },
);
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
expect(openChat).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
});
});