mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
refactor: improve tool processing, dropzone, seam carving, and format encoding
- Refactor use-tool-processor and use-pipeline-processor hooks - Enhance dropzone component with improved UX - Improve seam carving with better error handling and tests - Add JXL format encoding support to format-encoders - Update tool routes for consistent format handling - Add dropzone unit tests
This commit is contained in:
@@ -14,8 +14,27 @@ import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
|
||||
// Output formats accepted by the convert tool
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const;
|
||||
// All output formats accepted by the convert tool
|
||||
const OUTPUT_FORMATS = [
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
"avif",
|
||||
"tiff",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jxl",
|
||||
"bmp",
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
"psd",
|
||||
] as const;
|
||||
|
||||
// Formats whose CLI encoder (cjxl, heif-enc, opj_compress, magick) may not
|
||||
// be installed in every dev/CI environment. Allow graceful 422 for these.
|
||||
const CLI_ENCODED_FORMATS = new Set(["heic", "heif", "jxl", "bmp", "ico", "jp2", "qoi", "psd"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
@@ -108,8 +127,10 @@ describe("Format conversion matrix", () => {
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// HEIC encode/decode requires libheif which may not be installed (Windows, some Linux)
|
||||
if (res.statusCode === 422 && (inputFmt === "heic" || outputFmt === "heic")) return;
|
||||
// CLI-encoded formats may not have their encoder installed in every environment
|
||||
if (res.statusCode === 422 && (inputFmt === "heic" || CLI_ENCODED_FORMATS.has(outputFmt))) {
|
||||
return;
|
||||
}
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||
@@ -146,8 +167,8 @@ describe("SVG via convert tool", () => {
|
||||
body: payload,
|
||||
});
|
||||
|
||||
// HEIC encode/decode requires libheif which may not be installed (Windows, some Linux)
|
||||
if (res.statusCode === 422 && outputFmt === "heic") return;
|
||||
// CLI-encoded formats may not have their encoder installed in every environment
|
||||
if (res.statusCode === 422 && CLI_ENCODED_FORMATS.has(outputFmt)) return;
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("sharp", () => {
|
||||
const mockSharp = vi.fn(() => ({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}));
|
||||
@@ -44,6 +45,7 @@ beforeEach(() => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -71,6 +73,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -222,6 +225,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
// 4000x3000 = 12MP, should give timeout > 120s
|
||||
metadata: vi.fn().mockResolvedValue({ width: 4000, height: 3000 }),
|
||||
@@ -257,6 +261,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -275,6 +280,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
// 6000x5000 = 30 MP, exceeds 25 MP limit
|
||||
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
||||
@@ -286,40 +292,73 @@ describe("seamCarve", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when dimension reduction exceeds 75%", async () => {
|
||||
it("pre-resizes when width reduction exceeds 75%", async () => {
|
||||
let callCount = 0;
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
metadata: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount <= 1) return Promise.resolve({ width: 800, height: 600 });
|
||||
return Promise.resolve({ width: 200, height: 150 });
|
||||
}),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
const { seamCarve } = await importFresh();
|
||||
// Requesting width 100 from 800 is a 87.5% reduction (ratio 0.125 < 0.25)
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 100 })).rejects.toThrow(
|
||||
"cannot reduce dimensions by more than 75%",
|
||||
);
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 100 })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws when height reduction exceeds 75%", async () => {
|
||||
it("pre-resizes when height reduction exceeds 75%", async () => {
|
||||
let callCount = 0;
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
metadata: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount <= 1) return Promise.resolve({ width: 800, height: 600 });
|
||||
return Promise.resolve({ width: 200, height: 150 });
|
||||
}),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
const { seamCarve } = await importFresh();
|
||||
// Requesting height 100 from 600 is an 83% reduction (ratio 0.167 < 0.25)
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { height: 100 })).rejects.toThrow(
|
||||
"cannot reduce dimensions by more than 75%",
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { height: 100 })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("pre-resizes large image for square mode with small target", async () => {
|
||||
let callCount = 0;
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount <= 1) return Promise.resolve({ width: 3775, height: 5662 });
|
||||
return Promise.resolve({ width: 944, height: 1416 });
|
||||
}),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
const { seamCarve } = await importFresh();
|
||||
await expect(
|
||||
seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 500, height: 500, square: true }),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const calls = mockExecFileAsync.mock.calls;
|
||||
const caireCall = calls.find((c: unknown[]) => Array.isArray(c[1]) && c[1].includes("-square"));
|
||||
expect(caireCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes only width when height is not specified", async () => {
|
||||
@@ -382,6 +421,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -397,6 +437,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -440,6 +481,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 1200, height: 400 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -461,6 +503,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockRejectedValue(new Error("Corrupt file header")),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -476,6 +519,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockRejectedValue(new Error("JPEG encode failed")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -520,6 +564,7 @@ describe("seamCarve", () => {
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
@@ -530,6 +575,31 @@ describe("seamCarve", () => {
|
||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 200 })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("timeout is always an integer", async () => {
|
||||
vi.mocked(sharp).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
png: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
resize: vi.fn().mockReturnThis(),
|
||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||
// 3775x5662 = 21.37 MP -- produces a float if not rounded
|
||||
metadata: vi.fn().mockResolvedValue({ width: 3775, height: 5662 }),
|
||||
}) as unknown as ReturnType<typeof sharp>,
|
||||
);
|
||||
|
||||
const { seamCarve } = await importFresh();
|
||||
await seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||
|
||||
const calls = mockExecFileAsync.mock.calls;
|
||||
const caireCall = calls.find(
|
||||
(c: unknown[]) => Array.isArray(c[1]) && c[1].includes("-preview=false"),
|
||||
);
|
||||
expect(caireCall).toBeDefined();
|
||||
const timeout = caireCall?.[2]?.timeout;
|
||||
expect(Number.isInteger(timeout)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses unique UUID in temp file names to prevent collisions", async () => {
|
||||
const { seamCarve } = await importFresh();
|
||||
|
||||
|
||||
@@ -1307,11 +1307,10 @@ describe("seamCarve", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reductions larger than 75%", async () => {
|
||||
it("pre-resizes when reduction exceeds 75% instead of rejecting", async () => {
|
||||
// 800x600, requesting width: 100 => ratio 0.125 < 0.25
|
||||
await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).rejects.toThrow(
|
||||
"cannot reduce dimensions by more than 75%",
|
||||
);
|
||||
// Should succeed by pre-resizing to bring within 75% limit
|
||||
await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("uses original dimensions when width/height not specified", async () => {
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Dropzone, isImageFile } from "@/components/common/dropzone";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeFile(name: string, type = "image/png", size = 1024): File {
|
||||
const buf = new ArrayBuffer(size);
|
||||
return new File([buf], name, { type });
|
||||
}
|
||||
|
||||
function makeDataTransfer(files: File[]): DataTransfer {
|
||||
return { files } as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
function makePasteEvent({
|
||||
items = [],
|
||||
files = [] as File[],
|
||||
}: {
|
||||
items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>;
|
||||
files?: File[];
|
||||
}) {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: { items, files },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Simulate paste via clipboardData.items (e.g. screenshot paste in browser). */
|
||||
function pasteViaItems(files: File[]) {
|
||||
const items = files.map((f) => ({
|
||||
kind: "file" as const,
|
||||
type: f.type,
|
||||
getAsFile: () => f,
|
||||
}));
|
||||
const event = makePasteEvent({ items, files: [] as unknown as File[] });
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Simulate paste via clipboardData.files (e.g. Cmd+C files from Finder on macOS). */
|
||||
function pasteViaFiles(files: File[]) {
|
||||
const event = makePasteEvent({ items: [], files });
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spy on HTMLInputElement.prototype.click to capture the programmatically
|
||||
* created file input. Returns a getter for the captured input.
|
||||
*/
|
||||
function spyFileInput() {
|
||||
let captured: HTMLInputElement | null = null;
|
||||
vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLInputElement,
|
||||
) {
|
||||
if (this.type === "file") captured = this;
|
||||
});
|
||||
return () => captured;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isImageFile
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("isImageFile", () => {
|
||||
it("accepts files with image/* MIME type", () => {
|
||||
expect(isImageFile(makeFile("photo.jpg", "image/jpeg"))).toBe(true);
|
||||
expect(isImageFile(makeFile("photo.png", "image/png"))).toBe(true);
|
||||
expect(isImageFile(makeFile("photo.webp", "image/webp"))).toBe(true);
|
||||
expect(isImageFile(makeFile("icon.svg", "image/svg+xml"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts common image extensions even without MIME type", () => {
|
||||
const formats = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif", "tiff", "ico"];
|
||||
for (const ext of formats) {
|
||||
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts HEIC/HEIF variants", () => {
|
||||
expect(isImageFile(makeFile("photo.heic", ""))).toBe(true);
|
||||
expect(isImageFile(makeFile("photo.heif", ""))).toBe(true);
|
||||
expect(isImageFile(makeFile("photo.hif", ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts RAW camera formats", () => {
|
||||
const raw = ["dng", "cr2", "cr3", "nef", "nrw", "arw", "orf", "rw2", "raf", "pef"];
|
||||
for (const ext of raw) {
|
||||
expect(isImageFile(makeFile(`raw.${ext}`, ""))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts professional/specialized formats", () => {
|
||||
const pro = ["psd", "exr", "hdr", "tga", "eps", "dds", "qoi", "dpx", "cin"];
|
||||
for (const ext of pro) {
|
||||
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts JPEG 2000 variants", () => {
|
||||
const jp2 = ["jp2", "j2k", "j2c", "jpc", "jpf", "jpx"];
|
||||
for (const ext of jp2) {
|
||||
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts netpbm/scientific formats", () => {
|
||||
const pbm = ["pbm", "pgm", "ppm", "pnm", "pam", "pfm", "fits", "fit", "fts"];
|
||||
for (const ext of pbm) {
|
||||
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive for extensions", () => {
|
||||
expect(isImageFile(makeFile("PHOTO.HEIC", ""))).toBe(true);
|
||||
expect(isImageFile(makeFile("file.PSD", ""))).toBe(true);
|
||||
expect(isImageFile(makeFile("scan.Tiff", ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-image files", () => {
|
||||
expect(isImageFile(makeFile("doc.pdf", "application/pdf"))).toBe(false);
|
||||
expect(isImageFile(makeFile("data.json", "application/json"))).toBe(false);
|
||||
expect(isImageFile(makeFile("script.js", "text/javascript"))).toBe(false);
|
||||
expect(isImageFile(makeFile("readme.txt", "text/plain"))).toBe(false);
|
||||
expect(isImageFile(makeFile("archive.zip", "application/zip"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects files with no extension and no image MIME", () => {
|
||||
expect(isImageFile(makeFile("noext", ""))).toBe(false);
|
||||
expect(isImageFile(makeFile("noext", "application/octet-stream"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dropzone rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Dropzone", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders upload button and helper text", () => {
|
||||
render(<Dropzone />);
|
||||
expect(screen.getByText("Upload")).toBeDefined();
|
||||
expect(screen.getByText("Drop your images here")).toBeDefined();
|
||||
expect(screen.getByText("click anywhere to browse, or paste from clipboard")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows supported formats hint", () => {
|
||||
render(<Dropzone />);
|
||||
expect(screen.getByText("PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the drop zone section with aria label", () => {
|
||||
render(<Dropzone />);
|
||||
expect(screen.getByLabelText("File drop zone")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show file list when no files provided", () => {
|
||||
render(<Dropzone />);
|
||||
expect(screen.queryByText(/files selected/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show file list for a single file", () => {
|
||||
render(<Dropzone currentFiles={[makeFile("a.png")]} />);
|
||||
expect(screen.queryByText(/files selected/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows file count and list when multiple files are provided", () => {
|
||||
const files = [makeFile("a.png", "image/png", 2048), makeFile("b.jpg", "image/jpeg", 4096)];
|
||||
render(<Dropzone currentFiles={files} />);
|
||||
expect(screen.getByText("2 files selected")).toBeDefined();
|
||||
expect(screen.getByText("a.png")).toBeDefined();
|
||||
expect(screen.getByText("b.jpg")).toBeDefined();
|
||||
expect(screen.getByText("2 KB")).toBeDefined();
|
||||
expect(screen.getByText("4 KB")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click to upload
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("click to upload", () => {
|
||||
it("opens file picker when the section is clicked", () => {
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
expect(getInput()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens file picker when the Upload button is clicked", () => {
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone />);
|
||||
|
||||
fireEvent.click(screen.getByText("Upload"));
|
||||
expect(getInput()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("sets multiple attribute on the file input by default", () => {
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
expect(getInput()!.multiple).toBe(true);
|
||||
});
|
||||
|
||||
it("disables multiple when multiple=false", () => {
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone multiple={false} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
expect(getInput()!.multiple).toBe(false);
|
||||
});
|
||||
|
||||
it("sets accept attribute when accept prop is provided", () => {
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone accept="image/*" />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
const input = getInput()!;
|
||||
expect(input.accept).toContain("image/*");
|
||||
expect(input.accept).toContain(".heic");
|
||||
expect(input.accept).toContain(".psd");
|
||||
});
|
||||
|
||||
it("calls onFiles when files are selected via file picker", () => {
|
||||
const onFiles = vi.fn();
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
const input = getInput()!;
|
||||
|
||||
const file = makeFile("photo.png");
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
input.onchange!({ target: input } as unknown as Event);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("calls onFiles with multiple files from file picker", () => {
|
||||
const onFiles = vi.fn();
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
const input = getInput()!;
|
||||
|
||||
const files = [makeFile("a.png"), makeFile("b.jpg", "image/jpeg")];
|
||||
Object.defineProperty(input, "files", { value: files, configurable: true });
|
||||
input.onchange!({ target: input } as unknown as Event);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith(files);
|
||||
});
|
||||
|
||||
it("does not call onFiles when no files are selected (dialog cancelled)", () => {
|
||||
const onFiles = vi.fn();
|
||||
const getInput = spyFileInput();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
const input = getInput()!;
|
||||
|
||||
Object.defineProperty(input, "files", { value: [], configurable: true });
|
||||
input.onchange!({ target: input } as unknown as Event);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag and drop
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("drag and drop", () => {
|
||||
it("calls onFiles with image files on drop", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const png = makeFile("a.png", "image/png");
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([png]) });
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||
});
|
||||
|
||||
it("filters out non-image files on drop", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const png = makeFile("a.png", "image/png");
|
||||
const pdf = makeFile("doc.pdf", "application/pdf");
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([png, pdf]) });
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||
});
|
||||
|
||||
it("does not call onFiles when all dropped files are non-image", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const pdf = makeFile("doc.pdf", "application/pdf");
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([pdf]) });
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles multiple image files on drop", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const files = [
|
||||
makeFile("a.png", "image/png"),
|
||||
makeFile("b.jpg", "image/jpeg"),
|
||||
makeFile("c.webp", "image/webp"),
|
||||
];
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer(files) });
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith(files);
|
||||
});
|
||||
|
||||
it("accepts RAW files identified by extension on drop", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const raw = makeFile("photo.cr3", "");
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([raw]) });
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([raw]);
|
||||
});
|
||||
|
||||
it("accepts HEIC files on drop", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const heic = makeFile("photo.heic", "");
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([heic]) });
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([heic]);
|
||||
});
|
||||
|
||||
it("accepts PSD files on drop", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
const psd = makeFile("design.psd", "");
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([psd]) });
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([psd]);
|
||||
});
|
||||
|
||||
it("shows drag-active styling on dragenter and removes on dragleave", () => {
|
||||
render(<Dropzone />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
fireEvent.dragEnter(zone);
|
||||
expect(zone.className).toContain("border-primary");
|
||||
expect(zone.className).toContain("bg-primary/10");
|
||||
|
||||
fireEvent.dragLeave(zone);
|
||||
expect(zone.className).not.toContain("bg-primary/10");
|
||||
});
|
||||
|
||||
it("shows drag-active styling on dragover", () => {
|
||||
render(<Dropzone />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
fireEvent.dragOver(zone);
|
||||
expect(zone.className).toContain("bg-primary/10");
|
||||
});
|
||||
|
||||
it("removes drag styling after drop", () => {
|
||||
render(<Dropzone />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
fireEvent.dragEnter(zone);
|
||||
expect(zone.className).toContain("bg-primary/10");
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([]) });
|
||||
expect(zone.className).not.toContain("bg-primary/10");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipboard paste
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("clipboard paste", () => {
|
||||
it("calls onFiles when an image is pasted", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const file = makeFile("screenshot.png", "image/png");
|
||||
pasteViaItems([file]);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("handles multiple pasted images", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const files = [makeFile("a.png", "image/png"), makeFile("b.jpg", "image/jpeg")];
|
||||
pasteViaItems(files);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith(files);
|
||||
});
|
||||
|
||||
it("accepts pasted HEIC image", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const heic = makeFile("photo.heic", "image/heic");
|
||||
pasteViaItems([heic]);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([heic]);
|
||||
});
|
||||
|
||||
it("filters out non-image files from paste", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const png = makeFile("a.png", "image/png");
|
||||
const txt = makeFile("notes.txt", "text/plain");
|
||||
pasteViaItems([png, txt]);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||
});
|
||||
|
||||
it("does not call onFiles when pasted content has no image files", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
pasteViaItems([makeFile("doc.pdf", "application/pdf")]);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores paste with no clipboardData items and no files", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const event = new Event("paste", { bubbles: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { items: [], files: [] } });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores paste with no clipboardData at all", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const event = new Event("paste", { bubbles: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: null });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores text-only paste (no file items)", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const event = new Event("paste", { bubbles: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
files: [],
|
||||
items: [{ kind: "string", type: "text/plain", getAsFile: () => null }],
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents default on paste when image files are found", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const file = makeFile("img.png", "image/png");
|
||||
const event = pasteViaItems([file]);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prevent default on paste when no image files", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const event = pasteViaItems([makeFile("doc.pdf", "application/pdf")]);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("removes paste listener on unmount", () => {
|
||||
const onFiles = vi.fn();
|
||||
const { unmount } = render(<Dropzone onFiles={onFiles} />);
|
||||
unmount();
|
||||
|
||||
pasteViaItems([makeFile("a.png", "image/png")]);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles paste where getAsFile returns null", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
files: [],
|
||||
items: [{ kind: "file", type: "image/png", getAsFile: () => null }],
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipboard paste via clipboardData.files (macOS Finder Cmd+C)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("clipboard paste via files (Finder)", () => {
|
||||
it("handles multiple files copied from Finder", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const files = [
|
||||
makeFile("photo1.png", "image/png"),
|
||||
makeFile("photo2.jpg", "image/jpeg"),
|
||||
makeFile("photo3.webp", "image/webp"),
|
||||
];
|
||||
pasteViaFiles(files);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith(files);
|
||||
});
|
||||
|
||||
it("handles a single file from Finder", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const file = makeFile("photo.png", "image/png");
|
||||
pasteViaFiles([file]);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("filters non-image files from Finder paste", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const png = makeFile("photo.png", "image/png");
|
||||
const pdf = makeFile("doc.pdf", "application/pdf");
|
||||
pasteViaFiles([png, pdf]);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||
});
|
||||
|
||||
it("ignores Finder paste with only non-image files", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
pasteViaFiles([makeFile("doc.pdf", "application/pdf")]);
|
||||
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts RAW and HEIC files from Finder", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const files = [
|
||||
makeFile("photo.heic", ""),
|
||||
makeFile("raw.cr3", ""),
|
||||
makeFile("design.psd", ""),
|
||||
];
|
||||
pasteViaFiles(files);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith(files);
|
||||
});
|
||||
|
||||
it("prefers clipboardData.files over items when both are present", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const fileFromFiles = makeFile("from-files.png", "image/png");
|
||||
const fileFromItems = makeFile("from-items.png", "image/png");
|
||||
|
||||
const event = makePasteEvent({
|
||||
files: [fileFromFiles],
|
||||
items: [{ kind: "file", type: "image/png", getAsFile: () => fileFromItems }],
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([fileFromFiles]);
|
||||
});
|
||||
|
||||
it("falls back to items when files is empty", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const file = makeFile("screenshot.png", "image/png");
|
||||
const event = makePasteEvent({
|
||||
files: [],
|
||||
items: [{ kind: "file", type: "image/png", getAsFile: () => file }],
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("prevents default when files are found via clipboardData.files", () => {
|
||||
const onFiles = vi.fn();
|
||||
render(<Dropzone onFiles={onFiles} />);
|
||||
|
||||
const event = pasteViaFiles([makeFile("photo.png", "image/png")]);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("compact mode", () => {
|
||||
it("renders without min-height in compact mode", () => {
|
||||
render(<Dropzone compact />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
expect(zone.className).toContain("min-h-0");
|
||||
expect(zone.className).not.toContain("min-h-[400px]");
|
||||
});
|
||||
|
||||
it("uses standard min-height in default mode", () => {
|
||||
render(<Dropzone />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
expect(zone.className).toContain("min-h-[400px]");
|
||||
expect(zone.className).not.toContain("min-h-0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No onFiles callback (graceful no-op)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("without onFiles callback", () => {
|
||||
it("does not throw on drop without onFiles", () => {
|
||||
render(<Dropzone />);
|
||||
const zone = screen.getByLabelText("File drop zone");
|
||||
|
||||
expect(() => {
|
||||
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([makeFile("a.png")]) });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw on paste without onFiles", () => {
|
||||
render(<Dropzone />);
|
||||
|
||||
expect(() => {
|
||||
pasteViaItems([makeFile("a.png")]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw on click without onFiles", () => {
|
||||
spyFileInput();
|
||||
render(<Dropzone />);
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user