meridian-sdk 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -0
- package/dist/agents.d.ts +143 -8
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +141 -14
- package/dist/agents.js.map +1 -1
- package/dist/client.d.ts +35 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +45 -3
- package/dist/client.js.map +1 -1
- package/dist/conflict/types.d.ts +54 -0
- package/dist/conflict/types.d.ts.map +1 -0
- package/dist/conflict/types.js +9 -0
- package/dist/conflict/types.js.map +1 -0
- package/dist/crdt/rga.d.ts +20 -4
- package/dist/crdt/rga.d.ts.map +1 -1
- package/dist/crdt/rga.js +42 -6
- package/dist/crdt/rga.js.map +1 -1
- package/dist/crdt/tree.d.ts +128 -0
- package/dist/crdt/tree.d.ts.map +1 -0
- package/dist/crdt/tree.js +304 -0
- package/dist/crdt/tree.js.map +1 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +63 -3
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +18 -1
- package/dist/schema.js.map +1 -1
- package/dist/sync/delta.d.ts +35 -0
- package/dist/sync/delta.d.ts.map +1 -1
- package/dist/sync/delta.js +4 -0
- package/dist/sync/delta.js.map +1 -1
- package/dist/undo/UndoManager.d.ts +74 -0
- package/dist/undo/UndoManager.d.ts.map +1 -0
- package/dist/undo/UndoManager.js +318 -0
- package/dist/undo/UndoManager.js.map +1 -0
- package/dist/undo/types.d.ts +63 -0
- package/dist/undo/types.d.ts.map +1 -0
- package/dist/undo/types.js +9 -0
- package/dist/undo/types.js.map +1 -0
- package/dist/utils/fractional.d.ts +78 -0
- package/dist/utils/fractional.d.ts.map +1 -0
- package/dist/utils/fractional.js +159 -0
- package/dist/utils/fractional.js.map +1 -0
- package/dist/validation/index.d.ts +107 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +123 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +1 -1
- package/src/agents.ts +224 -15
- package/src/client.ts +52 -3
- package/src/conflict/types.ts +60 -0
- package/src/crdt/rga.ts +46 -7
- package/src/crdt/tree.ts +367 -0
- package/src/index.ts +31 -1
- package/src/schema.ts +24 -1
- package/src/sync/delta.ts +30 -1
- package/src/undo/UndoManager.ts +369 -0
- package/src/undo/types.ts +74 -0
- package/src/utils/fractional.ts +166 -0
- package/src/validation/index.ts +137 -0
- package/test/conflict.test.ts +242 -0
- package/test/fractional.test.ts +127 -0
- package/test/undo.test.ts +272 -0
- package/test/validation.test.ts +137 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for UndoManager — CRDT-aware per-client undo/redo.
|
|
3
|
+
*
|
|
4
|
+
* No server required — transport is stubbed. Tests verify that undo/redo
|
|
5
|
+
* dispatch the correct inverse CrdtOps via the transport.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
9
|
+
import { RGAHandle } from "../src/crdt/rga.js";
|
|
10
|
+
import { TreeHandle } from "../src/crdt/tree.js";
|
|
11
|
+
import { UndoManager } from "../src/undo/UndoManager.js";
|
|
12
|
+
import type { WsTransport } from "../src/transport/websocket.js";
|
|
13
|
+
import { decode } from "@msgpack/msgpack";
|
|
14
|
+
|
|
15
|
+
function stubTransport(): WsTransport & { sent: unknown[] } {
|
|
16
|
+
const sent: unknown[] = [];
|
|
17
|
+
return {
|
|
18
|
+
sent,
|
|
19
|
+
connect: () => {},
|
|
20
|
+
close: () => {},
|
|
21
|
+
subscribe: () => {},
|
|
22
|
+
updateClock: () => {},
|
|
23
|
+
send: (msg: unknown) => { sent.push(msg); },
|
|
24
|
+
get currentState() { return "CONNECTED" as const; },
|
|
25
|
+
} as unknown as WsTransport & { sent: unknown[] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decodeOp(msg: unknown): unknown {
|
|
29
|
+
const m = msg as { Op: { op_bytes: Uint8Array } };
|
|
30
|
+
return decode(m.Op.op_bytes);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const OPTS = { crdtId: "rg:doc", clientId: 42 };
|
|
34
|
+
const TREE_OPTS = { crdtId: "tr:doc", clientId: 42 };
|
|
35
|
+
|
|
36
|
+
// ─── RGA ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe("UndoManager — RGA", () => {
|
|
39
|
+
let t: WsTransport & { sent: unknown[] };
|
|
40
|
+
let handle: RGAHandle;
|
|
41
|
+
let manager: UndoManager<RGAHandle>;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
t = stubTransport();
|
|
45
|
+
handle = new RGAHandle({ ...OPTS, transport: t });
|
|
46
|
+
manager = new UndoManager(handle);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("insert returns node HLC strings", () => {
|
|
50
|
+
const ids = manager.insert(0, "hi");
|
|
51
|
+
expect(ids).toHaveLength(2);
|
|
52
|
+
expect(ids[0]).toMatch(/^\d+:0:42$/);
|
|
53
|
+
expect(ids[1]).toMatch(/^\d+:1:42$/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("undo after insert sends deleteById for each inserted node", async () => {
|
|
57
|
+
const ids = manager.insert(0, "ab");
|
|
58
|
+
expect(t.sent).toHaveLength(2); // 2 inserts
|
|
59
|
+
|
|
60
|
+
// Wait for debounce to commit the batch
|
|
61
|
+
await new Promise(r => setTimeout(r, 300));
|
|
62
|
+
|
|
63
|
+
expect(manager.canUndo).toBe(true);
|
|
64
|
+
manager.undo();
|
|
65
|
+
|
|
66
|
+
// 2 more sends: deleteById for each char (in reverse order)
|
|
67
|
+
expect(t.sent).toHaveLength(4);
|
|
68
|
+
const del1 = decodeOp(t.sent[3]) as { RGA: { Delete: { id: unknown } } };
|
|
69
|
+
const del2 = decodeOp(t.sent[2]) as { RGA: { Delete: { id: unknown } } };
|
|
70
|
+
expect(del1.RGA.Delete.id).toBeDefined();
|
|
71
|
+
expect(del2.RGA.Delete.id).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("undo with correct HLC — does not target remote nodes", async () => {
|
|
75
|
+
const ids = manager.insert(0, "x");
|
|
76
|
+
await new Promise(r => setTimeout(r, 300));
|
|
77
|
+
|
|
78
|
+
// Simulate remote delta — server sends back authoritative text
|
|
79
|
+
handle.applyDelta({ text: "yx" }); // remote "y" prepended
|
|
80
|
+
|
|
81
|
+
manager.undo();
|
|
82
|
+
// Delete op should target our HLC id, not the remote one
|
|
83
|
+
const del = decodeOp(t.sent[t.sent.length - 1]) as {
|
|
84
|
+
RGA: { Delete: { id: { node_id: number } } };
|
|
85
|
+
};
|
|
86
|
+
expect(del.RGA.Delete.id.node_id).toBe(42); // our clientId
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("undo on empty stack is a no-op", () => {
|
|
90
|
+
expect(manager.canUndo).toBe(false);
|
|
91
|
+
manager.undo(); // should not throw
|
|
92
|
+
expect(t.sent).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("RGA delete is not recorded — undo does nothing", () => {
|
|
96
|
+
manager.insert(0, "hello");
|
|
97
|
+
manager.delete(0, 5);
|
|
98
|
+
// delete sends pos-based ops (non-undoable) — stack only has insert batch
|
|
99
|
+
// Wait for debounce
|
|
100
|
+
// canUndo might be true from insert, but redo after undo of just-delete is empty
|
|
101
|
+
expect(manager.canRedo).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("batch debounce: 3 inserts within 250ms form one undo step", async () => {
|
|
105
|
+
manager.insert(0, "a");
|
|
106
|
+
manager.insert(1, "b");
|
|
107
|
+
manager.insert(2, "c");
|
|
108
|
+
// All within debounce window — still one pending batch
|
|
109
|
+
expect(manager.canUndo).toBe(false); // not committed yet
|
|
110
|
+
|
|
111
|
+
await new Promise(r => setTimeout(r, 300));
|
|
112
|
+
expect(manager.canUndo).toBe(true); // now committed as one batch
|
|
113
|
+
|
|
114
|
+
manager.undo();
|
|
115
|
+
// One undo step = 3 deletes
|
|
116
|
+
expect(t.sent).toHaveLength(6); // 3 inserts + 3 deletes
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("canUndo / canRedo reflect stack state", async () => {
|
|
120
|
+
expect(manager.canUndo).toBe(false);
|
|
121
|
+
expect(manager.canRedo).toBe(false);
|
|
122
|
+
|
|
123
|
+
manager.insert(0, "x");
|
|
124
|
+
await new Promise(r => setTimeout(r, 300));
|
|
125
|
+
expect(manager.canUndo).toBe(true);
|
|
126
|
+
expect(manager.canRedo).toBe(false);
|
|
127
|
+
|
|
128
|
+
manager.undo();
|
|
129
|
+
// RGA insert undo = deleteById, which returns null inverse — canRedo stays false
|
|
130
|
+
expect(manager.canUndo).toBe(false);
|
|
131
|
+
expect(manager.canRedo).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("new op after undo clears redoStack — tree example", () => {
|
|
135
|
+
// Use TreeHandle for this test since RGA undo doesn't produce redoStack entries
|
|
136
|
+
const t2 = stubTransport();
|
|
137
|
+
const h2 = new TreeHandle({ ...TREE_OPTS, clientId: 42, transport: t2 });
|
|
138
|
+
const m2 = new UndoManager(h2);
|
|
139
|
+
|
|
140
|
+
m2.addNode(null, "a0", "A");
|
|
141
|
+
m2.undo();
|
|
142
|
+
expect(m2.canRedo).toBe(true);
|
|
143
|
+
|
|
144
|
+
m2.addNode(null, "a0", "B"); // new op after undo
|
|
145
|
+
expect(m2.canRedo).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("onStackChange fires on undo and redo", async () => {
|
|
149
|
+
const calls: string[] = [];
|
|
150
|
+
manager.onStackChange(() => calls.push("change"));
|
|
151
|
+
|
|
152
|
+
manager.insert(0, "z");
|
|
153
|
+
await new Promise(r => setTimeout(r, 300));
|
|
154
|
+
// debounce commit fires onStackChange
|
|
155
|
+
expect(calls.length).toBeGreaterThanOrEqual(1);
|
|
156
|
+
|
|
157
|
+
const prev = calls.length;
|
|
158
|
+
manager.undo();
|
|
159
|
+
expect(calls.length).toBe(prev + 1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── Tree ─────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe("UndoManager — Tree", () => {
|
|
166
|
+
let t: WsTransport & { sent: unknown[] };
|
|
167
|
+
let handle: TreeHandle;
|
|
168
|
+
let manager: UndoManager<TreeHandle>;
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
t = stubTransport();
|
|
172
|
+
handle = new TreeHandle({ ...TREE_OPTS, transport: t });
|
|
173
|
+
manager = new UndoManager(handle);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("undo after addNode sends deleteNode for that nodeId", () => {
|
|
177
|
+
const nodeId = manager.addNode(null, "a0", "Root");
|
|
178
|
+
expect(manager.canUndo).toBe(true);
|
|
179
|
+
|
|
180
|
+
manager.undo();
|
|
181
|
+
expect(t.sent).toHaveLength(2);
|
|
182
|
+
const del = decodeOp(t.sent[1]) as { Tree: { DeleteNode: unknown } };
|
|
183
|
+
expect(del.Tree.DeleteNode).toBeDefined();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("undo after deleteNode sends addNode with original parent/position/value", () => {
|
|
187
|
+
const nodeId = manager.addNode(null, "a0", "Root");
|
|
188
|
+
t.sent.splice(0); // clear
|
|
189
|
+
|
|
190
|
+
manager.deleteNode(nodeId);
|
|
191
|
+
expect(t.sent).toHaveLength(1); // 1 deleteNode op
|
|
192
|
+
|
|
193
|
+
manager.undo(); // should add a new node
|
|
194
|
+
expect(t.sent).toHaveLength(2);
|
|
195
|
+
const add = decodeOp(t.sent[1]) as { Tree: { AddNode: { value: string } } };
|
|
196
|
+
expect(add.Tree.AddNode.value).toBe("Root");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("undo after moveNode sends moveNode back to old parent", () => {
|
|
200
|
+
const parentId = manager.addNode(null, "a0", "Parent");
|
|
201
|
+
const childId = manager.addNode(null, "b0", "Child");
|
|
202
|
+
t.sent.splice(0);
|
|
203
|
+
|
|
204
|
+
manager.moveNode(childId, parentId, "a0");
|
|
205
|
+
expect(t.sent).toHaveLength(1);
|
|
206
|
+
|
|
207
|
+
manager.undo();
|
|
208
|
+
expect(t.sent).toHaveLength(2);
|
|
209
|
+
const move = decodeOp(t.sent[1]) as {
|
|
210
|
+
Tree: { MoveNode: { new_parent_id: null | unknown } };
|
|
211
|
+
};
|
|
212
|
+
// Undo move: child should go back to root (null parent)
|
|
213
|
+
expect(move.Tree.MoveNode.new_parent_id).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("undo after updateNode sends updateNode with previous value", () => {
|
|
217
|
+
const nodeId = manager.addNode(null, "a0", "Original");
|
|
218
|
+
t.sent.splice(0);
|
|
219
|
+
|
|
220
|
+
manager.updateNode(nodeId, "Updated");
|
|
221
|
+
expect(t.sent).toHaveLength(1);
|
|
222
|
+
|
|
223
|
+
manager.undo();
|
|
224
|
+
expect(t.sent).toHaveLength(2);
|
|
225
|
+
const update = decodeOp(t.sent[1]) as {
|
|
226
|
+
Tree: { UpdateNode: { value: string } };
|
|
227
|
+
};
|
|
228
|
+
expect(update.Tree.UpdateNode.value).toBe("Original");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("redo after undo(addNode) re-adds the node", () => {
|
|
232
|
+
manager.addNode(null, "a0", "Root");
|
|
233
|
+
manager.undo();
|
|
234
|
+
expect(manager.canRedo).toBe(true);
|
|
235
|
+
|
|
236
|
+
manager.redo();
|
|
237
|
+
// redo of tree_delete = addNode (new nodeId)
|
|
238
|
+
expect(t.sent).toHaveLength(3); // original add + delete + re-add
|
|
239
|
+
const add = decodeOp(t.sent[2]) as { Tree: { AddNode: { value: string } } };
|
|
240
|
+
expect(add.Tree.AddNode.value).toBe("Root");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("manual batch: addNode + updateNode = one undo step", () => {
|
|
244
|
+
manager.startBatch();
|
|
245
|
+
const nodeId = manager.addNode(null, "a0", "Hello");
|
|
246
|
+
manager.updateNode(nodeId, "Hello World");
|
|
247
|
+
manager.commitBatch();
|
|
248
|
+
|
|
249
|
+
expect(manager.canUndo).toBe(true);
|
|
250
|
+
// One undo step undoes both ops
|
|
251
|
+
manager.undo();
|
|
252
|
+
expect(t.sent).toHaveLength(4); // add + update + delete + update-back
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("remote delta between op and undo does not affect target HLC", () => {
|
|
256
|
+
const nodeId = manager.addNode(null, "a0", "A");
|
|
257
|
+
|
|
258
|
+
// Remote delta: server adds another node
|
|
259
|
+
handle.applyDelta({
|
|
260
|
+
roots: [
|
|
261
|
+
{ id: nodeId, value: "A", children: [] },
|
|
262
|
+
{ id: "9999:0:99", value: "Remote", children: [] },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
manager.undo(); // should only delete our nodeId
|
|
267
|
+
const del = decodeOp(t.sent[t.sent.length - 1]) as {
|
|
268
|
+
Tree: { DeleteNode: { id: { node_id: number } } };
|
|
269
|
+
};
|
|
270
|
+
expect(del.Tree.DeleteNode.id.node_id).toBe(42); // our clientId
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { TreeHandle } from "../src/crdt/tree.js";
|
|
3
|
+
import { RGAHandle } from "../src/crdt/rga.js";
|
|
4
|
+
import { CrdtValidationError, zodValidator, fnValidator } from "../src/validation/index.js";
|
|
5
|
+
import type { WsTransport } from "../src/transport/websocket.js";
|
|
6
|
+
|
|
7
|
+
function stubTransport(): WsTransport {
|
|
8
|
+
return {
|
|
9
|
+
connect: () => {},
|
|
10
|
+
close: () => {},
|
|
11
|
+
send: () => {},
|
|
12
|
+
onStateChange: () => () => {},
|
|
13
|
+
onMessage: () => () => {},
|
|
14
|
+
pendingCount: () => 0,
|
|
15
|
+
} as unknown as WsTransport;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeTree(validator?: Parameters<typeof TreeHandle.prototype.constructor>[0]["validator"]) {
|
|
19
|
+
return new TreeHandle({ crdtId: "t", clientId: 1, transport: stubTransport(), validator });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeRga(validator?: Parameters<typeof RGAHandle.prototype.constructor>[0]["validator"]) {
|
|
23
|
+
return new RGAHandle({ crdtId: "r", clientId: 1, transport: stubTransport(), validator });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── fnValidator ──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("fnValidator", () => {
|
|
29
|
+
it("accepts when fn returns true", () => {
|
|
30
|
+
const v = fnValidator(() => true);
|
|
31
|
+
expect(() => {
|
|
32
|
+
const tree = makeTree(v);
|
|
33
|
+
tree.addNode(null, "a0", "anything");
|
|
34
|
+
}).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects when fn returns false", () => {
|
|
38
|
+
const v = fnValidator(() => false);
|
|
39
|
+
const tree = makeTree(v);
|
|
40
|
+
expect(() => tree.addNode(null, "a0", "x")).toThrow(CrdtValidationError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("uses string return as error message", () => {
|
|
44
|
+
const v = fnValidator(() => "must be non-empty");
|
|
45
|
+
const tree = makeTree(v);
|
|
46
|
+
let err: unknown;
|
|
47
|
+
try { tree.addNode(null, "a0", ""); } catch (e) { err = e; }
|
|
48
|
+
expect(err).toBeInstanceOf(CrdtValidationError);
|
|
49
|
+
expect((err as CrdtValidationError).reason).toBe("must be non-empty");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("captures thrown errors as reason", () => {
|
|
53
|
+
const v = fnValidator(() => { throw new Error("boom"); });
|
|
54
|
+
const tree = makeTree(v);
|
|
55
|
+
let err: unknown;
|
|
56
|
+
try { tree.addNode(null, "a0", "x"); } catch (e) { err = e; }
|
|
57
|
+
expect(err).toBeInstanceOf(CrdtValidationError);
|
|
58
|
+
expect((err as CrdtValidationError).reason).toBe("boom");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── zodValidator ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe("zodValidator", () => {
|
|
65
|
+
// Minimal stub matching Zod's safeParse API — no zod dependency needed
|
|
66
|
+
const schema = {
|
|
67
|
+
safeParse(value: unknown) {
|
|
68
|
+
if (typeof value === "object" && value !== null && "title" in value && typeof (value as Record<string, unknown>)["title"] === "string") {
|
|
69
|
+
return { success: true as const };
|
|
70
|
+
}
|
|
71
|
+
return { success: false as const, error: { message: "title must be a string" } };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it("accepts valid JSON matching schema", () => {
|
|
76
|
+
const tree = makeTree(zodValidator(schema));
|
|
77
|
+
expect(() => tree.addNode(null, "a0", JSON.stringify({ title: "Task" }))).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects invalid JSON value", () => {
|
|
81
|
+
const tree = makeTree(zodValidator(schema));
|
|
82
|
+
expect(() => tree.addNode(null, "a0", JSON.stringify({ title: 42 }))).toThrow(CrdtValidationError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("passes raw string to schema when not valid JSON", () => {
|
|
86
|
+
// Schema rejects non-objects — plain string "hello" fails
|
|
87
|
+
const tree = makeTree(zodValidator(schema));
|
|
88
|
+
expect(() => tree.addNode(null, "a0", "hello")).toThrow(CrdtValidationError);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── TreeHandle integration ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("TreeHandle validation", () => {
|
|
95
|
+
it("validates addNode value", () => {
|
|
96
|
+
const tree = makeTree(fnValidator((v) => v.length > 0 || "empty"));
|
|
97
|
+
expect(() => tree.addNode(null, "a0", "")).toThrow(CrdtValidationError);
|
|
98
|
+
expect(() => tree.addNode(null, "a0", "valid")).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("validates updateNode value", () => {
|
|
102
|
+
const tree = makeTree(fnValidator((v) => v !== "banned" || "banned word"));
|
|
103
|
+
const id = tree.addNode(null, "a0", "ok");
|
|
104
|
+
expect(() => tree.updateNode(id, "banned")).toThrow(CrdtValidationError);
|
|
105
|
+
expect(() => tree.updateNode(id, "fine")).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("no validator — all values accepted", () => {
|
|
109
|
+
const tree = makeTree();
|
|
110
|
+
expect(() => tree.addNode(null, "a0", "")).not.toThrow();
|
|
111
|
+
expect(() => tree.addNode(null, "a0", "anything")).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── RGAHandle integration ────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe("RGAHandle validation", () => {
|
|
118
|
+
it("validates insert text", () => {
|
|
119
|
+
const rga = makeRga(fnValidator((v) => !v.includes("<") || "no HTML"));
|
|
120
|
+
expect(() => rga.insert(0, "<script>")).toThrow(CrdtValidationError);
|
|
121
|
+
expect(() => rga.insert(0, "hello")).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("no validator — all text accepted", () => {
|
|
125
|
+
const rga = makeRga();
|
|
126
|
+
expect(() => rga.insert(0, "<anything>")).not.toThrow();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("CrdtValidationError has value and reason fields", () => {
|
|
130
|
+
const rga = makeRga(fnValidator(() => "nope"));
|
|
131
|
+
let err: unknown;
|
|
132
|
+
try { rga.insert(0, "bad"); } catch (e) { err = e; }
|
|
133
|
+
expect(err).toBeInstanceOf(CrdtValidationError);
|
|
134
|
+
expect((err as CrdtValidationError).value).toBe("bad");
|
|
135
|
+
expect((err as CrdtValidationError).reason).toBe("nope");
|
|
136
|
+
});
|
|
137
|
+
});
|