// @vitest-environment jsdom // // The Download Center's confirm dialogs name their ACTION on the confirm button: the shared // `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button, // and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DownloadJob } from "../lib/api"; const mocks = vi.hoisted(() => ({ jobs: [] as unknown[], confirm: vi.fn( async (_opts: { title: string; message?: string; confirmLabel?: string }) => false, ), })); 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" }) })); // The page toolbar (title + tabs) renders into the app shell's slot, which a bare render has none of. 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"]): 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", max_height: null, container: null, audio_format: null, vcodec: null, embed_subs: false, embed_chapters: false, embed_thumbnail: false, sponsorblock: false, }, display_name: `Clip ${id}`, display_uploader: null, display_uploader_url: null, extra_links: [], can_download: status === "done", expired: false, folder_id: null, kept: false, expires_at: null, asset: null, }); afterEach(() => { cleanup(); mocks.confirm.mockClear(); }); /** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom * (floating-ui never positions it), so the row is matched on its text. */ async function pick(row: string, label: string) { const more = screen .getByText(row) .closest("[data-nav-index]")! .querySelector('button[aria-label="downloads.rowMenu"]')!; fireEvent.click(more); const item = await waitFor(() => { const found = screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === label); expect(found).toBeTruthy(); return found!; }); fireEvent.click(item); } describe("DownloadCenter confirm dialogs", () => { it("label the confirm button with the action, not the dialog's question", async () => { mocks.jobs = [job(1, "running"), job(2, "done")]; render( , ); // The destructive and rare row buttons live in the row's "…". await pick("Clip 1", "downloads.actions.cancel"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); // The tab's label shares its button with a count badge. fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ })); await screen.findByText("Clip 2"); await pick("Clip 2", "downloads.actions.redownload"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); await pick("Clip 2", "downloads.actions.delete"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3)); expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([ ["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"], ["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"], ["downloads.confirm.deleteTitle", "downloads.actions.delete"], ]); }); 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.jobs = [{ ...job(3, "queued"), job_kind: "upload" }]; render( , ); await pick("Clip 3", "downloads.actions.cancel"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); const [opts] = mocks.confirm.mock.calls[0]!; expect([opts.title, opts.confirmLabel]).toEqual([ "downloads.confirm.cancelUploadTitle", "downloads.confirm.cancelUploadAction", ]); }); it("cancelling a clip speaks of the clip", async () => { mocks.jobs = [{ ...job(4, "running"), job_kind: "edit" }]; render( , ); await pick("Clip 4", "downloads.actions.cancel"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); const [opts] = mocks.confirm.mock.calls[0]!; expect([opts.title, opts.message, opts.confirmLabel]).toEqual([ "downloads.confirm.cancelEditTitle", "downloads.confirm.cancelEditBody", "downloads.confirm.cancelEditAction", ]); }); it.each([ ["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"], ["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"], ["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"], ] as const)("deleting a finished %s says what happens to its file", async (kind, title, body) => { // Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone. mocks.jobs = [{ ...job(5, "done"), job_kind: kind }]; render( , ); fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ })); await screen.findByText("Clip 5"); await pick("Clip 5", "downloads.actions.delete"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); const [opts] = mocks.confirm.mock.calls[0]!; expect([opts.title, opts.message]).toEqual([title, body]); }); });