Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
|
|
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
|
|
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
|
|
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
|
|
// menu. 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",
|
|
expired: false,
|
|
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("DownloadCenter · the library row", () => {
|
|
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
|
|
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
|
mount("done");
|
|
await screen.findByText("Clip 2");
|
|
expect(rowButtons("Clip 2")).toEqual([
|
|
"downloads.actions.play",
|
|
"downloads.actions.share",
|
|
"downloads.actions.download",
|
|
"downloads.rowMenu",
|
|
]);
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
expect(menuRows()).toEqual([
|
|
"downloads.actions.play",
|
|
"downloads.actions.share",
|
|
"downloads.actions.download",
|
|
"downloads.actions.edit",
|
|
"downloads.actions.editDetails",
|
|
"downloads.actions.redownload",
|
|
"downloads.actions.shareManage",
|
|
"common.copy.title",
|
|
"common.copy.link",
|
|
"downloads.actions.delete",
|
|
]);
|
|
});
|
|
|
|
it("copies the share link from the row's own Share button", async () => {
|
|
mocks.jobs = [job(2, "done")];
|
|
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
|
const create = vi
|
|
.spyOn(api, "createDownloadLink")
|
|
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
|
|
mount("done");
|
|
await screen.findByText("Clip 2");
|
|
fireEvent.click(
|
|
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
|
|
);
|
|
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
|
|
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
|
});
|
|
|
|
it("saves the file through the same link as the row's save button", async () => {
|
|
mocks.jobs = [job(2, "done")];
|
|
const followed: string[] = [];
|
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
|
this: HTMLAnchorElement,
|
|
) {
|
|
followed.push(this.href);
|
|
});
|
|
mount("done");
|
|
await screen.findByText("Clip 2");
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
|
fireEvent.click(
|
|
screen
|
|
.getAllByRole("menuitem", { hidden: true })
|
|
.find((b) => b.textContent === "downloads.actions.download")!,
|
|
);
|
|
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
|
|
'a[aria-label="downloads.actions.download"]',
|
|
)!;
|
|
expect(followed).toEqual([button.href]);
|
|
});
|
|
|
|
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
|
|
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
|
|
mount("done");
|
|
await screen.findByText("Clip 3");
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
|
|
expect(menuRows()).toEqual([
|
|
"downloads.actions.share",
|
|
"downloads.actions.download",
|
|
"downloads.actions.shareManage",
|
|
"common.copy.title",
|
|
"downloads.actions.delete",
|
|
]);
|
|
});
|
|
|
|
it("gives a link inside the row the browser's own menu", async () => {
|
|
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
|
mount("done");
|
|
const link = await screen.findByText("youtu.be/x");
|
|
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
|
act(() => void link.dispatchEvent(ev));
|
|
expect(ev.defaultPrevented).toBe(false);
|
|
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("DownloadCenter · the shared row", () => {
|
|
it("holds play, save, edit, the copies and Remove from my list", async () => {
|
|
mocks.jobs = [];
|
|
vi.spyOn(api, "downloadsShared").mockResolvedValue([
|
|
job(4, "done", { can_download: true }),
|
|
] as never);
|
|
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
|
|
mount("shared");
|
|
await screen.findByText("Clip 4");
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
|
|
expect(menuRows()).toEqual([
|
|
"downloads.actions.play",
|
|
"downloads.actions.download",
|
|
"downloads.actions.edit",
|
|
"common.copy.title",
|
|
"downloads.actions.removeShared",
|
|
]);
|
|
fireEvent.click(
|
|
screen
|
|
.getAllByRole("menuitem", { hidden: true })
|
|
.find((b) => b.textContent === "downloads.actions.removeShared")!,
|
|
);
|
|
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
|
|
});
|
|
});
|
|
|
|
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],
|
|
]);
|
|
});
|
|
});
|