Files
WRNexusJS/packages/block-editor/test/runtime.test.ts
T

231 lines
8.2 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");
test("partial registration failure cleans the backend and live region on mount and refresh", () => {
for (const failingMount of [1, 2]) {
const window = new Window(),
root = window.document.createElement("div");
window.document.body.append(root);
root.innerHTML =
'<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
let mounts = 0,
destroyed = 0,
cleaned = 0;
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(() => {
const number = ++mounts;
let registrations = 0;
return {
registerSortable() {
if (++registrations === 2 && number === failingMount)
throw new Error("registration failed");
return () => {
cleaned++;
};
},
destroy() {
destroyed++;
},
};
});
let editor: MountedEditor | undefined;
if (failingMount === 1)
expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
else {
editor = mount(root, { onMove() {} });
expect(() => editor!.refresh()).toThrow("registration failed");
}
expect(root.querySelector('[role="status"]')).toBeNull();
expect(root.querySelectorAll("[data-block-id]")).toHaveLength(2);
expect(destroyed).toBe(failingMount);
expect(cleaned).toBe(failingMount === 1 ? 1 : 3);
editor?.destroy();
editor?.refresh();
expect(destroyed).toBe(failingMount);
}
});
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("large canvas registration scans blocks once and preserves interleaved slot order", () => {
const f = fixture();
f.root.replaceChildren();
for (let i = 0; i < 300; i++) {
const node = f.document.createElement("div");
node.dataset.blockId = String(i);
node.dataset.blockParent = `parent:${i % 3}`;
node.dataset.blockSlot = "content";
f.root.append(node);
}
for (let i = 0; i < 4; i++) {
const slot = f.document.createElement("div");
slot.dataset.blockParent = `parent:${i}`;
slot.dataset.blockSlotTarget = "content";
f.root.append(slot);
}
const query = f.root.querySelectorAll.bind(f.root);
let scans = 0;
f.root.querySelectorAll = ((selector: string) => {
if (selector === "[data-block-id][data-block-parent][data-block-slot]") scans++;
return query(selector);
}) as typeof f.root.querySelectorAll;
f.editor.refresh();
const backend = f.backends.at(-1)!;
expect(scans).toBe(1);
expect(backend.registrations.map(({ id, group, index }) => ({ id, group, index }))).toEqual(
Array.from({ length: 300 }, (_, i) => ({
id: String(i),
group: JSON.stringify([`parent:${i % 3}`, "content"]),
index: Math.floor(i / 3),
})),
);
expect(backend.zones.map((zone) => zone.group)).toEqual([
JSON.stringify(["parent:3", "content"]),
]);
f.editor.destroy();
});
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);
}
});