import { describe, expect, it } from "vitest"; import { BLOCK_NODE_TYPE, blocksToDoc, docToBlocks, diffBlocks, toOperations, generateBlockId, jsonEqual, } from "./codec.js"; import type { BlockDto } from "./types.js"; function block( id: string, parentId: string | null, position: string, content: Record = { text: id }, extra?: Partial, ): BlockDto { return { id, type: extra?.type ?? "paragraph", content, ...(extra?.properties !== undefined ? { properties: extra.properties } : {}), parentId, position, version: 1, }; } /** `diffBlocks` operates on id-bearing blocks (BlockLike ⊂ BlockDto) — the * wire id-strip happens later in `toOperations`. Identity here. */ function asNew(b: BlockDto): BlockDto { return b; } describe("codec", () => { it("round-trips a nested forest losslessly", () => { const forest: BlockDto[] = [ block("a", null, "1", { text: "root A" }), block("b", null, "2", { text: "root B" }, { properties: { color: "blue" } }), block("c", null, "3", { text: "root C" }), block("a1", "a", "1a", { text: "A child 1" }), block("a2", "a", "1b", { text: "A child 2" }, { type: "todo", properties: { checked: true } }), block("a1x", "a1", "1aa", { text: "grandchild" }), ]; const doc = blocksToDoc(forest); expect(doc.type).toBe("doc"); expect(doc.content).toHaveLength(3); const back = docToBlocks(doc); expect(back).toHaveLength(6); // Siblings in position order, children after parent (DFS). expect(back.map((b) => b.id)).toEqual(["a", "a1", "a1x", "a2", "b", "c"]); const orig = new Map(forest.map((b) => [b.id, b])); for (const nb of back) { const o = orig.get(nb.id); expect(o, `missing ${nb.id}`).toBeDefined(); expect(nb.type).toBe(o?.type); expect(nb.position).toBe(o?.position); expect(nb.parentId).toBe(o?.parentId); expect(jsonEqual(nb.content, o?.content)).toBe(true); expect(jsonEqual(nb.properties, o?.properties)).toBe(true); } }); it("sorts siblings by fractional position (input order irrelevant)", () => { const forest: BlockDto[] = [ block("z", null, "9", { text: "last" }), block("m", null, "5", { text: "mid" }), block("a", null, "1", { text: "first" }), ]; const doc = blocksToDoc(forest); expect( doc.content.map((n) => (n.attrs as { id: string }).id), ).toEqual(["a", "m", "z"]); }); it("rejects duplicate ids and missing parents", () => { expect(() => blocksToDoc([block("a", null, "1"), block("a", null, "2")]), ).toThrow(/duplicate/); expect(() => blocksToDoc([block("a", "ghost", "1")]), ).toThrow(/missing parent/); }); it("rejects non-block nodes in docToBlocks", () => { expect(() => docToBlocks({ type: "doc", content: [{ type: "junk" }] })).toThrow(/unexpected node/); expect(() => docToBlocks("nope")).toThrow(/expected a/); }); it("generateBlockId returns unique blk_ prefixed ids", () => { const a = generateBlockId(); const b = generateBlockId(); expect(a).toMatch(/^blk_/); expect(a).not.toBe(b); }); }); describe("diffBlocks (the autosave op diff)", () => { it("detects inserts, updates, moves and deletes", () => { const prev: BlockDto[] = [ block("a", null, "1", { text: "A" }), block("b", null, "2", { text: "B" }), block("c", null, "3", { text: "C" }), ]; // b: content changed (update); c: moved before a (move, position 0); d: new (insert); a: unchanged. const next: BlockDto[] = [ block("c", null, "0", { text: "C" }), block("a", null, "1", { text: "A" }), block("b", null, "2", { text: "B!" }), block("d", null, "3", { text: "D" }), ]; const diff = diffBlocks(prev.map(asNew), next.map(asNew)); expect(diff.inserts.map((b) => b.id)).toEqual(["d"]); expect(diff.updates.map((u) => u.id)).toEqual(["b"]); expect(diff.updates[0]?.patch.content).toEqual({ text: "B!" }); expect(diff.updates[0]?.patch.properties).toBeUndefined(); expect(diff.moves.map((m) => m.id)).toEqual(["c"]); // c moved to the top: no `before`, `after` = a. expect(diff.moves[0]).toEqual({ id: "c", after: "a" }); expect(diff.deletes).toEqual([]); }); it("detects deletions and cross-parent moves", () => { const prev: BlockDto[] = [ block("a", null, "1", { text: "A" }), block("c", null, "2", { text: "C" }), ]; const next: BlockDto[] = [ block("a", null, "1", { text: "A" }), block("c", "a", "1", { text: "C" }), // c now a child of a ]; const diff = diffBlocks(prev.map(asNew), next.map(asNew)); // c moved under a and is the only child → no anchors, but parentId changed → a move. expect(diff.moves).toEqual([{ id: "c" }]); expect(diff.updates).toEqual([]); expect(diff.inserts).toEqual([]); const deleted: BlockDto[] = [block("x", null, "9", { text: "X" })]; const diff2 = diffBlocks(deleted.map(asNew), []); expect(diff2.deletes).toEqual(["x"]); }); it("detects property-only updates (minimal patch — no unchanged content)", () => { const prev: BlockDto[] = [block("a", null, "1", { text: "A" }, { properties: { color: "blue" } })]; const next: BlockDto[] = [block("a", null, "1", { text: "A" }, { properties: { color: "green" } })]; const diff = diffBlocks(prev.map(asNew), next.map(asNew)); expect(diff.updates).toHaveLength(1); expect(diff.updates[0]?.patch).toEqual({ properties: { color: "green" } }); expect(diff.updates[0]?.patch.content).toBeUndefined(); }); it("emits no update for an unchanged block", () => { const prev: BlockDto[] = [block("a", null, "1", { text: "A" }, { properties: { color: "blue" } })]; const diff = diffBlocks(prev.map(asNew), prev.map(asNew)); expect(diff.updates).toHaveLength(0); expect(diff.moves).toHaveLength(0); expect(diff.inserts).toHaveLength(0); expect(diff.deletes).toHaveLength(0); }); }); describe("toOperations (wire shape)", () => { it("emits delete → update → move → insert in the shared wire format", () => { const ops = toOperations( ["d1"], [{ id: "u1", patch: { content: { text: "x" } } }], [{ id: "m1", before: "b1", after: "b2" }], [{ type: "paragraph", content: { text: "n" }, parentId: "p", position: "7" }], ); expect(ops).toEqual([ { op: "delete", id: "d1" }, { op: "update", id: "u1", patch: { content: { text: "x" } } }, // wire contract: at most one of before/after — `before` wins { op: "move", id: "m1", before: "b1" }, { op: "insert", block: { type: "paragraph", content: { text: "n" }, parentId: "p", position: "7" } }, ]); }); it("falls back to the `after` anchor when a move has no `before`", () => { const ops = toOperations([], [], [{ id: "m1", after: "b2" }], []); expect(ops).toEqual([{ op: "move", id: "m1", after: "b2" }]); }); it("omits optional move anchors and top-level insert parentId", () => { const ops = toOperations([], [], [{ id: "m1" }], [ { type: "heading_1", content: { text: "h" }, parentId: null, position: "0" }, ]); expect(ops).toEqual([ { op: "move", id: "m1" }, { op: "insert", block: { type: "heading_1", content: { text: "h" }, position: "0" } }, ]); }); });