A session's marks are weighed per field against when each page of a never-refetched list was read: a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the server's word. The live search lays them over its rows too, not only the related list. Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go through features/video/videoWrites and mark the video; the contract test covers those writes and catches a write that is named without being called on the spot. The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's menu, a notice's Undo); Restart on the related row after a description link goes back to it. The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last two open-coded reads. Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both places render from one list of controls, so the docked tooltips name the mode too. The English cap message and admin hint quote "Show more"; the hint says refunds are limited.
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Files viewer: what each kind of file shows (an image, a PDF in a frame, text as text — HTML too,
|
|
// never a page — or the "no preview" card), ← / → through the files it was given in their order,
|
|
// and closing when the file it shows leaves the list. 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, HttpError, type LibraryFile } from "../../lib/api";
|
|
|
|
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>) =>
|
|
o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key,
|
|
}),
|
|
};
|
|
});
|
|
|
|
import FilePreview from "./FilePreview";
|
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
|
import { closeKeyboardHelp, toggleKeyboardHelp, useKeyboardHelpCovered } from "../../lib/keymap";
|
|
|
|
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
|
|
({
|
|
id,
|
|
status: "done",
|
|
display_name: `File ${id}`,
|
|
can_download: true,
|
|
job_kind: "upload",
|
|
shared: false,
|
|
source_key: "upload",
|
|
asset: { media_kind: "file", preview, container: "x", size_bytes: 4096, ...extra },
|
|
}) as unknown as LibraryFile;
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mount(files: LibraryFile[], fileId: number, onClose = vi.fn()) {
|
|
const onFileChange = vi.fn();
|
|
const view = (list: LibraryFile[], id: number) => (
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<FilePreview
|
|
files={list}
|
|
fileId={id}
|
|
onFileChange={onFileChange}
|
|
onClose={onClose}
|
|
menuItems={() => []}
|
|
/>
|
|
</QueryClientProvider>
|
|
);
|
|
const r = render(view(files, fileId));
|
|
return {
|
|
onFileChange,
|
|
onClose,
|
|
rerender: (l: LibraryFile[], id: number) => r.rerender(view(l, id)),
|
|
};
|
|
}
|
|
|
|
describe("FilePreview · what it shows", () => {
|
|
it("an image as an image, from the sandboxed preview", () => {
|
|
mount([file(1, "image")], 1);
|
|
const img = screen.getByRole("img", { name: "File 1" });
|
|
expect(img.getAttribute("src")).toBe(api.previewUrl(1));
|
|
});
|
|
|
|
it("a PDF in a frame of the preview, the browser's own viewer", () => {
|
|
mount([file(2, "pdf")], 2);
|
|
const frame = document.querySelector("iframe")!;
|
|
expect(frame.getAttribute("src")).toBe(api.previewUrl(2));
|
|
expect(frame.title).toBe("File 2");
|
|
});
|
|
|
|
it("a page's source as text, line by line — never as a page", async () => {
|
|
vi.spyOn(api, "previewText").mockResolvedValue({
|
|
text: "<h1 id=x>hi</h1>\n<script>boom()</script>",
|
|
truncated: false,
|
|
});
|
|
mount([file(3, "text")], 3);
|
|
await screen.findByText("<h1 id=x>hi</h1>");
|
|
expect(screen.getByText("<script>boom()</script>")).toBeTruthy();
|
|
expect(document.getElementById("x")).toBeNull();
|
|
expect(document.querySelector("script")).toBeNull();
|
|
expect(screen.getByText("files.preview.asText")).toBeTruthy();
|
|
});
|
|
|
|
it("numbers a file's lines, the newline that ends it making no line of its own", async () => {
|
|
vi.spyOn(api, "previewText").mockResolvedValue({ text: "one\r\ntwo\n", truncated: false });
|
|
mount([file(8, "text")], 8);
|
|
await screen.findByText("one");
|
|
expect([...document.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
|
|
"1one",
|
|
"2two",
|
|
]);
|
|
});
|
|
|
|
it("lays out a long text's first lines only, and says where the rest is", async () => {
|
|
// Every line is a row of the page: a log of tens of thousands would stall the viewer.
|
|
const text = Array.from({ length: 6000 }, (_, i) => `line ${i + 1}`).join(
|
|
String.fromCharCode(10),
|
|
);
|
|
vi.spyOn(api, "previewText").mockResolvedValue({ text, truncated: false });
|
|
mount([file(9, "text")], 9);
|
|
await screen.findByText("line 1");
|
|
expect(document.querySelectorAll("li")).toHaveLength(5000);
|
|
expect(screen.getByText(/files\.preview\.firstLines/)).toBeTruthy();
|
|
expect(screen.getByText("files.preview.downloadWhole").closest("a")!.getAttribute("href")).toBe(
|
|
api.fileUrl(9),
|
|
);
|
|
});
|
|
|
|
it("says when it shows only the start of a long text", async () => {
|
|
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
|
|
mount([file(4, "text")], 4);
|
|
expect(await screen.findByText("files.preview.truncated")).toBeTruthy();
|
|
});
|
|
|
|
it("a text the server refuses (binary after all) gets the no-preview card, not an error", async () => {
|
|
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(415, "x", {}));
|
|
mount([file(5, "text")], 5);
|
|
expect(await screen.findByText("files.preview.none")).toBeTruthy();
|
|
expect(screen.queryByRole("alert")).toBeNull();
|
|
// A new tab would only show the refusal.
|
|
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
|
|
});
|
|
|
|
it("a text that fails to load says so, with a retry", async () => {
|
|
vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(500, "x", {}));
|
|
mount([file(6, "text")], 6);
|
|
expect(await screen.findByRole("alert", {}, { timeout: 3000 })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy();
|
|
});
|
|
|
|
it("anything else: the card that offers the file itself", () => {
|
|
mount([file(7, null)], 7);
|
|
expect(screen.getByText("files.preview.none")).toBeTruthy();
|
|
const save = screen.getAllByRole("link", { name: "downloads.actions.download" });
|
|
expect(save.every((a) => a.getAttribute("href") === api.fileUrl(7))).toBe(true);
|
|
// Nothing to open in a tab: the preview would refuse it.
|
|
expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("FilePreview · stepping", () => {
|
|
const list = [file(1, "image"), file(2, "pdf"), file(3, null)];
|
|
|
|
it("← / → go to the files beside it, in the order given", () => {
|
|
const { onFileChange } = mount(list, 2);
|
|
expect(screen.getByText("files.preview.position:2/3")).toBeTruthy();
|
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
|
expect(onFileChange).toHaveBeenLastCalledWith(3);
|
|
fireEvent.keyDown(window, { key: "ArrowLeft" });
|
|
expect(onFileChange).toHaveBeenLastCalledWith(1);
|
|
});
|
|
|
|
it("a layer opened from it (a menu, a confirm) keeps its arrows — the viewer stays on its file", () => {
|
|
const { onFileChange } = mount(list, 2);
|
|
const confirm = pushLayer();
|
|
try {
|
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
|
expect(onFileChange).not.toHaveBeenCalled();
|
|
} finally {
|
|
removeLayer(confirm);
|
|
}
|
|
fireEvent.keyDown(window, { key: "ArrowRight" });
|
|
expect(onFileChange).toHaveBeenLastCalledWith(3);
|
|
});
|
|
|
|
// …so the help sheet opened over that layer leaves them out, and one over the viewer lists them.
|
|
it("tells the help sheet its keys wait while a layer sits above it", () => {
|
|
function Covered() {
|
|
return <span data-testid="covered">{[...useKeyboardHelpCovered()].join(",")}</span>;
|
|
}
|
|
mount(list, 2);
|
|
render(<Covered />);
|
|
const help = () => act(() => toggleKeyboardHelp());
|
|
const confirm = pushLayer();
|
|
try {
|
|
help();
|
|
expect(screen.getByTestId("covered").textContent).toBe("fileViewer");
|
|
act(() => closeKeyboardHelp());
|
|
} finally {
|
|
removeLayer(confirm);
|
|
}
|
|
help();
|
|
expect(screen.getByTestId("covered").textContent).toBe("");
|
|
act(() => closeKeyboardHelp());
|
|
});
|
|
|
|
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
|
|
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
|
|
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
|
|
await screen.findByText("a very long line");
|
|
const region = screen.getByRole("region");
|
|
Object.defineProperty(region, "scrollWidth", { value: 900 });
|
|
Object.defineProperty(region, "clientWidth", { value: 300 });
|
|
fireEvent.keyDown(region, { key: "ArrowRight" });
|
|
expect(onFileChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a text whose lines fit pages under ← / →, even with the keyboard in it", async () => {
|
|
vi.spyOn(api, "previewText").mockResolvedValue({ text: "short", truncated: false });
|
|
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
|
|
await screen.findByText("short");
|
|
const region = screen.getByRole("region");
|
|
Object.defineProperty(region, "scrollWidth", { value: 300 });
|
|
Object.defineProperty(region, "clientWidth", { value: 300 });
|
|
fireEvent.keyDown(region, { key: "ArrowRight" });
|
|
expect(onFileChange).toHaveBeenLastCalledWith(9);
|
|
});
|
|
|
|
it("Escape closes it — after a layer opened above it has taken its own", () => {
|
|
const { onClose } = mount(list, 2);
|
|
const menu = pushLayer();
|
|
try {
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
} finally {
|
|
removeLayer(menu);
|
|
}
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("stops at either end", () => {
|
|
const { onFileChange } = mount(list, 1);
|
|
fireEvent.keyDown(window, { key: "ArrowLeft" });
|
|
expect(onFileChange).not.toHaveBeenCalled();
|
|
expect(
|
|
screen.getByRole("button", { name: "files.preview.prev" }).hasAttribute("disabled"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("closes when the file it shows leaves the list", async () => {
|
|
const { onClose, rerender } = mount(list, 2);
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
act(() => rerender([list[0]!, list[2]!], 2));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
});
|