// @vitest-environment jsdom import { renderHook, act } from "@testing-library/react"; import { observable, ObservableHint } from "@legendapp/state"; import type { OpaqueObject } from "@legendapp/state"; import { describe, it, expect, vi, afterEach } from "vitest"; import { useDropZone } from "."; // --------------------------------------------------------------------------- // jsdom DragEvent polyfill // --------------------------------------------------------------------------- if (typeof window !== "undefined" && !window.DragEvent) { (globalThis as any).DragEvent = class DragEvent extends MouseEvent { dataTransfer: DataTransfer | null = null; constructor(type: string, init: DragEventInit = {}) { super(type, init); } }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const wrapEl = (el: Element) => observable | null>(ObservableHint.opaque(el)); function createDiv() { return document.createElement("div"); } function createFile(name: string, type: string): File { return new File(["content"], name, { type }); } function createDragEvent(type: string, files: File[] = [], mimeTypes: string[] = []): DragEvent { const event = new DragEvent(type, { bubbles: true, cancelable: true }); // Build DataTransferItemList-like object const items = mimeTypes.map((mime) => ({ type: mime, kind: "file" as const, })); const itemsProxy = new Proxy(items, { get(target, prop) { if (prop === "length") return target.length; if (prop === Symbol.iterator) return target[Symbol.iterator].bind(target); const idx = Number(prop); if (!isNaN(idx)) return target[idx]; return undefined; }, }) as unknown as DataTransferItemList; // Build FileList-like object const filesProxy = new Proxy(files, { get(target, prop) { if (prop === "length") return target.length; if (prop === Symbol.iterator) return target[Symbol.iterator].bind(target); const idx = Number(prop); if (!isNaN(idx)) return target[idx]; return undefined; }, }) as unknown as FileList; const dataTransfer = { items: itemsProxy, files: filesProxy, dropEffect: "none" as DataTransfer["dropEffect"], }; Object.defineProperty(event, "dataTransfer", { value: dataTransfer, writable: true, configurable: true, }); return event; } function fireDragEvent(target: EventTarget, event: DragEvent) { act(() => { target.dispatchEvent(event); }); } // --------------------------------------------------------------------------- // useDropZone tests // --------------------------------------------------------------------------- describe("useDropZone()", () => { afterEach(() => { vi.restoreAllMocks(); }); // TCs moved to index.browser.spec.ts (Type-A: real browser DataTransfer needed): // - valid file drop detected, invalid file type filtered, // - nested dragenter/dragleave sequence, dragenter→drop isOverDropZone$ transitions describe("initial values", () => { it("initial isOverDropZone$ is false", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); expect(result.current.isOverDropZone$.get()).toBe(false); }); it("initial files$ is null", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); expect(result.current.files$.get()).toBeNull(); }); }); describe("core behavior", () => { it("dragenter sets isOverDropZone$ to true", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); fireDragEvent(div, createDragEvent("dragenter", [], [])); expect(result.current.isOverDropZone$.get()).toBe(true); }); it("dragleave resets isOverDropZone$ to false when counter reaches 0", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); fireDragEvent(div, createDragEvent("dragenter", [], [])); expect(result.current.isOverDropZone$.get()).toBe(true); fireDragEvent(div, createDragEvent("dragleave", [], [])); expect(result.current.isOverDropZone$.get()).toBe(false); }); it("drop resets isOverDropZone$ to false", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); fireDragEvent(div, createDragEvent("dragenter", [], [])); expect(result.current.isOverDropZone$.get()).toBe(true); fireDragEvent(div, createDragEvent("drop", [], [])); expect(result.current.isOverDropZone$.get()).toBe(false); }); }); it("dragleave when counter is 0 does not underflow", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); fireDragEvent(div, createDragEvent("dragleave", [], [])); expect(result.current.isOverDropZone$.get()).toBe(false); }); it("multiple: false returns only first file", () => { const div = createDiv(); const files = [ createFile("a.png", "image/png"), createFile("b.png", "image/png"), createFile("c.png", "image/png"), ]; const { result } = renderHook(() => useDropZone(wrapEl(div) as any, { multiple: false })); const mimeTypes = files.map((f) => f.type); const dropEvent = createDragEvent("drop", files, mimeTypes); fireDragEvent(div, dropEvent); expect(result.current.files$.get()).toHaveLength(1); expect(result.current.files$.get()![0].name).toBe("a.png"); }); it("multiple: true (default) returns all files", () => { const div = createDiv(); const files = [ createFile("a.png", "image/png"), createFile("b.png", "image/png"), createFile("c.png", "image/png"), ]; const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); const mimeTypes = files.map((f) => f.type); const dropEvent = createDragEvent("drop", files, mimeTypes); fireDragEvent(div, dropEvent); expect(result.current.files$.get()).toHaveLength(3); }); it("dataTypes as function — custom validation", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any, { dataTypes: (types) => types.every((t) => t.startsWith("image/")), }) ); // valid: all image/ const validFiles = [createFile("a.jpeg", "image/jpeg"), createFile("b.png", "image/png")]; fireDragEvent( div, createDragEvent( "drop", validFiles, validFiles.map((f) => f.type) ) ); expect(result.current.files$.get()).toHaveLength(2); // invalid: includes text/plain const invalidFiles = [createFile("a.png", "image/png"), createFile("b.txt", "text/plain")]; fireDragEvent( div, createDragEvent( "drop", invalidFiles, invalidFiles.map((f) => f.type) ) ); expect(result.current.files$.get()).toBeNull(); }); it("checkValidity takes priority over dataTypes", () => { const div = createDiv(); const file = createFile("doc.txt", "text/plain"); const { result } = renderHook(() => useDropZone(wrapEl(div) as any, { dataTypes: ["image/png"], checkValidity: () => true, // always allow }) ); const dropEvent = createDragEvent("drop", [file], ["text/plain"]); fireDragEvent(div, dropEvent); expect(result.current.files$.get()).not.toBeNull(); }); it("options as function — treated as onDrop shorthand", () => { const div = createDiv(); const onDrop = vi.fn(); const file = createFile("a.png", "image/png"); const { result } = renderHook(() => useDropZone(wrapEl(div) as any, onDrop)); const dropEvent = createDragEvent("drop", [file], ["image/png"]); fireDragEvent(div, dropEvent); expect(onDrop).toHaveBeenCalledTimes(1); expect(onDrop.mock.calls[0][0]).toHaveLength(1); expect(result.current.files$.get()).not.toBeNull(); }); it("onEnter, onOver, onLeave callbacks are called", () => { const div = createDiv(); const onEnter = vi.fn(); const onLeave = vi.fn(); const onOver = vi.fn(); renderHook(() => useDropZone(wrapEl(div) as any, { onEnter, onLeave, onOver })); fireDragEvent(div, createDragEvent("dragenter", [], [])); expect(onEnter).toHaveBeenCalledTimes(1); fireDragEvent(div, createDragEvent("dragover", [], [])); expect(onOver).toHaveBeenCalledTimes(1); fireDragEvent(div, createDragEvent("dragleave", [], [])); expect(onLeave).toHaveBeenCalledTimes(1); expect(onLeave.mock.calls[0][0]).toBeNull(); }); it("drop with no files keeps files$ null", () => { const div = createDiv(); const { result } = renderHook(() => useDropZone(wrapEl(div) as any)); const dropEvent = createDragEvent("drop", [], []); fireDragEvent(div, dropEvent); expect(result.current.files$.get()).toBeNull(); }); it("unmount removes event listeners", async () => { const div = createDiv(); const removeSpy = vi.spyOn(div, "removeEventListener"); const { unmount } = renderHook(() => useDropZone(wrapEl(div) as any)); unmount(); await Promise.resolve(); expect(removeSpy).toHaveBeenCalled(); }); });