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 }> = []; let managerDestructions = 0; class Backend { monitor = { addEventListener: (name: string, callback: any) => { listeners[name] = callback; }, }; destroy() { managerDestructions++; } } class Sortable { destroyed = 0; constructor() { instances.push(this); } destroy() { this.destroyed++; } } const create = new Function( "DragDropManager", "Sortable", compiled + ";return createDndManager;", )(Backend, 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) => listeners.dragend!(event(id)), 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("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([]); });