// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FileDropControl } from "./file-drop-control";
import type { FileDropPresentation } from "./file-drop-types";
afterEach(() => {
cleanup();
});
const modelPresentation = {
accept: ".gltf,.glb",
allowsFileBatch: true,
allowsFolderSelection: true,
emptyDescription: "or drop its local dependencies",
emptyTitle: "Upload model",
items: [],
presenter: "model-item",
secondaryActions: [],
} satisfies FileDropPresentation<"model">;
describe("FileDropControl folder selection", () => {
it("exposes typed presentation feedback for browser evidence", () => {
render(
,
);
const alert = screen.getByRole("alert");
expect(alert.textContent).toBe("A referenced texture is unavailable.");
expect(alert.getAttribute("data-file-drop-feedback-code")).toBe("missing-appearance-resource");
});
it("preserves a selected model folder as one physical file batch", () => {
const onFilesSelect = vi.fn();
const onFolderSelect = vi.fn();
const files = [
new File([new Uint8Array([1])], "scene.gltf"),
new File([new Uint8Array([2])], "mesh.bin"),
];
Object.defineProperty(files[0], "webkitRelativePath", {
value: "robot/scene.gltf",
});
Object.defineProperty(files[1], "webkitRelativePath", {
value: "robot/mesh.bin",
});
render(
,
);
const folderInput = screen.getByLabelText("Upload model folder") as HTMLInputElement;
fireEvent.change(folderInput, { target: { files } });
expect(folderInput.hasAttribute("webkitdirectory")).toBe(true);
expect(folderInput.hasAttribute("directory")).toBe(true);
expect(folderInput.accept).toBe("");
expect(onFolderSelect).toHaveBeenCalledWith(files);
expect(onFilesSelect).not.toHaveBeenCalled();
expect(files.map((file) => file.webkitRelativePath)).toEqual([
"robot/scene.gltf",
"robot/mesh.bin",
]);
});
it("does not expose folder selection for an ordinary file presentation", () => {
render(
,
);
expect(screen.queryByRole("button", { name: "Choose folder" })).toBeNull();
expect(screen.queryByLabelText("Upload file folder")).toBeNull();
});
});
describe("FileDropControl collection actions", () => {
const filePresentation = {
accept: "image/*",
allowsFileBatch: true,
allowsFolderSelection: false,
emptyDescription: "or drop bump maps",
emptyTitle: "Upload bump maps",
items: [],
presenter: "file-list",
secondaryActions: [],
} satisfies FileDropPresentation<"file">;
it("adds an empty upload slot before selecting another file", () => {
const onFilesSelect = vi.fn();
render(
,
);
fireEvent.click(screen.getByRole("button", { name: "Add bump map" }));
expect(onFilesSelect).not.toHaveBeenCalled();
expect(
(screen.getByRole("button", { name: "Add bump map" }) as HTMLButtonElement)
.disabled,
).toBe(true);
const input = screen.getByLabelText("Upload bump map 2") as HTMLInputElement;
const file = new File([new Uint8Array([1])], "grain.png", {
type: "image/png",
});
fireEvent.change(input, { target: { files: [file] } });
expect(onFilesSelect).toHaveBeenCalledWith([file]);
});
it("removes the final item and suppresses duplicate list cardinality actions", () => {
const onItemRemove = vi.fn();
const finalItem = {
assetKind: "file" as const,
fileName: "fine.png",
id: "bump-fine",
};
render(
,
);
expect(screen.queryByRole("button", { name: "Add a new file" })).toBeNull();
expect(screen.queryByRole("button", { name: "Remove broad.png" })).toBeNull();
expect(screen.queryByRole("button", { name: "Remove fine.png" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove bump map" }));
expect(onItemRemove).toHaveBeenCalledWith(finalItem, 1);
});
it("removes a pending final slot before deleting an attached file", () => {
const onItemRemove = vi.fn();
render(
,
);
fireEvent.click(screen.getByRole("button", { name: "Add bump map" }));
expect(screen.getByLabelText("Upload bump map 2")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Remove bump map" }));
expect(screen.queryByLabelText("Upload bump map 2")).toBeNull();
expect(onItemRemove).not.toHaveBeenCalled();
});
it("renders item-owned settings only under an attached upload", () => {
const renderItemContent = vi.fn((item: { id: string }) => (
Settings for {item.id}
));
render(
,
);
expect(screen.getByText("Settings for bump-broad")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Add bump map" }));
expect(screen.getByLabelText("Upload bump map 2")).toBeTruthy();
expect(renderItemContent).toHaveBeenCalled();
expect(
renderItemContent.mock.calls.every(([item]) => item.id === "bump-broad"),
).toBe(true);
});
it("separates attached logical file groups with one between-group line", () => {
const { container } = render(
Settings for {item.id}
,
}}
onFilesSelect={vi.fn()}
onItemRemove={vi.fn()}
presentation={{
...filePresentation,
items: [
{ assetKind: "file", fileName: "broad.png", id: "broad" },
{ assetKind: "file", fileName: "fine.png", id: "fine" },
],
}}
/>,
);
const groups = container.querySelectorAll(
'[data-slot="collection-item-group"]',
);
expect(groups).toHaveLength(2);
expect(groups[0]?.className).not.toContain("border-t");
expect(groups[1]?.className).toContain("border-t");
expect(container.textContent).not.toMatch(/Item 1|Item 2/u);
});
});