E32 review round 1 (F1, F2). - Issue composer: the review's focus-return token became state in S4 and was never spent, so every Preview → Edit round trip remounted the attachments and moved the focus from the toggle to the log's Review button. The token is cleared once it has been used, as the ref was. - Modal (initialFocus "field"): the late-form watch moved the focus to the first control once and stopped. A failed load's Retry then gave way to the spinner, its focus fell to <body> inside the aria-modal dialog, and the form's first field never got it. The watch now re-arms when the focus falls to <body>: it waits on the ✕ and moves to the first field when the form arrives. Focus in a dialog stacked on top is left alone.
91 lines
3.9 KiB
TypeScript
91 lines
3.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// E32 S2: the admin's download-quota editor rendered NOTHING while its quota loaded and nothing when
|
|
// the load failed, so the click looked dead. It now opens at once and says which it is. The save
|
|
// turns the GB field back into bytes; the reset drops the override.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
import { api } from "../lib/api";
|
|
import { QuotaModal } from "./downloadAdmin";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
};
|
|
});
|
|
|
|
const GiB = 1024 ** 3;
|
|
const QUOTA = { max_bytes: 5 * GiB, max_jobs: 10, max_concurrent: 2, unlimited: false };
|
|
let qc: QueryClient;
|
|
const wrap = (ui: ReactNode) => <QueryClientProvider client={qc}>{ui}</QueryClientProvider>;
|
|
const open = (onClose = () => {}) =>
|
|
render(wrap(<QuotaModal userId={4} email="a@b.c" onClose={onClose} />));
|
|
|
|
beforeEach(() => {
|
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("the download-quota editor", () => {
|
|
it("opens at once and says it is loading", () => {
|
|
vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
|
|
open();
|
|
expect(screen.getByText("downloads.admin.quotaTitle")).toBeTruthy();
|
|
expect(screen.getAllByRole("status").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("says a failed load failed, and retries it", async () => {
|
|
const get = vi
|
|
.spyOn(api, "adminGetDownloadQuota")
|
|
.mockRejectedValueOnce(new Error("down"))
|
|
.mockResolvedValueOnce(QUOTA as never);
|
|
open();
|
|
expect((await screen.findByRole("alert")).textContent).toContain("common.loadError");
|
|
fireEvent.click(screen.getByText("common.retry"));
|
|
await screen.findByDisplayValue("5");
|
|
expect(get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("gives the first field the focus when the form arrives after a retry", async () => {
|
|
// jsdom lays nothing out, so every element reads as hidden to the dialog's focus rules.
|
|
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
|
|
vi.spyOn(api, "adminGetDownloadQuota")
|
|
.mockRejectedValueOnce(new Error("down"))
|
|
.mockResolvedValueOnce(QUOTA as never);
|
|
open();
|
|
await screen.findByRole("alert");
|
|
const retry = screen.getByText("common.retry").closest("button")!;
|
|
await waitFor(() => expect(document.activeElement).toBe(retry));
|
|
fireEvent.click(retry); // the Retry gives way to the spinner: its focus must not fall to <body>
|
|
const first = await screen.findByDisplayValue("5");
|
|
await waitFor(() => expect(document.activeElement).toBe(first));
|
|
});
|
|
|
|
it("saves the GB field as bytes, and the reset drops the override", async () => {
|
|
vi.spyOn(api, "adminGetDownloadQuota").mockResolvedValue(QUOTA as never);
|
|
const set = vi.spyOn(api, "adminSetDownloadQuota").mockResolvedValue(QUOTA as never);
|
|
const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue(QUOTA as never);
|
|
const onClose = vi.fn();
|
|
open(onClose);
|
|
fireEvent.change(await screen.findByDisplayValue("5"), { target: { value: "1.5" } });
|
|
fireEvent.click(screen.getByText("downloads.admin.save"));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
|
expect(set).toHaveBeenCalledWith(4, {
|
|
max_bytes: Math.round(1.5 * GiB),
|
|
max_jobs: 10,
|
|
max_concurrent: 2,
|
|
unlimited: false,
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("downloads.admin.reset"));
|
|
await waitFor(() => expect(reset).toHaveBeenCalledWith(4));
|
|
});
|
|
});
|