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); }); });