- past the list's cap a search waits in a skeleton, never showing the newest files' 'no match' for a file the server is about to find; a sort keeps its stand-in rows - the viewer keeps the file it was opened on when a server answer replaces the rows under it - a drop another zone took still clears the page's drop overlay - 'The first N items' only for a server sort; the past-the-end notice no longer claims the file exists - a failed server search is tested to say so
1179 lines
47 KiB
TypeScript
1179 lines
47 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Files page: a row has Copy link, Share and Save (on a wide list, shown while it is hovered or
|
|
// focused) and its "…" — a slot an action does not apply to stays empty, so the icons line up down
|
|
// the list — and its every action is its ONE menu,
|
|
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
|
|
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
|
|
// list. i18n is key-passthrough, so labels read as keys.
|
|
import { useState } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
act,
|
|
cleanup,
|
|
createEvent,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { api, type FileFolder, type LibraryFile } from "../lib/api";
|
|
import { formatDateTime } from "../lib/format";
|
|
import {
|
|
ALL_FILES,
|
|
FilesActionsContext,
|
|
FilesStateContext,
|
|
type FilesFocus,
|
|
type FilesView,
|
|
} from "./filesContext";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
files: [] as unknown[],
|
|
folders: [] as unknown[],
|
|
confirm: vi.fn(async (_o?: unknown) => true),
|
|
keys: [] as unknown[],
|
|
fetching: false,
|
|
titleArgs: [] as unknown[],
|
|
searched: [] as unknown[],
|
|
// The server's search/sort answer is still on its way (no data yet).
|
|
wideLoading: false,
|
|
// The server's search/sort answer failed.
|
|
wideFailed: false,
|
|
foldersFailed: false,
|
|
notify: vi.fn(),
|
|
}));
|
|
vi.mock("../lib/notifications", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../lib/notifications")>()),
|
|
notify: mocks.notify,
|
|
}));
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
i18n: { language: "en" },
|
|
t: (key: string, o?: Record<string, unknown>) => {
|
|
if (o) mocks.titleArgs.push([key, o]);
|
|
return o && "folder" in o ? `${key}:${String(o.folder)}` : key;
|
|
},
|
|
}),
|
|
};
|
|
});
|
|
vi.mock("./LibraryPlayer", () => ({
|
|
default: ({ job, fromStart }: { job: { id: number }; fromStart?: boolean }) => (
|
|
<div
|
|
role="dialog"
|
|
aria-label="player"
|
|
>{`playing ${job.id}${fromStart ? " from the start" : ""}`}</div>
|
|
),
|
|
}));
|
|
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
|
vi.mock("../lib/useLiveQuery", () => ({
|
|
useLiveQuery: (key: readonly unknown[], _fn: unknown, opts?: { enabled?: boolean }) => {
|
|
if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false };
|
|
mocks.keys.push(key);
|
|
const asked = key[1] as { q?: string; sort?: string } | undefined;
|
|
if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideFailed)
|
|
return {
|
|
data: undefined,
|
|
isError: true,
|
|
isLoading: false,
|
|
isFetching: false,
|
|
refetch: vi.fn(),
|
|
};
|
|
if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideLoading)
|
|
return {
|
|
data: undefined,
|
|
isError: false,
|
|
isLoading: true,
|
|
isFetching: true,
|
|
refetch: vi.fn(),
|
|
};
|
|
return {
|
|
data:
|
|
key[0] === "files"
|
|
? asked?.q || asked?.sort
|
|
? mocks.searched
|
|
: mocks.files
|
|
: key[0] === "file-folders"
|
|
? mocks.foldersFailed
|
|
? undefined
|
|
: mocks.folders
|
|
: undefined,
|
|
isError: key[0] === "file-folders" && mocks.foldersFailed,
|
|
isLoading: false,
|
|
isFetching: mocks.fetching,
|
|
refetch: vi.fn(),
|
|
};
|
|
},
|
|
}));
|
|
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
|
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
import Files from "./Files";
|
|
import { FILES_LIST_CAP, type QueueJob } from "../lib/api";
|
|
import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue";
|
|
|
|
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
|
|
({
|
|
id,
|
|
status: "done",
|
|
progress: 100,
|
|
created_at: "2026-10-10T10:00:00+00:00",
|
|
source_kind: "youtube",
|
|
source_ref: `v${id}`,
|
|
source_url: null,
|
|
poster_url: null,
|
|
display_name: `Clip ${id}`,
|
|
display_uploader: null,
|
|
extra_links: [],
|
|
can_download: true,
|
|
job_kind: "download",
|
|
folder_id: 1,
|
|
kept: false,
|
|
expires_at: null,
|
|
shared: false,
|
|
source_key: "youtube",
|
|
asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 },
|
|
...extra,
|
|
}) as unknown as LibraryFile;
|
|
|
|
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
|
id,
|
|
kind: "source",
|
|
source_key: null,
|
|
name: null,
|
|
retention_days: 30,
|
|
count: 1,
|
|
size_bytes: 0,
|
|
created_at: null,
|
|
...extra,
|
|
});
|
|
const YT = folder(1, { source_key: "youtube" });
|
|
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
mocks.confirm.mockClear();
|
|
mocks.keys = [];
|
|
mocks.fetching = false;
|
|
mocks.titleArgs = [];
|
|
mocks.wideLoading = false;
|
|
mocks.wideFailed = false;
|
|
mocks.notify.mockClear();
|
|
resetQueueWatch();
|
|
});
|
|
|
|
function mount(
|
|
view: FilesView = ALL_FILES,
|
|
setView = vi.fn(),
|
|
search = "",
|
|
{
|
|
focus = null,
|
|
consumeFocus = vi.fn(),
|
|
}: { focus?: FilesFocus | null; consumeFocus?: () => void } = {},
|
|
) {
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<FilesActionsContext.Provider
|
|
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus }}
|
|
>
|
|
<FilesStateContext.Provider value={{ search, view, focus, arrived: null }}>
|
|
<Files />
|
|
</FilesStateContext.Provider>
|
|
</FilesActionsContext.Provider>
|
|
</QueryClientProvider>,
|
|
);
|
|
return setView;
|
|
}
|
|
|
|
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
|
const moreOf = (text: string) =>
|
|
rowOf(text).querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!;
|
|
// A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows.
|
|
const menuEls = () => [
|
|
...screen.getAllByRole("menuitem", { hidden: true }),
|
|
...screen.queryAllByRole("menuitemradio", { hidden: true }),
|
|
];
|
|
const menuRows = () => menuEls().map((b) => b.textContent ?? "");
|
|
const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!;
|
|
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[aria-label], a[aria-label]")].map((b) =>
|
|
b.getAttribute("aria-label"),
|
|
);
|
|
|
|
describe("Files · my file's row", () => {
|
|
it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => {
|
|
mocks.files = [file(2, { source_url: "https://youtu.be/x" })];
|
|
mocks.folders = [YT, OWN];
|
|
mount();
|
|
expect(rowButtons("Clip 2")).toEqual([
|
|
"files.actions.copyLink",
|
|
"files.actions.share",
|
|
"downloads.actions.download",
|
|
"files.rowMenu",
|
|
]);
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
expect(menuRows()).toEqual([
|
|
"downloads.actions.play",
|
|
"files.actions.copyLink",
|
|
"files.actions.share",
|
|
"downloads.actions.download",
|
|
"downloads.actions.edit",
|
|
"files.actions.rename",
|
|
"files.actions.move",
|
|
"files.actions.keep",
|
|
"files.actions.details",
|
|
"files.actions.redownload",
|
|
"common.copy.title",
|
|
"files.actions.copySourceLink",
|
|
"files.actions.delete",
|
|
]);
|
|
});
|
|
|
|
it("holds back the quick icons on a wide row until it is hovered or focused; the '…' stays", () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
const row = rowOf("Clip 2");
|
|
const quick = row.querySelector<HTMLElement>("[data-quick-actions]")!;
|
|
expect(row.className).toMatch(/(^|\s)group(\s|$)/);
|
|
expect(quick.className).toContain("md:opacity-0");
|
|
expect(quick.className).toContain("md:group-hover:opacity-100");
|
|
expect(quick.className).toContain("md:group-focus-within:opacity-100");
|
|
expect(quick.contains(row.querySelector('[aria-label="files.actions.share"]'))).toBe(true);
|
|
expect(quick.contains(moreOf("Clip 2"))).toBe(false);
|
|
});
|
|
|
|
it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
|
|
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
expect(rowButtons("Clip 3")).toEqual([
|
|
"files.actions.share",
|
|
"downloads.actions.download",
|
|
"files.rowMenu",
|
|
]);
|
|
// The empty slot holds the Copy link's place, the same width as an icon.
|
|
const actions = rowOf("Clip 3").querySelector(
|
|
'button[aria-label="files.actions.share"]',
|
|
)!.parentElement!;
|
|
expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true");
|
|
});
|
|
|
|
it("copies the link from the row's own icon", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
|
const create = vi
|
|
.spyOn(api, "createDownloadLink")
|
|
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
|
|
mount();
|
|
fireEvent.click(
|
|
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="files.actions.copyLink"]')!,
|
|
);
|
|
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
|
|
});
|
|
|
|
it("saves through the file's own address", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
const followed: string[] = [];
|
|
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
|
this: HTMLAnchorElement,
|
|
) {
|
|
followed.push(new URL(this.href).pathname);
|
|
});
|
|
mount();
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
|
fireEvent.click(menuItem("downloads.actions.download"));
|
|
expect(followed).toEqual(["/api/files/2/file"]);
|
|
const icon = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
|
|
'a[aria-label="downloads.actions.download"]',
|
|
)!;
|
|
expect(new URL(icon.href).pathname).toBe("/api/files/2/file");
|
|
});
|
|
|
|
it("renames in the row: Enter saves, an emptied name gives the title back", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.rename"));
|
|
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
|
fireEvent.change(field, { target: { value: " Új név " } });
|
|
fireEvent.keyDown(field, { key: "Enter" });
|
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
|
|
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
|
// The keyboard is handed back to the row, not dropped on <body>.
|
|
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
|
|
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.rename"));
|
|
const again = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
|
fireEvent.change(again, { target: { value: "" } });
|
|
fireEvent.keyDown(again, { key: "Enter" });
|
|
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
|
|
});
|
|
|
|
it("a rename left as it was sends nothing — a name never set stays unset", async () => {
|
|
// The field starts on the title the file shows; saving that would pin it as a name of its own.
|
|
mocks.files = [file(2, { display_name: null, asset: { title: "Its own title" } } as never)];
|
|
mocks.folders = [YT];
|
|
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Its own title")));
|
|
fireEvent.click(menuItem("files.actions.rename"));
|
|
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
|
expect((field as HTMLInputElement).value).toBe("Its own title");
|
|
fireEvent.keyDown(field, { key: "Enter" });
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(),
|
|
);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Escape leaves the name as it was", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.rename"));
|
|
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
|
|
fireEvent.change(field, { target: { value: "Más" } });
|
|
fireEvent.keyDown(field, { key: "Escape" });
|
|
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
|
expect(update).not.toHaveBeenCalled();
|
|
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
|
|
});
|
|
|
|
it("moves to an own folder, and from there back to its source folder only", async () => {
|
|
mocks.files = [file(2, { folder_id: 2 })];
|
|
// Another source folder first, so "its own" is chosen by its key, not by its place in the list.
|
|
mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN];
|
|
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
|
|
await waitFor(() => expect(menuRows()).toContain("Cimboráknak"));
|
|
// The folder it is in, ticked; home is its own source folder, never another site's.
|
|
expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube");
|
|
expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false);
|
|
expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true");
|
|
fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube"));
|
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
|
|
});
|
|
|
|
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
|
|
mocks.files = [file(2, { folder_id: 1 })];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
|
|
await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders"));
|
|
expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true);
|
|
});
|
|
|
|
it("keeps a file, and a kept one is handed back to its folder", async () => {
|
|
mocks.files = [file(2), file(3, { kept: true })];
|
|
mocks.folders = [YT];
|
|
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
|
|
mount();
|
|
expect(rowOf("Clip 3").textContent).toContain("files.retention.kept");
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.keep"));
|
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true }));
|
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
|
|
fireEvent.click(menuItem("files.actions.unkeep"));
|
|
await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false }));
|
|
});
|
|
|
|
it("deletes only through the confirm", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
|
|
mocks.confirm.mockResolvedValueOnce(false);
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.delete"));
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
expect(del).not.toHaveBeenCalled();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.delete"));
|
|
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
|
|
});
|
|
|
|
it("a deleted file leaves the tray's finished list: its Open would reach nothing", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
|
|
const job = (status: string) => ({ id: 2, status }) as unknown as QueueJob;
|
|
noteQueue([job("running")]);
|
|
noteQueue([job("done")]);
|
|
let finished: number[] = [];
|
|
function Tray() {
|
|
finished = useFinished().map((j) => j.id);
|
|
return null;
|
|
}
|
|
render(<Tray />);
|
|
expect(finished).toEqual([2]);
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.delete"));
|
|
await waitFor(() => expect(finished).toEqual([]));
|
|
});
|
|
});
|
|
|
|
describe("Files · a library past the list's cap", () => {
|
|
it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = [file(1, { display_name: "Recept" }), file(2)];
|
|
mocks.keys = [];
|
|
mount(ALL_FILES, vi.fn(), "recept");
|
|
expect(rowOf("Recept")).toBeTruthy();
|
|
expect(
|
|
mocks.keys.some(
|
|
(k) => (k as unknown[])[0] === "files" && "q" in ((k as unknown[])[1] as object),
|
|
),
|
|
).toBe(false);
|
|
cleanup();
|
|
|
|
// The newest FILES_LIST_CAP files came back: the one searched for may be older than all of them.
|
|
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
|
|
mocks.searched = [file(9999, { display_name: "Régi recept" })];
|
|
mocks.keys = [];
|
|
mount(ALL_FILES, vi.fn(), "recept");
|
|
expect(rowOf("Régi recept")).toBeTruthy();
|
|
expect(mocks.keys).toContainEqual(["files", { q: "recept" }]);
|
|
});
|
|
|
|
const capped = () => Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
|
|
const nameHeader = () => screen.getByRole("button", { name: /files\.columns\.name/ });
|
|
|
|
it("a sort asked of the server keeps the table on screen until its answer is in", () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mocks.wideLoading = true;
|
|
mount();
|
|
const header = nameHeader();
|
|
fireEvent.click(header);
|
|
// Not a skeleton in its place: that would take the header just pressed, and the keyboard with it.
|
|
expect(header.isConnected).toBe(true);
|
|
expect(rowOf("Clip 1")).toBeTruthy();
|
|
expect(screen.getByText("files.countNewest")).toBeTruthy();
|
|
});
|
|
|
|
it("says a list the server's sort cut holds only its first files; a short search is whole", () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1000));
|
|
mount();
|
|
expect(screen.getByText("files.countNewest")).toBeTruthy();
|
|
fireEvent.click(nameHeader());
|
|
expect(screen.getByText("files.countFirst")).toBeTruthy();
|
|
cleanup();
|
|
|
|
// A search with no sort of the server's: it answers the newest matches first.
|
|
mount(ALL_FILES, vi.fn(), "clip");
|
|
expect(screen.getByText("files.countNewest")).toBeTruthy();
|
|
cleanup();
|
|
|
|
mocks.searched = [file(9999, { display_name: "Régi recept" })];
|
|
mount(ALL_FILES, vi.fn(), "recept");
|
|
expect(screen.getByText("files.count")).toBeTruthy();
|
|
});
|
|
|
|
it("a search past the cap waits for the server's answer — never a 'no match' it has not given", () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mocks.wideLoading = true;
|
|
mount(ALL_FILES, vi.fn(), "recept");
|
|
expect(screen.getByRole("status")).toBeTruthy();
|
|
expect(screen.queryByText("files.empty.search")).toBeNull();
|
|
});
|
|
|
|
it("a server search that failed says so, not the newest files searched here", () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mocks.wideFailed = true;
|
|
mount(ALL_FILES, vi.fn(), "clip");
|
|
expect(screen.getByText("common.loadError")).toBeTruthy();
|
|
expect(screen.queryByText("Clip 1")).toBeNull();
|
|
});
|
|
|
|
it("a file opened while the server's sort was on its way stays open when the answer leaves it out", async () => {
|
|
mocks.folders = [YT];
|
|
const doc = file(7, {
|
|
job_kind: "upload",
|
|
display_name: "Doc 7",
|
|
asset: { media_kind: "file", preview: "pdf", container: "pdf", size_bytes: 10 },
|
|
} as never);
|
|
mocks.files = [doc, ...capped().slice(1)];
|
|
mocks.wideLoading = true;
|
|
mount();
|
|
fireEvent.click(nameHeader());
|
|
fireEvent.click(screen.getByText("Doc 7"));
|
|
expect(await screen.findByRole("dialog")).toBeTruthy();
|
|
// The answer: the first files by name, without it.
|
|
mocks.wideLoading = false;
|
|
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1000));
|
|
fireEvent.click(nameHeader());
|
|
expect(screen.getByText("Clip 1499")).toBeTruthy(); // by name, from the end
|
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
|
});
|
|
|
|
it("an arrival waits for the server's answer: the table can only turn to a file it holds", async () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mocks.wideLoading = true;
|
|
const consumeFocus = vi.fn();
|
|
mount(ALL_FILES, vi.fn(), "clip", { focus: { id: 3, token: 1 }, consumeFocus });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(consumeFocus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a file past the end of a cut list is not said to be gone — it may only be older", async () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9999, token: 1 } });
|
|
await waitFor(() =>
|
|
expect(mocks.notify).toHaveBeenCalledWith({
|
|
level: "info",
|
|
message: "files.arrival.notListed",
|
|
}),
|
|
);
|
|
expect(mocks.notify).not.toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" });
|
|
});
|
|
|
|
it("an arrival's cleared search is asked of the server at once, not after the typing pause", () => {
|
|
mocks.folders = [YT];
|
|
mocks.files = capped();
|
|
mocks.searched = [];
|
|
const view = (search: string, focus: FilesFocus | null) => (
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<FilesActionsContext.Provider
|
|
value={{ setSearch: vi.fn(), setView: vi.fn(), openFile: vi.fn(), consumeFocus: vi.fn() }}
|
|
>
|
|
<FilesStateContext.Provider value={{ search, view: ALL_FILES, focus, arrived: null }}>
|
|
<Files />
|
|
</FilesStateContext.Provider>
|
|
</FilesActionsContext.Provider>
|
|
</QueryClientProvider>
|
|
);
|
|
const r = render(view("recept", null));
|
|
expect(mocks.keys).toContainEqual(["files", { q: "recept" }]);
|
|
mocks.keys = [];
|
|
// openFile: the search cleared and an arrival pending, in one step.
|
|
r.rerender(view("", { id: 3, token: 1 }));
|
|
expect(mocks.keys).not.toContainEqual(["files", { q: "recept" }]);
|
|
});
|
|
});
|
|
|
|
describe("Files · the confirms", () => {
|
|
it("label the confirm button with the action, not the dialog's question", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
mocks.confirm.mockResolvedValue(false);
|
|
mount();
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.redownload"));
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.delete"));
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
|
|
expect(
|
|
mocks.confirm.mock.calls.map(([o]) => {
|
|
const opts = o as { title: string; confirmLabel?: string };
|
|
return [opts.title, opts.confirmLabel];
|
|
}),
|
|
).toEqual([
|
|
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
|
|
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
|
|
]);
|
|
expect(mocks.titleArgs).toContainEqual([
|
|
"downloads.confirm.redownloadTitle",
|
|
{ name: "Clip 2" },
|
|
]);
|
|
mocks.confirm.mockResolvedValue(true);
|
|
});
|
|
|
|
it.each([
|
|
["upload", "downloads.confirm.deleteUploadBody"],
|
|
["edit", "downloads.confirm.deleteEditBody"],
|
|
["download", "downloads.confirm.deleteBody"],
|
|
] as const)("deleting a %s names it and says what happens to its file", async (kind, body) => {
|
|
// Only a download's file may stay for other users; an upload's or a clip's is theirs alone.
|
|
mocks.files = [file(5, { job_kind: kind })];
|
|
mocks.folders = [YT];
|
|
mocks.confirm.mockResolvedValueOnce(false);
|
|
mount();
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 5")));
|
|
fireEvent.click(menuItem("files.actions.delete"));
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string };
|
|
expect([opts.title, opts.message]).toEqual(["downloads.confirm.deleteTitle", body]);
|
|
expect(mocks.titleArgs).toContainEqual(["downloads.confirm.deleteTitle", { name: "Clip 5" }]);
|
|
});
|
|
});
|
|
|
|
describe("Files · a shared file's row", () => {
|
|
it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => {
|
|
mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })];
|
|
mocks.folders = [YT];
|
|
const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never);
|
|
mount({ kind: "shared" });
|
|
expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]);
|
|
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
|
|
expect(menuRows()).toEqual([
|
|
"downloads.actions.play",
|
|
"downloads.actions.download",
|
|
"downloads.actions.edit",
|
|
"common.copy.title",
|
|
"files.actions.removeShared",
|
|
]);
|
|
fireEvent.click(menuItem("files.actions.removeShared"));
|
|
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
|
|
expect(mocks.titleArgs).toContainEqual([
|
|
"downloads.confirm.removeSharedTitle",
|
|
{ name: "Clip 4" },
|
|
]);
|
|
// The shared list is its own read, and nothing can be uploaded into it.
|
|
expect(mocks.keys).toContainEqual(["files", { shared: true }]);
|
|
expect(screen.queryByText("files.actions.upload")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Files · the viewer", () => {
|
|
const doc = (id: number, preview: string | null = "pdf") =>
|
|
file(id, {
|
|
job_kind: "upload",
|
|
display_name: `Doc ${id}`,
|
|
asset: { media_kind: "file", preview, container: "pdf", size_bytes: 10 },
|
|
} as never);
|
|
const viewer = () => screen.findByRole("dialog");
|
|
|
|
it("opens a file that is not media from its name, in the sandboxed preview", async () => {
|
|
mocks.files = [file(1), doc(2)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
fireEvent.click(screen.getByText("Doc 2"));
|
|
const dialog = await viewer();
|
|
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2));
|
|
});
|
|
|
|
it("puts Preview first in such a file's menu, and it opens the viewer", async () => {
|
|
mocks.files = [doc(2, null)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Doc 2")));
|
|
expect(menuRows()[0]).toBe("files.actions.view");
|
|
fireEvent.click(menuItem("files.actions.view"));
|
|
expect(await viewer()).toBeTruthy();
|
|
expect(screen.getByText("files.preview.none")).toBeTruthy();
|
|
});
|
|
|
|
it("steps through the list's files that are not media, in the list's order", async () => {
|
|
mocks.files = [doc(2), file(1), doc(3)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
fireEvent.click(screen.getByText("Doc 2"));
|
|
const dialog = await viewer();
|
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
|
await waitFor(() =>
|
|
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(3)),
|
|
);
|
|
});
|
|
|
|
it("leaves Preview and Rename out of the viewer's own menu", async () => {
|
|
mocks.files = [doc(2)];
|
|
mocks.folders = [YT, OWN];
|
|
mount();
|
|
fireEvent.click(screen.getByText("Doc 2"));
|
|
const dialog = await viewer();
|
|
await openMenu(() =>
|
|
fireEvent.click(dialog.querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!),
|
|
);
|
|
expect(menuRows()).not.toContain("files.actions.view");
|
|
expect(menuRows()).not.toContain("files.actions.rename");
|
|
expect(menuRows()).toContain("files.actions.move");
|
|
});
|
|
});
|
|
|
|
describe("Files · the view", () => {
|
|
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
const list = screen.getByRole("table").closest<HTMLElement>(".max-w-7xl")!;
|
|
expect(list.className).not.toMatch(/(^|\s)(pt|py|p)-/);
|
|
});
|
|
|
|
it("reads one folder's files and names the folder", () => {
|
|
mocks.files = [file(2, { folder_id: 2 })];
|
|
mocks.folders = [YT, OWN];
|
|
mount({ kind: "folder", id: 2 });
|
|
expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]);
|
|
expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy();
|
|
});
|
|
|
|
it("opens the whole library when the folder it was given is gone", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT];
|
|
const setView = mount({ kind: "folder", id: 99 });
|
|
expect(mocks.keys).toContainEqual(["files", {}]);
|
|
expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]);
|
|
await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES));
|
|
});
|
|
|
|
it("searches the rows it holds by name and channel", () => {
|
|
mocks.files = [
|
|
file(2, { display_name: "Szméagol szompolyog" }),
|
|
file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }),
|
|
file(4, { display_name: "Harmadik" }),
|
|
];
|
|
mocks.folders = [YT];
|
|
mount(ALL_FILES, vi.fn(), "szméagol");
|
|
expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull();
|
|
expect(screen.queryByText("Más")).not.toBeNull();
|
|
expect(screen.queryByText("Harmadik")).toBeNull();
|
|
});
|
|
|
|
it("dates and sorts a file by when it was done, not when it was asked for", () => {
|
|
// Done in another order than asked: a download that failed for days and then went through is new.
|
|
mocks.files = [
|
|
file(2, { created_at: "2026-10-01T10:00:00Z", ready_at: "2026-10-05T10:00:00Z" }),
|
|
file(3, { created_at: "2026-10-02T10:00:00Z", ready_at: "2026-10-12T10:00:00Z" }),
|
|
file(4, { created_at: "2026-10-03T10:00:00Z", ready_at: "2026-10-03T10:00:00Z" }),
|
|
];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
const order = () =>
|
|
[...document.querySelectorAll<HTMLElement>("[data-nav-index]")].map(
|
|
(r) => r.textContent?.match(/Clip \d/)?.[0],
|
|
);
|
|
const byDone = [
|
|
["Clip 4", "Clip 2", "Clip 3"],
|
|
["Clip 3", "Clip 2", "Clip 4"],
|
|
];
|
|
fireEvent.click(screen.getByRole("button", { name: /files\.columns\.added/ }));
|
|
expect(byDone).toContainEqual(order());
|
|
fireEvent.click(screen.getByRole("button", { name: /files\.columns\.added/ }));
|
|
expect(byDone).toContainEqual(order());
|
|
expect(
|
|
rowOf("Clip 3").querySelector(`[title="${formatDateTime("2026-10-12T10:00:00Z", "en")}"]`),
|
|
).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Files · the grid", () => {
|
|
// The layout is a pref, saved to the server; nothing here is about that.
|
|
const quiet = () => vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
|
|
|
async function pick(layout: "grid" | "list") {
|
|
fireEvent.click(screen.getByRole("button", { name: /files\.layout\.label/ }));
|
|
const options = await screen.findAllByRole("menuitemradio", { hidden: true });
|
|
fireEvent.click(options.find((o) => o.textContent === `files.layout.${layout}`)!);
|
|
}
|
|
|
|
it("paints the same files as cards, each with the same menu as its row", async () => {
|
|
quiet();
|
|
mocks.files = [file(2, { kept: true }), file(3)];
|
|
mocks.folders = [YT, OWN];
|
|
mount();
|
|
expect(rowOf("Clip 2").tagName).toBe("TR");
|
|
await pick("grid");
|
|
await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV"));
|
|
// Kept says so on its card.
|
|
expect(rowOf("Clip 2").textContent).toContain("files.retention.kept");
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
expect(menuRows()).toContain("files.actions.move");
|
|
expect(menuRows()).toContain("files.actions.delete");
|
|
});
|
|
|
|
it("is remembered per folder", async () => {
|
|
quiet();
|
|
mocks.files = [file(2, { folder_id: 2 })];
|
|
mocks.folders = [YT, OWN];
|
|
// One mount whose view the test turns, as the rail would.
|
|
let turn: (v: FilesView) => void = () => {};
|
|
function Harness() {
|
|
const [view, setView] = useState<FilesView>(ALL_FILES);
|
|
turn = setView;
|
|
return (
|
|
<FilesActionsContext.Provider
|
|
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus: vi.fn() }}
|
|
>
|
|
<FilesStateContext.Provider value={{ search: "", view, focus: null, arrived: null }}>
|
|
<Files />
|
|
</FilesStateContext.Provider>
|
|
</FilesActionsContext.Provider>
|
|
);
|
|
}
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<Harness />
|
|
</QueryClientProvider>,
|
|
);
|
|
await pick("grid");
|
|
await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV"));
|
|
act(() => turn({ kind: "folder", id: 2 }));
|
|
expect(rowOf("Clip 2").tagName).toBe("TR");
|
|
act(() => turn(ALL_FILES));
|
|
expect(rowOf("Clip 2").tagName).toBe("DIV");
|
|
});
|
|
});
|
|
|
|
describe("Files · the open folder's menu", () => {
|
|
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
|
|
|
|
it("an own folder is renamed, kept for a while, or deleted", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
mount({ kind: "folder", id: 2 });
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
expect(menuRows()).toEqual([
|
|
"files.folder.rename",
|
|
"files.folder.retention",
|
|
"files.folder.delete",
|
|
]);
|
|
});
|
|
|
|
it("a source folder only keeps its files for a while", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
mount({ kind: "folder", id: 1 });
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
expect(menuRows()).toEqual(["files.folder.retention"]);
|
|
});
|
|
|
|
it("sets the retention, its current value ticked — an odd one in its place", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [folder(1, { source_key: "youtube", retention_days: 14 })];
|
|
const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never);
|
|
mount({ kind: "folder", id: 1 });
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
fireEvent.keyDown(menuItem("files.folder.retention"), { key: "ArrowRight" });
|
|
await waitFor(() => expect(menuRows()).toContain("files.folder.forever"));
|
|
const choices = screen.queryAllByRole("menuitemradio", { hidden: true });
|
|
// 7, 14 (its own, ticked), 30, 90, forever.
|
|
expect(choices.map((c) => c.getAttribute("aria-checked"))).toEqual([
|
|
"false",
|
|
"true",
|
|
"false",
|
|
"false",
|
|
"false",
|
|
]);
|
|
fireEvent.click(menuItem("files.folder.forever"));
|
|
await waitFor(() => expect(update).toHaveBeenCalledWith(1, { retention_days: null }));
|
|
});
|
|
|
|
it("renames in the title: Enter saves, an unchanged name sends nothing", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never);
|
|
mount({ kind: "folder", id: 2 });
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
fireEvent.click(menuItem("files.folder.rename"));
|
|
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
|
fireEvent.keyDown(field, { key: "Enter" });
|
|
expect(update).not.toHaveBeenCalled();
|
|
// The keyboard goes back to the "…" the rename was opened from.
|
|
await waitFor(() => expect(document.activeElement).toBe(folderMenu()));
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
fireEvent.click(menuItem("files.folder.rename"));
|
|
const again = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
|
fireEvent.change(again, { target: { value: " Receptek " } });
|
|
fireEvent.keyDown(again, { key: "Enter" });
|
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Receptek" }));
|
|
// Only the changed name was sent: the first, unchanged Enter sent nothing.
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
[3, "files.folder.deleteBody"],
|
|
[0, "files.folder.deleteBodyEmpty"],
|
|
])("deletes only through a confirm that says where its %i files go", async (count, body) => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, { ...OWN, count }];
|
|
const del = vi.spyOn(api, "deleteFolder").mockResolvedValue({} as never);
|
|
mocks.confirm.mockResolvedValueOnce(false);
|
|
mount({ kind: "folder", id: 2 });
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
fireEvent.click(menuItem("files.folder.delete"));
|
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
|
expect(del).not.toHaveBeenCalled();
|
|
const opts = mocks.confirm.mock.calls[0]![0] as Record<string, unknown>;
|
|
expect([opts.title, opts.message, opts.confirmLabel, opts.danger]).toEqual([
|
|
"files.folder.deleteTitle",
|
|
body,
|
|
"files.folder.deleteAction",
|
|
true,
|
|
]);
|
|
await openMenu(() => fireEvent.click(folderMenu()));
|
|
fireEvent.click(menuItem("files.folder.delete"));
|
|
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
|
|
});
|
|
});
|
|
|
|
describe("Files · arriving at one file", () => {
|
|
const many = () => Array.from({ length: 60 }, (_, i) => file(i + 1));
|
|
|
|
// FilesProvider's arrival half: a pending focus, spent by the page into what it found.
|
|
function arriveAt(id: number, startView: FilesView = ALL_FILES) {
|
|
const seen = { setView: vi.fn(), consumed: [] as (string | null)[] };
|
|
function Harness() {
|
|
const [view, setViewState] = useState<FilesView>(startView);
|
|
const [focus, setFocus] = useState<FilesFocus | null>({ id, token: 1 });
|
|
const [arrived, setArrived] = useState<(FilesFocus & { view: string }) | null>(null);
|
|
const actions = {
|
|
setSearch: vi.fn(),
|
|
setView: (v: FilesView) => {
|
|
seen.setView(v);
|
|
setViewState(v);
|
|
},
|
|
openFile: vi.fn(),
|
|
consumeFocus: (at: string | null) => {
|
|
seen.consumed.push(at);
|
|
setArrived(focus && at !== null ? { ...focus, view: at } : null);
|
|
setFocus(null);
|
|
},
|
|
};
|
|
return (
|
|
<FilesActionsContext.Provider value={actions}>
|
|
<FilesStateContext.Provider value={{ search: "", view, focus, arrived }}>
|
|
<Files />
|
|
</FilesStateContext.Provider>
|
|
</FilesActionsContext.Provider>
|
|
);
|
|
}
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<Harness />
|
|
</QueryClientProvider>,
|
|
);
|
|
return seen;
|
|
}
|
|
|
|
it("turns to the page that holds it, rings it and puts the keyboard on it", async () => {
|
|
mocks.files = many();
|
|
mocks.folders = [YT];
|
|
const seen = arriveAt(55);
|
|
// Page two of fifty: without the arrival it would not be on screen at all.
|
|
await waitFor(() => expect(screen.queryByText("Clip 55")).not.toBeNull());
|
|
expect(seen.consumed).toEqual(["all"]);
|
|
expect(rowOf("Clip 55").className).toContain("ring-accent");
|
|
expect(rowOf("Clip 54").className).not.toContain("ring-accent");
|
|
await waitFor(() => expect(rowOf("Clip 55").contains(document.activeElement)).toBe(true));
|
|
});
|
|
|
|
it("finds it among every file when the folder on screen does not hold it", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT, OWN];
|
|
const seen = arriveAt(2, { kind: "folder", id: 2 });
|
|
// The mock answers every view with the same rows, so "not in this folder" is staged by the
|
|
// folder's read: it is the all-files read that holds the file.
|
|
expect(seen.consumed).toEqual(["2"]);
|
|
expect(seen.setView).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("spends an arrival at a file that is nowhere, ringing nothing", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
const seen = arriveAt(9);
|
|
await waitFor(() => expect(seen.consumed).toEqual([null]));
|
|
expect(seen.setView).not.toHaveBeenCalled();
|
|
expect(rowOf("Clip 2").className).not.toContain("ring-accent");
|
|
});
|
|
|
|
it("waits while the list re-reads: a file just finished is not in the cached rows yet", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
mocks.fetching = true;
|
|
const seen = arriveAt(9);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(seen.consumed).toEqual([]);
|
|
expect(seen.setView).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("looks in every file when this folder has no such file", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT, OWN];
|
|
const seen = arriveAt(9, { kind: "folder", id: 2 });
|
|
await waitFor(() => expect(seen.setView).toHaveBeenCalledWith(ALL_FILES));
|
|
// Then, among every file, it is nowhere either: spent.
|
|
await waitFor(() => expect(seen.consumed).toEqual([null]));
|
|
});
|
|
});
|
|
|
|
const drag = (type: string, files: File[] = []) => {
|
|
const e = new Event(type, { bubbles: true, cancelable: true });
|
|
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
|
|
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
|
|
act(() => {
|
|
window.dispatchEvent(e);
|
|
});
|
|
return e;
|
|
};
|
|
|
|
describe("Files · dropping a file on the page", () => {
|
|
it("names the folder it lands in, and opens Upload with the file", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never);
|
|
mount({ kind: "folder", id: 2 });
|
|
// preventDefault is what makes the page a drop target at all.
|
|
expect(drag("dragenter").defaultPrevented).toBe(true);
|
|
expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy();
|
|
drag("drop", [new File(["x"], "recept.pdf")]);
|
|
expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull();
|
|
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
|
});
|
|
|
|
it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT];
|
|
mount({ kind: "shared" });
|
|
drag("dragenter");
|
|
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
|
|
// Left to the browser, a dropped file opens in the tab and the app is gone.
|
|
const drop = drag("drop", [new File(["x"], "recept.pdf")]);
|
|
expect(drop.defaultPrevented).toBe(true);
|
|
expect(screen.queryByText("recept.pdf")).toBeNull();
|
|
});
|
|
|
|
it("a second file dropped beside the open Upload dialog does not unload the app", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never);
|
|
mount({ kind: "folder", id: 2 });
|
|
drag("drop", [new File(["x"], "recept.pdf")]);
|
|
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
|
const beside = drag("dragover");
|
|
expect(beside.defaultPrevented).toBe(true);
|
|
expect((beside as DragEvent).dataTransfer?.dropEffect).toBe("none");
|
|
expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("a drop another zone took still ends the page's drag", () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
mount({ kind: "folder", id: 2 });
|
|
drag("dragenter");
|
|
expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy();
|
|
// Taken by a zone over the page (the issue composer's screenshot box): no dragleave follows.
|
|
const e = new Event("drop", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(e, "dataTransfer", {
|
|
value: { types: ["Files"], files: Object.assign([], { item: () => null }) },
|
|
});
|
|
e.preventDefault();
|
|
act(() => {
|
|
window.dispatchEvent(e);
|
|
});
|
|
expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull();
|
|
});
|
|
|
|
it("the open Upload dialog's own zone keeps the drop it accepted", async () => {
|
|
mocks.files = [];
|
|
mocks.folders = [YT, OWN];
|
|
vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never);
|
|
mount({ kind: "folder", id: 2 });
|
|
drag("drop", [new File(["x"], "recept.pdf")]);
|
|
const zone = (await screen.findByText("recept.pdf")).closest("button")!;
|
|
const over = createEvent.dragOver(zone, {
|
|
dataTransfer: { types: ["Files"], files: [], dropEffect: "copy" },
|
|
}) as DragEvent;
|
|
fireEvent(zone, over);
|
|
// A "none" written by the page's own listener (it runs last) would refuse the zone's drop.
|
|
expect(over.defaultPrevented).toBe(true);
|
|
expect(over.dataTransfer?.dropEffect).toBe("copy");
|
|
});
|
|
});
|
|
|
|
describe("Files · when what it needs is missing", () => {
|
|
afterEach(() => {
|
|
mocks.foldersFailed = false;
|
|
mocks.fetching = false;
|
|
mocks.notify.mockClear();
|
|
});
|
|
|
|
it("folders that failed to load are said to be missing, never shown as none — and take no drop", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.foldersFailed = true;
|
|
mount({ kind: "folder", id: 2 });
|
|
expect(screen.getByText("files.folderUnknown")).toBeTruthy();
|
|
// Dropped now, the file would land in Uploads instead of the folder on screen.
|
|
drag("dragenter");
|
|
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
fireEvent.click(menuItem("files.actions.move"));
|
|
expect(await screen.findByText("files.actions.foldersUnknown")).toBeTruthy();
|
|
expect(screen.queryByText("files.actions.noOwnFolders")).toBeNull();
|
|
});
|
|
|
|
it("a folder the cached list does not have yet is waited for, not taken for gone", () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
mocks.fetching = true;
|
|
const setView = mount({ kind: "folder", id: 7 });
|
|
expect(setView).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opening a file that is no longer there says so", async () => {
|
|
mocks.files = [file(2)];
|
|
mocks.folders = [YT];
|
|
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
|
|
await waitFor(() =>
|
|
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Files · the open folder deleted", () => {
|
|
it("hands the keyboard to the page's heading, not to <body>", async () => {
|
|
// The deleted folder's "…" held the focus and is gone with it.
|
|
mocks.files = [];
|
|
mocks.folders = [YT];
|
|
(document.activeElement as HTMLElement | null)?.blur();
|
|
mount({ kind: "folder", id: 2 });
|
|
await waitFor(() =>
|
|
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "files.rail.all" })),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Files · a file watched part way", () => {
|
|
it("leads with Continue and offers Start over, which plays it from the start", async () => {
|
|
mocks.files = [file(2, { position_seconds: 120 })];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
|
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
|
fireEvent.click(menuItem("card.restartTitle"));
|
|
expect(await screen.findByText("playing 2 from the start")).toBeTruthy();
|
|
});
|
|
|
|
it("one never played has no Start over, and plays where it is", async () => {
|
|
mocks.files = [file(3)];
|
|
mocks.folders = [YT];
|
|
mount();
|
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
|
|
expect(menuRows()).not.toContain("card.restartTitle");
|
|
fireEvent.click(menuItem("downloads.actions.play"));
|
|
expect(await screen.findByText("playing 3")).toBeTruthy();
|
|
});
|
|
});
|