Files
siftlode/frontend/src/components/downloadRow.dom.test.tsx
T
peter fb8fb980e5 feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
2026-10-12 15:28:31 +02:00

138 lines
4.5 KiB
TypeScript

// @vitest-environment jsdom
//
// A job's picture (the Files list and grid): with none at all, a quiet glyph of what it is; a remote
// thumbnail that fails falls back to the same glyph. i18n is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
import { JobThumb } from "./downloadRow";
import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = {
id: 1,
status: "done",
progress: 100,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: "abc",
source_url: null,
poster_url: null,
profile_id: null,
spec: {
mode: "av",
max_height: null,
container: null,
audio_format: null,
vcodec: null,
embed_subs: false,
embed_chapters: false,
embed_thumbnail: false,
sponsorblock: false,
},
display_name: "Clip",
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: true,
folder_id: null,
kept: false,
expires_at: null,
asset: null,
};
const withAsset = (
container: string | null,
over: Partial<NonNullable<DownloadJob["asset"]>> = {},
): DownloadJob => ({
...baseJob,
asset: {
id: 10,
status: "ready",
title: "Clip",
uploader: null,
uploader_url: null,
upload_date: null,
duration_s: 90,
size_bytes: 148_800_000,
width: 1920,
height: 1080,
container,
media_kind: "video",
thumbnail_url: null,
...over,
},
});
afterEach(cleanup);
// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
describe("JobThumb · no picture", () => {
it("draws a film glyph for a video, a note for audio", () => {
const { container, unmount } = render(<JobThumb job={withAsset("mp4")} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
unmount();
const { container: audio } = render(
<JobThumb job={withAsset("m4a", { media_kind: "audio" })} />,
);
expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
});
// The remote thumbnail was the last resort with no fallback of its own — when it
// failed too (a dead CDN link), the row drew the browser's broken-image mark.
it("falls back to the glyph when the remote picture fails too", () => {
const { container } = render(
<JobThumb job={withAsset("mp4", { thumbnail_url: "https://cdn.example/dead.jpg" })} />,
);
const img = container.querySelector("img")!;
expect(img.getAttribute("src")).toBe("https://cdn.example/dead.jpg");
fireEvent.error(img);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-film")).not.toBeNull();
});
});
// A file that is not media: an image is its own picture (its sandboxed preview), unless it is too big
// for a thumbnail or fails; anything else draws the file glyph.
describe("JobThumb · a file", () => {
const image = (over: Partial<NonNullable<DownloadJob["asset"]>> = {}) =>
withAsset("png", { media_kind: "file", preview: "image", size_bytes: 200_000, ...over });
it("shows an image through its preview", () => {
const { container } = render(<JobThumb job={image()} />);
expect(new URL(container.querySelector("img")!.src).pathname).toBe("/api/files/1/preview");
});
it("keeps the glyph for an image too big to be a thumbnail, and for other files", () => {
const { container, unmount } = render(<JobThumb job={image({ size_bytes: 50_000_000 })} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
unmount();
const { container: pdf } = render(
<JobThumb job={withAsset("pdf", { media_kind: "file", preview: "pdf" })} />,
);
expect(pdf.querySelector("img")).toBeNull();
});
it("falls back to the glyph when the image fails", () => {
const { container } = render(<JobThumb job={image()} />);
fireEvent.error(container.querySelector("img")!);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
});
});