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.
272 lines
9.4 KiB
TypeScript
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],
|
|
]);
|
|
});
|
|
});
|