1import { describe, expect, it } from "vitest";
2
3import {
4 BLOCK_NODE_TYPE,
5 blocksToDoc,
6 docToBlocks,
7 diffBlocks,
8 toOperations,
9 generateBlockId,
10 jsonEqual,
11} from "./codec.js";
12import type { BlockDto } from "./types.js";
13
14function block(
15 id: string,
16 parentId: string | null,
17 position: string,
18 content: Record<string, unknown> = { text: id },
19 extra?: Partial<BlockDto>,
20): BlockDto {
21 return {
22 id,
23 type: extra?.type ?? "paragraph",
24 content,
25 ...(extra?.properties !== undefined ? { properties: extra.properties } : {}),
26 parentId,
27 position,
28 version: 1,
29 };
30}
31
32
34function asNew(b: BlockDto): BlockDto {
35 return b;
36}
37
38describe("codec", () => {
39 it("round-trips a nested forest losslessly", () => {
40 const forest: BlockDto[] = [
41 block("a", null, "1", { text: "root A" }),
42 block("b", null, "2", { text: "root B" }, { properties: { color: "blue" } }),
43 block("c", null, "3", { text: "root C" }),
44 block("a1", "a", "1a", { text: "A child 1" }),
45 block("a2", "a", "1b", { text: "A child 2" }, { type: "todo", properties: { checked: true } }),
46 block("a1x", "a1", "1aa", { text: "grandchild" }),
47 ];
48 const doc = blocksToDoc(forest);
49 expect(doc.type).toBe("doc");
50 expect(doc.content).toHaveLength(3);
51
52 const back = docToBlocks(doc);
53 expect(back).toHaveLength(6);
54
55
56 expect(back.map((b) => b.id)).toEqual(["a", "a1", "a1x", "a2", "b", "c"]);
57
58 const orig = new Map(forest.map((b) => [b.id, b]));
59 for (const nb of back) {
60 const o = orig.get(nb.id);
61 expect(o, `missing ${nb.id}`).toBeDefined();
62 expect(nb.type).toBe(o?.type);
63 expect(nb.position).toBe(o?.position);
64 expect(nb.parentId).toBe(o?.parentId);
65 expect(jsonEqual(nb.content, o?.content)).toBe(true);
66 expect(jsonEqual(nb.properties, o?.properties)).toBe(true);
67 }
68 });
69
70 it("sorts siblings by fractional position (input order irrelevant)", () => {
71 const forest: BlockDto[] = [
72 block("z", null, "9", { text: "last" }),
73 block("m", null, "5", { text: "mid" }),
74 block("a", null, "1", { text: "first" }),
75 ];
76 const doc = blocksToDoc(forest);
77 expect(
78 doc.content.map((n) => (n.attrs as { id: string }).id),
79 ).toEqual(["a", "m", "z"]);
80 });
81
82 it("rejects duplicate ids and missing parents", () => {
83 expect(() =>
84 blocksToDoc([block("a", null, "1"), block("a", null, "2")]),
85 ).toThrow(/duplicate/);
86 expect(() =>
87 blocksToDoc([block("a", "ghost", "1")]),
88 ).toThrow(/missing parent/);
89 });
90
91 it("rejects non-block nodes in docToBlocks", () => {
92 expect(() => docToBlocks({ type: "doc", content: [{ type: "junk" }] })).toThrow(/unexpected node/);
93 expect(() => docToBlocks("nope")).toThrow(/expected a/);
94 });
95
96 it("generateBlockId returns unique blk_ prefixed ids", () => {
97 const a = generateBlockId();
98 const b = generateBlockId();
99 expect(a).toMatch(/^blk_/);
100 expect(a).not.toBe(b);
101 });
102});
103
104describe("diffBlocks (the autosave op diff)", () => {
105 it("detects inserts, updates, moves and deletes", () => {
106 const prev: BlockDto[] = [
107 block("a", null, "1", { text: "A" }),
108 block("b", null, "2", { text: "B" }),
109 block("c", null, "3", { text: "C" }),
110 ];
111
112 const next: BlockDto[] = [
113 block("c", null, "0", { text: "C" }),
114 block("a", null, "1", { text: "A" }),
115 block("b", null, "2", { text: "B!" }),
116 block("d", null, "3", { text: "D" }),
117 ];
118 const diff = diffBlocks(prev.map(asNew), next.map(asNew));
119
120 expect(diff.inserts.map((b) => b.id)).toEqual(["d"]);
121 expect(diff.updates.map((u) => u.id)).toEqual(["b"]);
122 expect(diff.updates[0]?.patch.content).toEqual({ text: "B!" });
123 expect(diff.updates[0]?.patch.properties).toBeUndefined();
124 expect(diff.moves.map((m) => m.id)).toEqual(["c"]);
125
126 expect(diff.moves[0]).toEqual({ id: "c", after: "a" });
127 expect(diff.deletes).toEqual([]);
128 });
129
130 it("detects deletions and cross-parent moves", () => {
131 const prev: BlockDto[] = [
132 block("a", null, "1", { text: "A" }),
133 block("c", null, "2", { text: "C" }),
134 ];
135 const next: BlockDto[] = [
136 block("a", null, "1", { text: "A" }),
137 block("c", "a", "1", { text: "C" }),
138 ];
139 const diff = diffBlocks(prev.map(asNew), next.map(asNew));
140
141 expect(diff.moves).toEqual([{ id: "c" }]);
142 expect(diff.updates).toEqual([]);
143 expect(diff.inserts).toEqual([]);
144
145 const deleted: BlockDto[] = [block("x", null, "9", { text: "X" })];
146 const diff2 = diffBlocks(deleted.map(asNew), []);
147 expect(diff2.deletes).toEqual(["x"]);
148 });
149
150 it("detects property-only updates (minimal patch — no unchanged content)", () => {
151 const prev: BlockDto[] = [block("a", null, "1", { text: "A" }, { properties: { color: "blue" } })];
152 const next: BlockDto[] = [block("a", null, "1", { text: "A" }, { properties: { color: "green" } })];
153 const diff = diffBlocks(prev.map(asNew), next.map(asNew));
154 expect(diff.updates).toHaveLength(1);
155 expect(diff.updates[0]?.patch).toEqual({ properties: { color: "green" } });
156 expect(diff.updates[0]?.patch.content).toBeUndefined();
157 });
158
159 it("emits no update for an unchanged block", () => {
160 const prev: BlockDto[] = [block("a", null, "1", { text: "A" }, { properties: { color: "blue" } })];
161 const diff = diffBlocks(prev.map(asNew), prev.map(asNew));
162 expect(diff.updates).toHaveLength(0);
163 expect(diff.moves).toHaveLength(0);
164 expect(diff.inserts).toHaveLength(0);
165 expect(diff.deletes).toHaveLength(0);
166 });
167});
168
169describe("toOperations (wire shape)", () => {
170 it("emits delete → update → move → insert in the shared wire format", () => {
171 const ops = toOperations(
172 ["d1"],
173 [{ id: "u1", patch: { content: { text: "x" } } }],
174 [{ id: "m1", before: "b1", after: "b2" }],
175 [{ type: "paragraph", content: { text: "n" }, parentId: "p", position: "7" }],
176 );
177 expect(ops).toEqual([
178 { op: "delete", id: "d1" },
179 { op: "update", id: "u1", patch: { content: { text: "x" } } },
180
181 { op: "move", id: "m1", before: "b1" },
182 { op: "insert", block: { type: "paragraph", content: { text: "n" }, parentId: "p", position: "7" } },
183 ]);
184 });
185
186 it("falls back to the `after` anchor when a move has no `before`", () => {
187 const ops = toOperations([], [], [{ id: "m1", after: "b2" }], []);
188 expect(ops).toEqual([{ op: "move", id: "m1", after: "b2" }]);
189 });
190
191 it("omits optional move anchors and top-level insert parentId", () => {
192 const ops = toOperations([], [], [{ id: "m1" }], [
193 { type: "heading_1", content: { text: "h" }, parentId: null, position: "0" },
194 ]);
195 expect(ops).toEqual([
196 { op: "move", id: "m1" },
197 { op: "insert", block: { type: "heading_1", content: { text: "h" }, position: "0" } },
198 ]);
199 });
200});
201