import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import type { DndAnnouncement, DndManager, DndManagerOptions } from "../src/types.ts"; // Run the real wrapper with an isolated backend, without global module mocks. const source = readFileSync(new URL("../src/manager.ts", import.meta.url), "utf8"); const compiled = new Bun.Transpiler({ loader: "ts" }) .transformSync(source) .replace(/^import .*;\s*$/gm, "") .replace("export function createDndManager", "function createDndManager"); function fixture(overrides: Partial = {}) { const listeners: Record any> = {}; const announcements: DndAnnouncement[] = []; const instances: Array<{ destroyed: number; input: any }> = []; let managerDestructions = 0; class Backend { monitor = { addEventListener: (name: string, callback: any) => { listeners[name] = callback; }, }; destroy() { managerDestructions++; } } class Sortable { destroyed = 0; constructor(public input: any) { instances.push(this); } destroy() { this.destroyed++; } } const create = new Function( "DragDropManager", "Sortable", "Droppable", compiled + ";return createDndManager;", )(Backend, Sortable, Sortable); const manager: DndManager = create({ onMoveIntent: () => true, announce: (event: DndAnnouncement) => announcements.push(event), ...overrides, }); const event = (id: string) => ({ operation: { source: { id, group: "b", index: 1, initialGroup: "a", initialIndex: 0 }, canceled: false, }, }); return { manager, announcements, instances, start: (id: string) => listeners.dragstart!(event(id)), end: (id: string, target?: { id: string }) => listeners.dragend!({ ...event(id), operation: { ...event(id).operation, target } }), over: (target: { id?: string; group?: string; index?: number }) => { let prevented = false; listeners.dragover!({ ...event("first"), operation: { ...event("first").operation, target }, preventDefault: () => { prevented = true; }, }); return prevented; }, get managerDestructions() { return managerDestructions; }, }; } test("late drop resolution cannot clear or announce a newer gesture", async () => { let finish!: (value: boolean) => void; const f = fixture({ onMoveIntent: (intent) => intent.ids[0] === "first" ? new Promise((resolve) => { finish = resolve; }) : true, }); f.start("first"); const pending = f.end("first"); f.start("second"); finish(true); await pending; await f.end("second"); expect(f.announcements.filter((event) => event.type === "dropped")).toEqual([ { type: "dropped", ids: ["second"], location: { group: "b", index: 1 } }, ]); }); test("collision candidates exclude overlapping ancestors and invalid destinations before sorting", () => { const f = fixture({ accept: (_ids, to) => to.group !== "blocked" }); const child = {} as HTMLElement; f.manager.registerSortable({ id: "child", group: "content", index: 0, element: child, type: "text" }); f.manager.registerSortable({ id: "parent", group: "root", index: 0, element: { contains: (element: unknown) => element === child } as HTMLElement }); f.manager.registerSortable({ id: "sibling", group: "content", index: 1, element: {} as HTMLElement, accepts: ["text"] }); f.manager.registerSortable({ id: "invalid", group: "blocked", index: 0, element: {} as HTMLElement }); const source = { id: "child", type: "text" }; expect(f.instances[1]!.input.accept(source)).toBe(false); expect(f.instances[2]!.input.accept(source)).toBe(true); expect(f.instances[2]!.input.accept({ ...source, type: "image" })).toBe(false); expect(f.instances[3]!.input.accept(source)).toBe(false); f.manager.destroy(); }); test("empty drop zones use explicit locations for acceptance and commit", async () => { const commits: unknown[] = []; const f = fixture({ accept: (_ids, to) => to.group === "empty", onMoveIntent: intent => { commits.push(intent.to); } }); const registration = { id: "zone", group: "empty", index: 0, element: {} as HTMLElement }; const cleanup = f.manager.registerDropZone(registration); f.start("block"); expect(f.over({ id: "zone" })).toBe(false); expect(f.announcements.at(-1)?.location).toEqual({ group: "empty", index: 0 }); await f.end("block", { id: "zone" }); expect(commits).toEqual([{ group: "empty", index: 0 }]); expect(() => f.manager.registerSortable(registration)).toThrow("Duplicate"); cleanup(); f.manager.registerDropZone(registration); cleanup(); expect(() => f.manager.registerDropZone(registration)).toThrow("Duplicate"); f.manager.destroy(); f.manager.destroy(); expect(f.instances.map(instance => instance.destroyed)).toEqual([1, 1]); expect(() => f.manager.registerDropZone(registration)).toThrow("destruction"); }); test("invalid drop zone indices are rejected before backend registration", () => { const f = fixture(); for (const index of [-1, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) { expect(() => f.manager.registerDropZone({ id: "zone", group: "empty", index, element: {} as HTMLElement })).toThrow("index"); } expect(f.instances).toEqual([]); }); test("hover acceptance checks the proposed target instead of the source's old group", () => { const f = fixture({ accept: (_ids, to) => to.group === "allowed" }); f.start("first"); expect(f.over({ group: "blocked", index: 2 })).toBe(true); expect(f.announcements.at(-1)?.location).toEqual({ group: "blocked", index: 2 }); expect(f.over({ group: "allowed", index: 0 })).toBe(false); }); test("drop commit rejection reports cancellation and its original intent", async () => { const failure = new Error("save failed"); const errors: unknown[] = []; const f = fixture({ onMoveIntent: async () => { throw failure; }, onError: (error, intent) => errors.push([error, intent.ids]), }); f.start("first"); await f.end("first"); expect(errors).toEqual([[failure, ["first"]]]); expect(f.announcements.at(-1)?.type).toBe("cancelled"); }); test("drop acceptance is checked again before dispatching a commit", async () => { let commits = 0; const f = fixture({ accept: () => false, onMoveIntent: () => { commits++; }, }); f.start("first"); await f.end("first"); expect(commits).toBe(0); expect(f.announcements.at(-1)?.type).toBe("cancelled"); }); test("old cleanup cannot remove replacement registrations and destruction is terminal", async () => { let finish!: (value: boolean) => void; const f = fixture({ onMoveIntent: () => new Promise((resolve) => { finish = resolve; }), }); const registration = { id: "same", group: "a", index: 0, element: {} as HTMLElement }; const cleanup = f.manager.registerSortable(registration); cleanup(); f.manager.registerSortable(registration); cleanup(); expect(() => f.manager.registerSortable(registration)).toThrow("Duplicate"); f.start("same"); const pending = f.end("same"); f.manager.destroy(); f.manager.destroy(); finish(true); await pending; cleanup(); expect(f.instances.map((instance) => instance.destroyed)).toEqual([1, 1]); expect(f.managerDestructions).toBe(1); expect(() => f.manager.registerSortable(registration)).toThrow("destruction"); expect(f.announcements.filter((event) => event.type === "dropped")).toEqual([]); });