- A library past the list's cap is searched and sorted on the server; the count says "the newest". - Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope). - A file dropped where the page takes none never reaches the browser (which would unload the app). - The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line; a text shows its first 5000 lines and says where the rest is. - Confirms name the file (no more "your downloads"); the asking menu rows end in "…". - The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly while only paused work waits, and forgets a finished file that was deleted. - An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top; the link box never replaces what was typed. - Honest states: folders that failed to load, a file no longer there, the storage figure; the rail says "nothing downloaded" only when there is nothing. - Focus after deleting the open folder and when the format form opens; the format name's label. - ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer. - Copy fixes, stale comments and the speculative hub key; a contract test for the list's parameters; e2e: the Files page and the paste intake.
142 lines
5.5 KiB
TypeScript
142 lines
5.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The download formats (Settings → Downloads, and the download dialog's "Manage formats"): one aligned
|
|
// list, built-ins first, each row saying what it saves. A row's one menu: a built-in's only
|
|
// Duplicate; the user's own Edit, Duplicate and Delete (behind a confirm). A duplicate is a NEW format
|
|
// with the original's settings and a name of its own. i18n is key-passthrough.
|
|
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 DownloadProfile } from "../lib/api";
|
|
|
|
const mocks = vi.hoisted(() => ({ confirm: vi.fn(async (_o?: unknown) => true) }));
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
|
}));
|
|
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
|
|
|
import { DownloadFormats } from "./ProfileEditor";
|
|
|
|
const SPEC = {
|
|
mode: "av",
|
|
max_height: 480,
|
|
container: "mp4",
|
|
audio_format: "m4a",
|
|
vcodec: null,
|
|
embed_subs: true,
|
|
embed_chapters: false,
|
|
embed_thumbnail: false,
|
|
sponsorblock: true,
|
|
};
|
|
const PROFILES = [
|
|
{ id: 9, name: "Phone 480", is_builtin: false, spec: SPEC },
|
|
{ id: 1, name: "best", is_builtin: true, spec: { mode: "a", audio_format: "mp3" } },
|
|
] as unknown as DownloadProfile[];
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
mocks.confirm.mockClear();
|
|
});
|
|
|
|
async function mount() {
|
|
vi.spyOn(api, "downloadProfiles").mockResolvedValue(PROFILES);
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<DownloadFormats />
|
|
</QueryClientProvider>,
|
|
);
|
|
await screen.findByText("Phone 480");
|
|
}
|
|
|
|
const rows = () => [...document.querySelectorAll<HTMLElement>("[data-nav-index]")];
|
|
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
|
const menuRows = () =>
|
|
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
|
const pick = (label: string) =>
|
|
fireEvent.click(
|
|
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!,
|
|
);
|
|
async function openMenu(row: HTMLElement) {
|
|
act(() => {
|
|
fireEvent.contextMenu(row);
|
|
});
|
|
await waitFor(() =>
|
|
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
|
);
|
|
}
|
|
|
|
describe("DownloadFormats", () => {
|
|
it("lists the built-ins first, each saying what it saves", async () => {
|
|
await mount();
|
|
expect(rows().map((r) => r.textContent)).toEqual([
|
|
expect.stringContaining("downloads.profiles.modeA · MP3"),
|
|
expect.stringContaining(
|
|
"downloads.profiles.modeAv · ≤480p · downloads.profiles.short.subs · downloads.profiles.short.sponsorblock",
|
|
),
|
|
]);
|
|
});
|
|
|
|
it("a built-in's menu only duplicates; the user's own edits, duplicates and deletes", async () => {
|
|
await mount();
|
|
await openMenu(rows()[0]!);
|
|
expect(menuRows()).toEqual(["downloads.profiles.duplicate"]);
|
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
|
await openMenu(rowOf("Phone 480"));
|
|
expect(menuRows()).toEqual([
|
|
"downloads.profiles.edit",
|
|
"downloads.profiles.duplicate",
|
|
"downloads.profiles.delete",
|
|
]);
|
|
});
|
|
|
|
it("deletes only through the confirm", async () => {
|
|
const del = vi.spyOn(api, "deleteDownloadProfile").mockResolvedValue({} as never);
|
|
await mount();
|
|
mocks.confirm.mockResolvedValueOnce(false);
|
|
await openMenu(rowOf("Phone 480"));
|
|
pick("downloads.profiles.delete");
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
expect(del).not.toHaveBeenCalled();
|
|
await openMenu(rowOf("Phone 480"));
|
|
pick("downloads.profiles.delete");
|
|
await waitFor(() => expect(del).toHaveBeenCalledWith(9));
|
|
});
|
|
|
|
it("a duplicate is a new format with the original's settings and a name of its own", async () => {
|
|
const create = vi.spyOn(api, "createDownloadProfile").mockResolvedValue({} as never);
|
|
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
|
|
await mount();
|
|
await openMenu(rowOf("Phone 480"));
|
|
pick("downloads.profiles.duplicate");
|
|
// The form opens with the keyboard in its name, the field named by its label.
|
|
const name = await screen.findByRole("textbox", { name: "downloads.profiles.name" });
|
|
await waitFor(() => expect(document.activeElement).toBe(name));
|
|
expect((name as HTMLInputElement).value).toBe("");
|
|
fireEvent.change(name, { target: { value: "Phone copy" } });
|
|
fireEvent.click(screen.getByText("downloads.profiles.create"));
|
|
await waitFor(() =>
|
|
expect(create).toHaveBeenCalledWith({
|
|
name: "Phone copy",
|
|
spec: expect.objectContaining({ max_height: 480, embed_subs: true, sponsorblock: true }),
|
|
}),
|
|
);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Edit saves over the format itself", async () => {
|
|
const update = vi.spyOn(api, "updateDownloadProfile").mockResolvedValue({} as never);
|
|
await mount();
|
|
await openMenu(rowOf("Phone 480"));
|
|
pick("downloads.profiles.edit");
|
|
const name = await screen.findByDisplayValue("Phone 480");
|
|
fireEvent.change(name, { target: { value: "Phone" } });
|
|
fireEvent.click(screen.getByText("downloads.profiles.save"));
|
|
await waitFor(() =>
|
|
expect(update).toHaveBeenCalledWith(9, expect.objectContaining({ name: "Phone" })),
|
|
);
|
|
});
|
|
});
|