Files
siftlode/frontend/src/components/UploadDialog.dom.test.tsx
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
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
2026-09-29 22:39:57 +02:00

184 lines
8.8 KiB
TypeScript

// @vitest-environment jsdom
//
// E7·S5c — the local-upload dialog. It's a general file upload (any type, no format list): a picked
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
// actually reach the ingest call, and that the success toast points at the library. i18n is
// key-passthrough.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42 })),
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
// user's bandwidth on it. Generous by default; the cap test below lowers it.
downloadUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
}));
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("../lib/api", () => ({
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage },
}));
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
import UploadDialog from "./UploadDialog";
const wrap = (onClose = vi.fn()) => {
const qc = new QueryClient();
return render(
<QueryClientProvider client={qc}>
<UploadDialog onClose={onClose} />
</QueryClientProvider>,
);
};
// The dialog portals into document.body, so query the whole document, not the render container.
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
h.uploadDownload.mockClear();
h.notify.mockClear();
h.downloadUsage.mockClear();
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
});
afterEach(cleanup);
describe("UploadDialog", () => {
it("shows the picked file's name and uploads it with the optional name", async () => {
const onClose = vi.fn();
wrap(onClose);
const f = new File(["hello world"], "notes.pdf", { type: "application/pdf" });
fireEvent.change(fileInput(), { target: { files: [f] } });
// The drop zone now surfaces the file name.
expect(screen.getByText("notes.pdf")).toBeTruthy();
// Optional display name.
const nameBox = screen.getByPlaceholderText("downloads.upload.namePlaceholder");
fireEvent.change(nameBox, { target: { value: "My notes" } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("keeps Upload disabled until a file is chosen", () => {
wrap();
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
expect(btn.disabled).toBe(true);
});
// `file.size > cap`, on the boundary in both directions — the cap is inclusive server-side
// (backend test_upload.py pins that a body of EXACTLY the cap is accepted), so a dialog that
// refused it would make a legal file un-uploadable with no way round. Two sizes either side of
// one cap, because a single over-cap case passes just as happily with `>` loosened to `>=`.
const CAP = 8;
const pickFile = (bytes: number, name = "f.bin") =>
fireEvent.change(fileInput(), {
target: { files: [new File(["x".repeat(bytes)], name)] },
});
it("refuses a file one byte over the cap, naming the limit, instead of sending it", async () => {
// Both server-side caps answer only after the transfer — and on a default install the one that
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
// moves.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
pickFile(CAP + 1, "big.bin");
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
expect(btn.disabled).toBe(true);
// The drop zone carries the reason, because a natively disabled Submit is not in the tab order
// to be asked for it.
const zone = screen.getByText("big.bin").closest("button")!;
expect(zone.getAttribute("aria-invalid")).toBe("true");
expect(zone.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id);
});
it("still highlights as a drop target while the picked file is over the cap", async () => {
// The state a user is in when they see the refusal is "replace this file". With a file
// already picked the zone's label shows its NAME rather than `dropActive`, so the border is
// the only feedback that a drag is over a live target — and a danger border that outranks the
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
// does not exist. The drop would have worked.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
pickFile(CAP + 1, "big.bin");
const zone = screen.getByText("big.bin").closest("button")!;
expect(zone.className).toContain("border-danger");
fireEvent.dragEnter(zone);
expect(zone.className).toContain("border-accent");
expect(zone.className).not.toContain("border-danger");
});
it("swallows a Submit press that arrives while the button still holds focus", async () => {
// The OTHER half of "never sent": the test above covers the natively disabled button (not
// focused), this one covers the soft-disabled one. `ui/Button` is focus-safe, so a button that
// turns unavailable while it HOLDS focus stays in the tab order and stays clickable, and it is
// the primitive's own click guard that refuses — measured: deleting the `tooBig` term from
// `submit()` changes nothing here, which is why that term is documented as belt and braces
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
// after dropping a bigger file does not start a transfer the server will refuse.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
pickFile(CAP, "fits.bin");
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
btn.focus();
fireEvent.focus(btn);
pickFile(CAP + 1, "toobig.bin");
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
fireEvent.click(btn);
expect(h.uploadDownload).not.toHaveBeenCalled();
});
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
pickFile(CAP, "exact.bin");
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
});
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
// Failing the same way again has to be announced again. The message names the CAP, not the
// file, so two over-cap picks leave the node and its text identical — and a live region that
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
// the three other alerts in this app that can repeat.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
pickFile(CAP + 1, "a.bin");
const first = await screen.findByRole("alert");
pickFile(CAP + 2, "b.bin");
await waitFor(() => expect(screen.getByRole("alert")).not.toBe(first));
});
});