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:
SnapOtter
2026-05-11 21:57:40 +08:00
parent 656a8d3d44
commit b06906025c
25 changed files with 1368 additions and 436 deletions
+27 -6
View File
@@ -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}`);
+81 -11
View File
@@ -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();
+3 -4
View File
@@ -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 () => {
+682
View File
@@ -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();
});
});
});