139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
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 =
|
|
'<div id="left"><button data-block-id="a" data-block-parent="parent:one" data-block-slot="content:main">A</button><span>Keep</span><button data-block-id="b" data-block-parent="parent:one" data-block-slot="content:main">B</button></div><div id="right"><button data-block-id="c" data-block-parent="parent:two" data-block-slot="content">C</button></div>';
|
|
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);
|
|
}
|
|
});
|