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()); }); });