Files
siftlode/frontend/src/components/FilesRail.dom.test.tsx
T
peter 8b2260c9a8 fix(files): review round 1, the page — honest states, the keyboard, the tray's words
- 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.
2026-10-13 00:14:09 +02:00

186 lines
6.8 KiB
TypeScript

// @vitest-environment jsdom
//
// The Files rail's folder management: a new folder is named in place, a folder's right click opens its
// ONE menu (the page's "…" opens the same — Files.menu.dom.test), and a rename happens in the entry.
// A field left by Enter or Escape hands the keyboard back to what stands there now, never to <body>.
// The SidePanel chrome around the groups is stubbed; the writes are watched at the API.
import { afterEach, beforeAll, 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 type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
});
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
vi.mock("./FilterRail", () => ({
default: ({
items,
children,
}: {
items: { id: string; body: ReactNode }[];
children: ReactNode;
}) => (
<>
{items.map((i) => (
<div key={i.id}>{i.body}</div>
))}
{children}
</>
),
}));
import FilesRail from "./FilesRail";
import { api, type FileFolder } from "../lib/api";
import { defaultLayout } from "../lib/panelLayout";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
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: "Receptek", retention_days: null });
async function mount(folders: FileFolder[] = [YT, OWN]) {
vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<FilesRail
layout={defaultLayout("files")}
setLayout={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
/>
</QueryClientProvider>,
);
await screen.findByText("files.folder.new");
}
const entry = (name: string) => screen.getByText(name).closest<HTMLElement>("button")!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
describe("FilesRail · folders", () => {
it("names a new folder in place; Enter makes it and gives the keyboard back", async () => {
await mount();
const create = vi.spyOn(api, "createFolder").mockResolvedValue(folder(3, { kind: "own" }));
fireEvent.click(screen.getByText("files.folder.new"));
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
fireEvent.change(field, { target: { value: " Cimboráknak " } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(create).toHaveBeenCalledWith("Cimboráknak"));
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByText("files.folder.new").closest("button")),
);
});
it("Escape makes nothing; an empty name makes nothing", async () => {
await mount();
const create = vi.spyOn(api, "createFolder");
fireEvent.click(screen.getByText("files.folder.new"));
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
fireEvent.change(field, { target: { value: "Más" } });
fireEvent.keyDown(field, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
fireEvent.click(screen.getByText("files.folder.new"));
fireEvent.keyDown(screen.getByRole("textbox", { name: "files.folder.newName" }), {
key: "Enter",
});
await act(async () => {});
expect(create).not.toHaveBeenCalled();
});
it("a folder's right click opens its menu; a source folder's holds only its retention", async () => {
await mount();
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() =>
expect(menuRows()).toEqual([
"files.folder.rename",
"files.folder.retention",
"files.folder.delete",
]),
);
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
act(() => {
fireEvent.contextMenu(entry("files.source.youtube"));
});
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
});
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
await mount();
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
act(() => {
fireEvent.contextMenu(entry("Receptek"));
});
await waitFor(() => expect(menuRows()).toContain("files.folder.rename"));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.folder.rename")!,
);
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
fireEvent.change(field, { target: { value: "Ételek" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Ételek" }));
await waitFor(() => expect(document.activeElement).toBe(entry("Receptek")));
});
});
describe("FilesRail · what it says when a part is empty or missing", () => {
it("says nothing was downloaded only when nothing is there — not when it all moved into own folders", async () => {
await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 3 }]);
expect(screen.queryByText("files.rail.noSources")).toBeNull();
cleanup();
await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 0 }]);
expect(screen.getByText("files.rail.noSources")).toBeTruthy();
});
it("a storage figure that failed to load says so, rather than the meter quietly not being there", async () => {
vi.spyOn(api, "fileFolders").mockResolvedValue([YT, OWN]);
vi.spyOn(api, "filesUsage").mockRejectedValue(new Error("down"));
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<FilesRail
layout={defaultLayout("files")}
setLayout={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
/>
</QueryClientProvider>,
);
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
});
});