Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
221 lines
8.0 KiB
TypeScript
221 lines
8.0 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
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<typeof import("./ui/PageShell")>()),
|
|
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
import DownloadCenter from "./DownloadCenter";
|
|
|
|
const job = (
|
|
id: number,
|
|
status: DownloadJob["status"],
|
|
extra: Partial<DownloadJob> = {},
|
|
): 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(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadCenter />
|
|
</QueryClientProvider>,
|
|
);
|
|
if (tab)
|
|
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
|
|
}
|
|
|
|
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
|
const moreOf = (text: string) =>
|
|
rowOf(text).querySelector<HTMLElement>('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<HTMLElement>("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],
|
|
]);
|
|
});
|
|
});
|