import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import {
FileDrop,
type FileDropPresentation,
type FileDropPreview,
} from "@repo/ui";
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
cleanup();
});
const modelItemPresentation = {
accept: ".glb",
allowsFileBatch: true,
allowsFolderSelection: true,
emptyDescription: "or drop its local dependencies",
emptyTitle: "Upload model",
items: [
{
assetKind: "model",
fileName: "scene.unknown",
id: "model-1",
},
],
presenter: "model-item",
secondaryActions: [
{
ariaLabel: "Repair model geometry",
icon: "wand-sparkles",
label: "Fix model",
state: { loading: true, loadingAriaLabel: "Optimizing model" },
value: "model.fix",
},
],
} satisfies FileDropPresentation<"model", never, never>;
describe("FileDrop presentation", () => {
it("submits one complete physical file batch for one previewless model item", () => {
const onFilesSelect = vi.fn();
const files = [
new File([new Uint8Array([1])], "scene.unknown"),
new File([new Uint8Array([2])], "mesh.bin"),
];
render(
,
);
expect(screen.getByText("scene.unknown")).toBeTruthy();
expect(screen.queryByRole("img")).toBeNull();
const input = screen.getByLabelText("Upload model") as HTMLInputElement;
expect(input.multiple).toBe(true);
const clickSpy = vi.spyOn(input, "click");
fireEvent.click(screen.getByText("scene.unknown"));
expect(clickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(input, { target: { files } });
expect(onFilesSelect).toHaveBeenCalledTimes(1);
expect(onFilesSelect).toHaveBeenCalledWith(files);
});
it("routes secondary action presentation through ActionsControl", () => {
const onAction = vi.fn();
const { container } = render(
,
);
const actionLayout = container.querySelector(
'[data-slot="actions-control-buttons"]',
);
const loadingAction = screen.getByRole("button", { name: "Optimizing model" });
expect(actionLayout?.getAttribute("data-actions-columns")).toBe("1");
expect(actionLayout?.className).toContain("w-1/2");
expect((loadingAction as HTMLButtonElement).disabled).toBe(true);
expect(loadingAction.querySelector('[data-slot="button-loader"]')).toBeTruthy();
expect(screen.queryByText("File actions")).toBeNull();
expect(onAction).not.toHaveBeenCalled();
});
it("keeps physical batch allowance separate from presented collection cardinality", () => {
render(
,
);
expect((screen.getByLabelText("Upload model") as HTMLInputElement).multiple).toBe(
false,
);
expect(screen.getByText("one.txt")).toBeTruthy();
expect(screen.getByText("two.txt")).toBeTruthy();
});
it("submits the complete drop once even when native batch picking is disabled", () => {
const onFilesSelect = vi.fn();
const files = [
new File([new Uint8Array([1])], "one.txt"),
new File([new Uint8Array([2])], "two.txt"),
];
render(
,
);
const dropTarget = screen.getByRole("group", {
name: "Upload model. or drop its local dependencies",
});
fireEvent.drop(dropTarget, {
dataTransfer: { files },
});
expect(onFilesSelect).toHaveBeenCalledTimes(1);
expect(onFilesSelect).toHaveBeenCalledWith(files);
});
it("routes an enabled secondary action through ActionsControl", () => {
const onAction = vi.fn();
render(
,
);
fireEvent.click(screen.getByRole("button", { name: "Inspect model" }));
expect(onAction).toHaveBeenCalledTimes(1);
expect(onAction).toHaveBeenCalledWith("model.inspect");
});
it("disables custom actions that have no callback instead of allowing no-ops", () => {
render(
,
);
expect(
(screen.getByRole("button", { name: "Inspect model" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("does not let parent keyboard handling steal nested add-file actions", () => {
render(
,
);
const input = screen.getByLabelText("Upload model") as HTMLInputElement;
const clickSpy = vi.spyOn(input, "click");
const addButton = screen.getByRole("button", { name: "Add a new file" });
fireEvent.keyDown(addButton, { key: "Enter" });
expect(clickSpy).not.toHaveBeenCalled();
fireEvent.click(addButton);
expect(clickSpy).toHaveBeenCalledTimes(1);
});
it("uses an announced native button for multi-image selection", () => {
const onPreviewTransform = vi.fn();
const previews: FileDropPreview[] = [
{
alt: "First image",
fileName: "first.png",
id: "image-1",
src: "data:image/png;base64,b25l",
},
{
alt: "Second image",
fileName: "second.png",
id: "image-2",
src: "data:image/png;base64,dHdv",
},
];
render(
,
);
const selectFirst = screen.getByRole("button", { name: "Select First image" });
expect(selectFirst.tagName).toBe("BUTTON");
fireEvent.click(selectFirst);
expect(selectFirst.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "90° Right" }));
expect(onPreviewTransform).toHaveBeenCalledWith(previews[0], "rotate-right");
});
it("reports original presentation indices when non-renderable image items are hidden", () => {
const onItemRemove = vi.fn();
const onItemSelect = vi.fn();
const hiddenItem = {
assetKind: "image" as const,
fileName: "missing.png",
id: "image-hidden",
};
const visibleItem = {
alt: "Visible image",
assetKind: "image" as const,
fileName: "visible.png",
id: "image-visible",
previewSrc: "data:image/png;base64,dmlzaWJsZQ==",
};
render(
,
);
fireEvent.click(screen.getByRole("button", { name: "Select Visible image" }));
fireEvent.click(screen.getByRole("button", { name: "Remove Visible image" }));
expect(onItemSelect).toHaveBeenCalledWith(visibleItem, 1);
expect(onItemRemove).toHaveBeenCalledWith(visibleItem, 1);
});
it("keeps image selection separate from the keyboard reorder handle", () => {
const onItemSelect = vi.fn();
const items = [
{
alt: "First image",
assetKind: "image" as const,
fileName: "first.png",
id: "image-1",
previewSrc: "data:image/png;base64,b25l",
},
{
alt: "Second image",
assetKind: "image" as const,
fileName: "second.png",
id: "image-2",
previewSrc: "data:image/png;base64,dHdv",
},
];
render(
,
);
const selectButton = screen.getByRole("button", { name: "Select First image" });
const reorderButton = screen.getByRole("button", { name: "Reorder First image" });
expect(selectButton).not.toBe(reorderButton);
expect(selectButton.getAttribute("aria-roledescription")).toBeNull();
expect(reorderButton.getAttribute("aria-roledescription")).toBe("sortable");
fireEvent.click(selectButton);
expect(onItemSelect).toHaveBeenCalledWith(items[0], 0);
});
it("rejects collection reorder callbacks for non-collection presenters", () => {
expect(() =>
render(
,
),
).toThrow(
'FileDrop presenter "model-item" does not support item reordering.',
);
});
it("never nests one interactive button inside another", () => {
const { container } = render(
,
);
for (const button of container.querySelectorAll("button")) {
expect(button.querySelector("button")).toBeNull();
}
});
it("treats image presentations without preview sources as empty", () => {
render(
,
);
expect(screen.getByText("Upload image")).toBeTruthy();
expect(screen.queryByRole("img")).toBeNull();
});
it("preserves the legacy empty-source empty state", () => {
render(
,
);
expect(screen.getByText("Click to upload an image")).toBeTruthy();
expect(screen.getByRole("group", { name: "Browse image file" })).toBeTruthy();
expect(screen.queryByRole("img")).toBeNull();
});
it("adapts legacy image props without losing preview metadata or actions", () => {
const onClear = vi.fn();
const onPreviewTransform = vi.fn();
const preview: FileDropPreview = {
alt: "Source image",
fileName: "source.png",
id: "image-1",
size: { height: 80, width: 120 },
src: "data:image/png;base64,c291cmNl",
transform: { flipHorizontal: true, rotationDeg: 90 },
};
render(
,
);
const image = screen.getByRole("img", { name: "Source image" });
const input = screen.getByLabelText("Click to upload an image") as HTMLInputElement;
expect(image.getAttribute("src")).toBe(preview.src);
expect(image.getAttribute("height")).toBe("80");
expect(image.getAttribute("width")).toBe("120");
expect(image.getAttribute("style")).toContain("rotate(90deg)");
expect(image.getAttribute("style")).toContain("scale(-1, 1)");
expect(input.accept).toBe(".png,image/png,.jpg,.jpeg,image/jpeg");
fireEvent.click(screen.getByRole("button", { name: "90° Right" }));
fireEvent.click(screen.getByRole("button", { name: "Remove Source image" }));
expect(onPreviewTransform).toHaveBeenCalledWith(preview, "rotate-right");
expect(onClear).toHaveBeenCalledTimes(1);
});
});