import { describe, expect, test, vi } from "vitest"; import { act, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useContext } from "react"; import { GlobalPlayer } from "../../components/GlobalPlayer.tsx"; import { JournalCard } from "../../components/JournalCard.tsx"; import { PlaylistCard } from "../../components/PlaylistCard.tsx"; import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts"; import { makeDump, makePlaylist } from "../fixtures.ts"; import { currentPath, renderWithContexts } from "../render.tsx"; import { markPlaylistVisited } from "../../utils/visited.ts"; let player!: React.ContextType; function Probe() { player = useContext(PlayerContext); return null; } function renderPlayer() { return renderWithContexts( , ); } const track = (i: number): PlayerItem => ({ kind: "stream", type: "bandcamp", streamUrl: `https://s/${i}`, resolveUrl: "https://a.bandcamp.com/album/x", resolveIndex: i, resolvedAt: Date.now(), title: `Song ${i}`, trackNum: i + 1, duration: 61, subtitle: "Band", dumpHref: "/dumps/album", }); describe("GlobalPlayer", () => { test("hidden until something plays; the body makes room for it", () => { renderPlayer(); expect(document.querySelector(".global-player")).toBeNull(); act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" })); expect(document.querySelector(".global-player--soundcloud")).not.toBeNull(); expect(document.body).toHaveClass("has-player"); act(() => player.stop()); expect(document.querySelector(".global-player")).toBeNull(); expect(document.body).not.toHaveClass("has-player"); }); test("an embed autoplays on a fresh play only", () => { localStorage.setItem("player", JSON.stringify({ v: 2, queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }], index: 0, time: 0, owner: null, })); renderPlayer(); const iframe = () => document.querySelector("iframe")!; expect(iframe().src).toBe("https://www.youtube.com/embed/abc"); act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" })); expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1"); act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" })); expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true"); act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" })); expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1"); }); test("a YouTube playlist embed gets the JS API parameters", () => { renderPlayer(); act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" })); const src = new URL(document.querySelector("iframe")!.src); expect(src.searchParams.get("enablejsapi")).toBe("1"); expect(src.searchParams.get("list")).toBe("PL"); }); test("a queue: title links to the dump, tracklist, transport", async () => { const user = userEvent.setup(); renderPlayer(); act(() => player.playQueue([track(0), track(1), track(2)], 0)); expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album"); expect(screen.getByText("Band")).toBeInTheDocument(); expect(screen.getByText("1 / 3")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled(); const list = screen.getByRole("list"); expect(within(list).getAllByRole("button")).toHaveLength(3); expect(within(list).getAllByText("1:01")).toHaveLength(3); await user.click(screen.getByRole("button", { name: "Next track" })); expect(screen.getByText("2 / 3")).toBeInTheDocument(); await user.click(within(list).getByRole("button", { name: /Song 2/ })); expect(screen.getByText("3 / 3")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Next track" })).toBeDisabled(); expect(list.querySelectorAll(".is-current")).toHaveLength(1); }); test("collapse hides the tracklist; a new item opens it again", async () => { const user = userEvent.setup(); renderPlayer(); act(() => player.playQueue([track(0), track(1)])); await user.click(screen.getByRole("button", { name: "Collapse player" })); expect(screen.queryByRole("list")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument(); act(() => player.playQueue([track(2), track(3)])); expect(screen.getByRole("list")).toBeInTheDocument(); }); test("the close button stops playback", async () => { const user = userEvent.setup(); renderPlayer(); act(() => player.play(track(0))); await user.click(screen.getByRole("button", { name: "Close player" })); expect(player.current).toBeNull(); }); test("a file plays in the built-in player", () => { renderPlayer(); act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" })); expect(document.querySelector(".global-player--file-video video")).not.toBeNull(); expect(screen.getByText("Clip")).toBeInTheDocument(); }); test("the resolving notice", async () => { renderPlayer(); act(() => player.play(track(0))); // Force a re-resolve that never lands. const { route, deferred } = await import("../fetch.ts"); const pending = deferred(); route("GET /api/bandcamp/tracks", () => pending.promise); act(() => player.onError()); expect(screen.getByText("Refreshing stream…")).toBeInTheDocument(); }); }); describe("JournalCard", () => { const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() }; test("image, quote and text modes", () => { const { container, unmount } = renderWithContexts( , ); 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( , { player: { play } }, ); const cards = container.querySelectorAll("li"); await user.click(cards[0].querySelector(".journal-card-image")!); expect(play).toHaveBeenCalledWith(expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" })); expect(currentPath()).toBe("/"); await user.click(cards[1].querySelector(".journal-card-image")!); expect(currentPath()).toBe("/dumps/p"); }); test("a video file shows its generated still", () => { const { container } = renderWithContexts( , ); expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v"); }); }); describe("PlaylistCard", () => { test("title, badge, owner, count; clicking opens it and marks it seen", async () => { const user = userEvent.setup(); const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" }); const { container } = renderWithContexts(); 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(); 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(); expect(first.container.querySelector(".unread-dot")).not.toBeNull(); first.unmount(); markPlaylistVisited("fresh"); const again = renderWithContexts(); expect(again.container.querySelector(".unread-dot")).toBeNull(); }); test("delete doesn't open the playlist", async () => { const onDelete = vi.fn(); const user = userEvent.setup(); renderWithContexts(); await user.click(screen.getByRole("button", { name: "Delete playlist" })); expect(onDelete).toHaveBeenCalled(); expect(currentPath()).toBe("/"); }); });