Files
siftlode/frontend/src/features/downloads/DownloadTray.menu.dom.test.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

272 lines
9.4 KiB
TypeScript

// @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<typeof import("react-i18next")>();
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: <T,>(_key: string, initial: T) => useState<T>(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> = {},
): 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 = () => (
<QueryClientProvider client={qc}>
<DownloadTray />
</QueryClientProvider>
);
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<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('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],
]);
});
});