// @vitest-environment jsdom // // The tray's row menu: the copies and cancel of a waiting, running or failed job are its ONE menu, // opened by the row's "…" (it shows on hover or focus) or a right-click on the row; a failed job's // first row is Retry. The cancel confirm names its ACTION on its button, and speaks of what is // cancelled (a download, an upload, a clip). The tray's own showing and done rows are // DownloadTray.dom.test.tsx's; a finished file's menu 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 { useState } from "react"; import { api, type DownloadJob } from "../../lib/api"; import { menuGroups } from "../../components/ui/menuGroups"; const mocks = vi.hoisted(() => ({ jobs: [] as unknown[], confirm: vi.fn( async (_o?: { title: string; message?: string; confirmLabel?: string; danger?: boolean }) => true, ), })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), }; }); vi.mock("../../components/ui/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/usePref", () => ({ usePref: (_key: string, initial: T) => useState(initial), })); vi.mock("../../modules/files/filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }), })); globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; import DownloadTray from "./DownloadTray"; 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", source_key: "youtube", asset: { media_kind: "video", duration_s: 60 }, ...extra, }) as unknown as DownloadJob; afterEach(() => { cleanup(); vi.restoreAllMocks(); mocks.confirm.mockClear(); }); function mount() { const qc = new QueryClient(); const tree = () => ( ); const { rerender } = render(tree()); mounted.rerender = () => rerender(tree()); } // The last mount's re-render: what a poll's new data does to the tray. const mounted = { rerender: () => {} }; const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; const moreOf = (text: string) => rowOf(text).querySelector('button[aria-label="tray.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), ); } describe("DownloadTray · the row menu", () => { it("the '…' hides until hover or focus", () => { mocks.jobs = [job(1, "running")]; mount(); 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", async () => { mocks.jobs = [job(1, "error")]; mount(); const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? ""; 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 })]; mounted.rerender(); 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("DownloadTray · the cancel confirm", () => { const cancelOf = async (title: string) => { mount(); await openMenu(() => fireEvent.click(moreOf(title))); fireEvent.click( screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "downloads.actions.cancel")!, ); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); return mocks.confirm.mock.calls[0]![0]!; }; it("labels its button with the action, not the dialog's question", async () => { mocks.confirm.mockResolvedValueOnce(false); mocks.jobs = [job(1, "running")]; const o = await cancelOf("Clip 1"); expect([o.title, o.confirmLabel]).toEqual([ "downloads.confirm.cancelTitle", "downloads.confirm.cancelAction", ]); }); it("cancelling an upload says the uploaded file goes with it", async () => { // Nothing to fetch again: the cancel deletes the original from the server. mocks.confirm.mockResolvedValueOnce(false); mocks.jobs = [job(3, "queued", { job_kind: "upload" })]; const o = await cancelOf("Clip 3"); expect([o.title, o.confirmLabel]).toEqual([ "downloads.confirm.cancelUploadTitle", "downloads.confirm.cancelUploadAction", ]); }); it("cancelling a clip speaks of the clip", async () => { mocks.confirm.mockResolvedValueOnce(false); mocks.jobs = [job(4, "running", { job_kind: "edit" })]; const o = await cancelOf("Clip 4"); expect([o.title, o.message, o.confirmLabel]).toEqual([ "downloads.confirm.cancelEditTitle", "downloads.confirm.cancelEditBody", "downloads.confirm.cancelEditAction", ]); }); }); 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], ]); }); });