// @vitest-environment jsdom // // The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and // the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a // right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx). // i18n is key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type DownloadJob } from "../lib/api"; import { menuGroups } from "./ui/menuGroups"; const mocks = vi.hoisted(() => ({ jobs: [] as unknown[], confirm: vi.fn(async (_o?: unknown) => true), })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ useLiveQuery: (key: readonly unknown[]) => ({ data: key[0] === "downloads" ? mocks.jobs : undefined, isError: false, isLoading: false, refetch: vi.fn(), }), })); vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) })); vi.mock("./ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, })); import DownloadCenter from "./DownloadCenter"; const job = ( id: number, status: DownloadJob["status"], extra: Partial = {}, ): DownloadJob => ({ id, status, progress: 0, speed_bps: null, eta_s: null, phase: null, error: null, error_code: null, error_detail: null, queue_pos: 0, created_at: null, source_kind: "youtube", source_ref: `v${id}`, source_url: null, poster_url: null, profile_id: null, spec: { mode: "av" }, display_name: `Clip ${id}`, display_uploader: null, display_uploader_url: null, extra_links: [], can_download: status === "done", job_kind: "download", asset: { media_kind: "video", duration_s: 60 }, ...extra, }) as unknown as DownloadJob; afterEach(() => { cleanup(); vi.restoreAllMocks(); mocks.confirm.mockClear(); }); function mount(tab?: string) { render( , ); if (tab) fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) })); } const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => rowOf(text).querySelector('button[aria-label="downloads.rowMenu"]')!; const menuRows = () => screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); async function openMenu(open: () => void) { act(open); await waitFor(() => expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), ); } const rowButtons = (text: string) => [...rowOf(text).querySelectorAll("button, a[aria-label]")].map((b) => b.getAttribute("aria-label"), ); describe("DownloadCenter · the queue row", () => { it("shows pause and its '…' only; the '…' hides until hover or focus", async () => { mocks.jobs = [job(1, "running")]; mount(); expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]); expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true); expect(rowOf("Clip 1").classList.contains("group")).toBe(true); }); it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => { mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")]; mount(); await openMenu(() => fireEvent.click(moreOf("Clip 1"))); expect(menuRows()).toEqual([ "downloads.actions.pause", "common.copy.title", "common.copy.link", "downloads.actions.cancel", ]); expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true"); fireEvent.keyDown(document.activeElement!, { key: "Escape" }); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); expect(menuRows()).toEqual([ "downloads.actions.retry", "common.copy.title", "downloads.actions.cancel", ]); }); it("draws Retry as 'do it again', never the Play glyph (row and menu)", async () => { mocks.jobs = [job(1, "error"), job(2, "paused")]; mount(); const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? ""; const retry = rowOf("Clip 1").querySelector('[aria-label="downloads.actions.retry"]')!; expect(glyph(retry)).toContain("lucide-refresh-cw"); // A paused job resumes: that one keeps the transport glyph. const resume = rowOf("Clip 2").querySelector('[aria-label="downloads.actions.resume"]')!; expect(glyph(resume)).toContain("lucide-play"); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1"))); const row = screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "downloads.actions.retry")!; expect(glyph(row)).toContain("lucide-refresh-cw"); }); it("closes on a second press of the same '…'", async () => { mocks.jobs = [job(1, "running")]; mount(); await openMenu(() => fireEvent.click(moreOf("Clip 1"))); // The press lands outside the panel, which floating-ui's dismiss closes on; the click after // it must not open it again. act(() => { fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf("Clip 1")); fireEvent.click(moreOf("Clip 1"), { detail: 1 }); }); await waitFor(() => expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), ); expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false"); }); it("still closes when a poll replaced the job under the open menu", async () => { mocks.jobs = [job(1, "running")]; mount(); await openMenu(() => fireEvent.click(moreOf("Clip 1"))); // The queue polls while a job runs: the next render lists a NEW object for the same job. mocks.jobs = [job(1, "running", { progress: 40 })]; fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), { target: { value: "x" }, }); act(() => { fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" }); fireEvent.mouseDown(moreOf("Clip 1")); fireEvent.click(moreOf("Clip 1"), { detail: 1 }); }); await waitFor(() => expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0), ); }); it("cancels only through the confirm", async () => { mocks.jobs = [job(1, "queued")]; const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never); mount(); const pickCancel = async () => { await openMenu(() => fireEvent.click(moreOf("Clip 1"))); fireEvent.click( screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "downloads.actions.cancel")!, ); }; mocks.confirm.mockResolvedValueOnce(false); await pickCancel(); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true })); expect(cancel).not.toHaveBeenCalled(); await pickCancel(); await waitFor(() => expect(cancel).toHaveBeenCalledWith(1)); }); }); describe("menuGroups", () => { const row = (id: string) => ({ id, label: id, onSelect: () => {} }); it("rules between non-empty groups only", () => { const out = menuGroups([], [row("a"), row("b")], [], [row("c")]); expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([ ["a", false], ["b", false], ["c", true], ]); }); });