import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { Window } from "happy-dom"; import type { DndManagerOptions, SortableRegistration, DropZoneRegistration } from "@wrnexus/dnd"; import type { MountedEditor, MountEditorOptions } from "../src/runtime.ts"; const code = new Bun.Transpiler({ loader: "ts" }) .transformSync(readFileSync(new URL("../src/runtime.ts", import.meta.url), "utf8")) .replace(/^import .*;\s*$/gm, "") .replace("export function mountBlockEditor", "function mountBlockEditor"); function fixture(onMove: MountEditorOptions["onMove"] = () => false) { const window = new Window(); const document = window.document; const root = document.createElement("div"); document.body.append(root); root.innerHTML = '
Keep
'; const backends: Array<{ options: DndManagerOptions; registrations: SortableRegistration[]; zones: DropZoneRegistration[]; destroyed: number; }> = []; const mount = new Function("createDndManager", code + ";return mountBlockEditor;")( (options: DndManagerOptions) => { const backend = { options, registrations: [] as SortableRegistration[], zones: [] as DropZoneRegistration[], destroyed: 0 }; backends.push(backend); return { registerDropZone: (registration: DropZoneRegistration) => { backend.zones.push(registration); return () => {}; }, registerSortable: (registration: SortableRegistration) => { backend.registrations.push(registration); return () => {}; }, destroy: () => backend.destroyed++, }; }, ); const editor: MountedEditor = mount(root, { onMove }); const emit = (type: "pickedUp" | "cancelled" | "dropped") => backends.at(-1)!.options.announce!({ type, ids: ["a"] }); return { root, document, editor, backends, emit, flush: () => window.happyDOM.whenAsyncComplete(), }; } test("cancelled optimistic moves restore original DOM nodes across containers", async () => { const f = fixture(); const a = f.root.querySelector('[data-block-id="a"]')!; let clicks = 0; a.addEventListener("click", () => clicks++); f.emit("pickedUp"); f.root.querySelector("#right")!.append(a); f.emit("cancelled"); await f.flush(); expect(f.root.querySelector("#left")!.textContent).toBe("AKeepB"); expect(f.root.querySelector("#right")!.textContent).toBe("C"); expect(f.root.querySelector('[data-block-id="a"]')).toBe(a); (a as any).click(); expect(clicks).toBe(1); expect(f.backends).toHaveLength(2); expect(f.backends[0]!.destroyed).toBe(1); f.editor.destroy(); }); test("empty named slot targets produce validated editor move intents and refresh away when populated", async () => { const moves: unknown[] = []; const f = fixture(intent => { moves.push(intent); return true; }); const target = f.document.createElement("div"); target.dataset.blockParent = "parent:empty"; target.dataset.blockSlotTarget = "content:empty"; f.root.append(target); f.editor.refresh(); const backend = f.backends.at(-1)!; expect(backend.zones).toHaveLength(1); const zone = backend.zones[0]!; expect(backend.options.accept!(["a"], { group: zone.group, index: 0 })).toBe(true); await backend.options.onMoveIntent({ ids: ["a"], from: [], to: { group: zone.group, index: 0 } }); expect(moves).toEqual([{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } }]); const child = f.root.querySelector('[data-block-id="a"]')! as any; child.dataset.blockParent = "parent:empty"; child.dataset.blockSlot = "content:empty"; target.append(child); f.editor.refresh(); expect(f.backends.at(-1)!.zones).toHaveLength(0); f.editor.destroy(); }); test("slot and parent identifiers containing colons round-trip without collisions", async () => { const moves: unknown[] = []; const f = fixture((intent) => { moves.push(intent); return true; }); const backend = f.backends[0]!; await backend.options.onMoveIntent({ ids: ["b"], from: [], to: { group: backend.registrations[0]!.group, index: 0 }, }); expect(moves).toEqual([ { nodeIds: ["b"], to: { parentId: "parent:one", slot: "content:main", index: 0 } }, ]); expect( await backend.options.onMoveIntent({ ids: ["b"], from: [], to: { group: "unknown", index: 0 }, }), ).toBe(false); f.editor.destroy(); }); test("pending rollback cannot resurrect removed nodes or run after destruction", async () => { for (const destroyed of [false, true]) { const f = fixture(); f.emit("pickedUp"); f.root.querySelector('[data-block-id="a"]')!.remove(); f.emit("cancelled"); if (destroyed) { f.editor.destroy(); f.editor.destroy(); f.editor.refresh(); } await f.flush(); expect(f.root.querySelector('[data-block-id="a"]')).toBeNull(); if (destroyed) expect(f.backends).toHaveLength(1); f.editor.destroy(); expect(f.backends.every((backend) => backend.destroyed === 1)).toBe(true); } });