@loro-extended/change 0.2.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/LICENSE +21 -0
- package/README.md +565 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +1491 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/change.test.ts +2006 -0
- package/src/change.ts +105 -0
- package/src/conversion.test.ts +728 -0
- package/src/conversion.ts +220 -0
- package/src/draft-nodes/base.ts +34 -0
- package/src/draft-nodes/counter.ts +21 -0
- package/src/draft-nodes/doc.ts +81 -0
- package/src/draft-nodes/list-base.ts +326 -0
- package/src/draft-nodes/list.ts +18 -0
- package/src/draft-nodes/map.ts +156 -0
- package/src/draft-nodes/movable-list.ts +26 -0
- package/src/draft-nodes/record.ts +215 -0
- package/src/draft-nodes/text.ts +48 -0
- package/src/draft-nodes/tree.ts +31 -0
- package/src/draft-nodes/utils.ts +55 -0
- package/src/index.ts +33 -0
- package/src/json-patch.test.ts +697 -0
- package/src/json-patch.ts +391 -0
- package/src/overlay.ts +90 -0
- package/src/record.test.ts +188 -0
- package/src/schema.fixtures.ts +138 -0
- package/src/shape.ts +348 -0
- package/src/types.ts +15 -0
- package/src/utils/type-guards.ts +210 -0
- package/src/validation.ts +261 -0
|
@@ -0,0 +1,2006 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { createTypedDoc } from "./change.js"
|
|
3
|
+
import { Shape } from "./shape.js"
|
|
4
|
+
|
|
5
|
+
describe("CRDT Operations", () => {
|
|
6
|
+
describe("Text Operations", () => {
|
|
7
|
+
it("should handle basic text insertion and deletion", () => {
|
|
8
|
+
const schema = Shape.doc({
|
|
9
|
+
title: Shape.text(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const emptyState = {
|
|
13
|
+
title: "",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
17
|
+
|
|
18
|
+
const result = typedDoc.change(draft => {
|
|
19
|
+
draft.title.insert(0, "Hello")
|
|
20
|
+
draft.title.insert(5, " World")
|
|
21
|
+
draft.title.delete(0, 5) // Delete "Hello"
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expect(result.title).toBe(" World")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("should handle text update (replacement)", () => {
|
|
28
|
+
const schema = Shape.doc({
|
|
29
|
+
content: Shape.text(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emptyState = {
|
|
33
|
+
content: "",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
37
|
+
|
|
38
|
+
const result = typedDoc.change(draft => {
|
|
39
|
+
draft.content.insert(0, "Initial content")
|
|
40
|
+
draft.content.update("Replaced content")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(result.content).toBe("Replaced content")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("should handle text marking and unmarking", () => {
|
|
47
|
+
const schema = Shape.doc({
|
|
48
|
+
richText: Shape.text(),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const emptyState = {
|
|
52
|
+
richText: "",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
56
|
+
|
|
57
|
+
const result = typedDoc.change(draft => {
|
|
58
|
+
draft.richText.insert(0, "Bold text")
|
|
59
|
+
draft.richText.mark({ start: 0, end: 4 }, "bold", true)
|
|
60
|
+
draft.richText.unmark({ start: 0, end: 2 }, "bold")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(result.richText).toBe("Bold text")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("should handle delta operations", () => {
|
|
67
|
+
const schema = Shape.doc({
|
|
68
|
+
deltaText: Shape.text(),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const emptyState = {
|
|
72
|
+
deltaText: "",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
76
|
+
|
|
77
|
+
typedDoc.change(draft => {
|
|
78
|
+
draft.deltaText.insert(0, "Hello World")
|
|
79
|
+
const delta = draft.deltaText.toDelta()
|
|
80
|
+
expect(delta).toBeDefined()
|
|
81
|
+
|
|
82
|
+
// Apply a new delta
|
|
83
|
+
draft.deltaText.applyDelta([{ insert: "New " }])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const result = typedDoc.value
|
|
87
|
+
expect(result.deltaText).toContain("New")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("should provide text length property", () => {
|
|
91
|
+
const schema = Shape.doc({
|
|
92
|
+
measuredText: Shape.text(),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const emptyState = {
|
|
96
|
+
measuredText: "",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
100
|
+
|
|
101
|
+
typedDoc.change(draft => {
|
|
102
|
+
draft.measuredText.insert(0, "Hello")
|
|
103
|
+
expect(draft.measuredText.length).toBe(5)
|
|
104
|
+
|
|
105
|
+
draft.measuredText.insert(5, " World")
|
|
106
|
+
expect(draft.measuredText.length).toBe(11)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe("Counter Operations", () => {
|
|
112
|
+
it("should handle increment and decrement operations", () => {
|
|
113
|
+
const schema = Shape.doc({
|
|
114
|
+
count: Shape.counter(),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const emptyState = {
|
|
118
|
+
count: 0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
122
|
+
|
|
123
|
+
const result = typedDoc.change(draft => {
|
|
124
|
+
draft.count.increment(5)
|
|
125
|
+
draft.count.decrement(2)
|
|
126
|
+
draft.count.increment(10)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(result.count).toBe(13) // 5 - 2 + 10 = 13
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("should provide counter value property", () => {
|
|
133
|
+
const schema = Shape.doc({
|
|
134
|
+
counter: Shape.counter(),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const emptyState = {
|
|
138
|
+
counter: 0,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
142
|
+
|
|
143
|
+
typedDoc.change(draft => {
|
|
144
|
+
draft.counter.increment(7)
|
|
145
|
+
expect(draft.counter.value).toBe(7)
|
|
146
|
+
|
|
147
|
+
draft.counter.decrement(3)
|
|
148
|
+
expect(draft.counter.value).toBe(4)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("should handle negative increments and decrements", () => {
|
|
153
|
+
const schema = Shape.doc({
|
|
154
|
+
negativeCounter: Shape.counter(),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const emptyState = {
|
|
158
|
+
negativeCounter: 0,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
162
|
+
|
|
163
|
+
const result = typedDoc.change(draft => {
|
|
164
|
+
draft.negativeCounter.increment(-5) // Negative increment
|
|
165
|
+
draft.negativeCounter.decrement(-3) // Negative decrement (adds 3)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(result.negativeCounter).toBe(-2) // -5 + 3 = -2
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("List Operations", () => {
|
|
173
|
+
it("should handle push, insert, and delete operations", () => {
|
|
174
|
+
const schema = Shape.doc({
|
|
175
|
+
items: Shape.list(Shape.plain.string()),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const emptyState = {
|
|
179
|
+
items: [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
183
|
+
|
|
184
|
+
const result = typedDoc.change(draft => {
|
|
185
|
+
draft.items.push("first")
|
|
186
|
+
draft.items.insert(0, "zero")
|
|
187
|
+
draft.items.push("second")
|
|
188
|
+
draft.items.delete(1, 1) // Delete "first"
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
expect(result.items).toEqual(["zero", "second"])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("should handle list with number items", () => {
|
|
195
|
+
const schema = Shape.doc({
|
|
196
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const emptyState = {
|
|
200
|
+
numbers: [],
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
204
|
+
|
|
205
|
+
const result = typedDoc.change(draft => {
|
|
206
|
+
draft.numbers.push(1)
|
|
207
|
+
draft.numbers.push(2)
|
|
208
|
+
draft.numbers.insert(1, 1.5)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
expect(result.numbers).toEqual([1, 1.5, 2])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("should handle list with boolean items", () => {
|
|
215
|
+
const schema = Shape.doc({
|
|
216
|
+
flags: Shape.list(Shape.plain.boolean()),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const emptyState = {
|
|
220
|
+
flags: [],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
224
|
+
|
|
225
|
+
const result = typedDoc.change(draft => {
|
|
226
|
+
draft.flags.push(true)
|
|
227
|
+
draft.flags.push(false)
|
|
228
|
+
draft.flags.insert(1, true)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
expect(result.flags).toEqual([true, true, false])
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("should provide list length and array conversion", () => {
|
|
235
|
+
const schema = Shape.doc({
|
|
236
|
+
testList: Shape.list(Shape.plain.string()),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const emptyState = {
|
|
240
|
+
testList: [],
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
244
|
+
|
|
245
|
+
typedDoc.change(draft => {
|
|
246
|
+
draft.testList.push("a")
|
|
247
|
+
draft.testList.push("b")
|
|
248
|
+
|
|
249
|
+
expect(draft.testList.length).toBe(2)
|
|
250
|
+
expect(draft.testList.toArray()).toEqual(["a", "b"])
|
|
251
|
+
expect(draft.testList.get(0)).toBe("a")
|
|
252
|
+
expect(draft.testList.get(1)).toBe("b")
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("should handle container insertion", () => {
|
|
257
|
+
const schema = Shape.doc({
|
|
258
|
+
containerList: Shape.list(Shape.text()),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const emptyState = {
|
|
262
|
+
containerList: [],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
266
|
+
|
|
267
|
+
typedDoc.change(draft => {
|
|
268
|
+
// Note: pushContainer and insertContainer expect actual container instances
|
|
269
|
+
// For testing purposes, we'll just verify the list exists
|
|
270
|
+
expect(draft.containerList.length).toBe(0)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const result = typedDoc.value
|
|
274
|
+
expect(result.containerList).toHaveLength(0) // No containers were actually added
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("should handle move operations on lists", () => {
|
|
278
|
+
const schema = Shape.doc({
|
|
279
|
+
items: Shape.list(Shape.plain.string()),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const emptyState = {
|
|
283
|
+
items: [],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
287
|
+
|
|
288
|
+
// Add initial items
|
|
289
|
+
typedDoc.change(draft => {
|
|
290
|
+
draft.items.push("first")
|
|
291
|
+
draft.items.push("second")
|
|
292
|
+
draft.items.push("third")
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// Test move operation: move first item to the end
|
|
296
|
+
const result = typedDoc.change(draft => {
|
|
297
|
+
const valueToMove = draft.items.get(0)
|
|
298
|
+
draft.items.delete(0, 1)
|
|
299
|
+
draft.items.insert(2, valueToMove)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(result.items).toEqual(["second", "third", "first"])
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe("Movable List Operations", () => {
|
|
307
|
+
it("should handle push, insert, delete, and move operations", () => {
|
|
308
|
+
const schema = Shape.doc({
|
|
309
|
+
tasks: Shape.movableList(
|
|
310
|
+
Shape.plain.object({
|
|
311
|
+
id: Shape.plain.string(),
|
|
312
|
+
title: Shape.plain.string(),
|
|
313
|
+
}),
|
|
314
|
+
),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const emptyState = {
|
|
318
|
+
tasks: [],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
322
|
+
|
|
323
|
+
const result = typedDoc.change(draft => {
|
|
324
|
+
draft.tasks.push({ id: "1", title: "Task 1" })
|
|
325
|
+
draft.tasks.push({ id: "2", title: "Task 2" })
|
|
326
|
+
draft.tasks.push({ id: "3", title: "Task 3" })
|
|
327
|
+
draft.tasks.move(0, 2) // Move first task to position 2
|
|
328
|
+
draft.tasks.delete(1, 1) // Delete middle task
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(result.tasks).toHaveLength(2)
|
|
332
|
+
expect(result.tasks[0]).toEqual({ id: "2", title: "Task 2" })
|
|
333
|
+
expect(result.tasks[1]).toEqual({ id: "1", title: "Task 1" })
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it("should handle set operation", () => {
|
|
337
|
+
const schema = Shape.doc({
|
|
338
|
+
editableList: Shape.movableList(Shape.plain.string()),
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const emptyState = {
|
|
342
|
+
editableList: [],
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
346
|
+
|
|
347
|
+
const result = typedDoc.change(draft => {
|
|
348
|
+
draft.editableList.push("original")
|
|
349
|
+
draft.editableList.set(0, "modified")
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(result.editableList).toEqual(["modified"])
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it("should provide movable list properties and methods", () => {
|
|
356
|
+
const schema = Shape.doc({
|
|
357
|
+
movableItems: Shape.movableList(Shape.plain.number()),
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const emptyState = {
|
|
361
|
+
movableItems: [],
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
365
|
+
|
|
366
|
+
typedDoc.change(draft => {
|
|
367
|
+
draft.movableItems.push(10)
|
|
368
|
+
draft.movableItems.push(20)
|
|
369
|
+
|
|
370
|
+
expect(draft.movableItems.length).toBe(2)
|
|
371
|
+
expect(draft.movableItems.get(0)).toBe(10)
|
|
372
|
+
expect(draft.movableItems.toArray()).toEqual([10, 20])
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
describe("Map Operations", () => {
|
|
378
|
+
it("should handle set, get, and delete operations", () => {
|
|
379
|
+
const schema = Shape.doc({
|
|
380
|
+
metadata: Shape.map({
|
|
381
|
+
title: Shape.plain.string(),
|
|
382
|
+
count: Shape.plain.number(),
|
|
383
|
+
enabled: Shape.plain.boolean(),
|
|
384
|
+
}),
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const emptyState = {
|
|
388
|
+
metadata: {
|
|
389
|
+
title: "",
|
|
390
|
+
count: 1,
|
|
391
|
+
enabled: false,
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
396
|
+
|
|
397
|
+
const result = typedDoc.change(draft => {
|
|
398
|
+
draft.metadata.set("title", "Test Title")
|
|
399
|
+
draft.metadata.set("count", 42)
|
|
400
|
+
draft.metadata.set("enabled", true)
|
|
401
|
+
draft.metadata.delete("count")
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
expect(result.metadata.title).toBe("Test Title")
|
|
405
|
+
expect(result.metadata.count).toBe(1) // Should fall back to empty state
|
|
406
|
+
expect(result.metadata.enabled).toBe(true)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it("should handle array values in maps", () => {
|
|
410
|
+
const schema = Shape.doc({
|
|
411
|
+
config: Shape.map({
|
|
412
|
+
tags: Shape.plain.array(Shape.plain.string()),
|
|
413
|
+
numbers: Shape.plain.array(Shape.plain.number()),
|
|
414
|
+
}),
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
const emptyState = {
|
|
418
|
+
config: {
|
|
419
|
+
tags: [],
|
|
420
|
+
numbers: [],
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
425
|
+
|
|
426
|
+
const result = typedDoc.change(draft => {
|
|
427
|
+
draft.config.set("tags", ["tag1", "tag2", "tag3"])
|
|
428
|
+
draft.config.set("numbers", [1, 2, 3])
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
expect(result.config.tags).toEqual(["tag1", "tag2", "tag3"])
|
|
432
|
+
expect(result.config.numbers).toEqual([1, 2, 3])
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it("should provide map utility methods", () => {
|
|
436
|
+
const schema = Shape.doc({
|
|
437
|
+
testMap: Shape.map({
|
|
438
|
+
key1: Shape.plain.string(),
|
|
439
|
+
key2: Shape.plain.number(),
|
|
440
|
+
}),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const emptyState = {
|
|
444
|
+
testMap: {
|
|
445
|
+
key1: "",
|
|
446
|
+
key2: 0,
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
451
|
+
|
|
452
|
+
typedDoc.change(draft => {
|
|
453
|
+
draft.testMap.set("key1", "value1")
|
|
454
|
+
draft.testMap.set("key2", 123)
|
|
455
|
+
|
|
456
|
+
expect(draft.testMap.get("key1")).toBe("value1")
|
|
457
|
+
expect(draft.testMap.has("key1")).toBe(true)
|
|
458
|
+
// Note: TypeScript enforces key constraints, so we can't test nonexistent keys
|
|
459
|
+
expect(draft.testMap.size).toBe(2)
|
|
460
|
+
expect(draft.testMap.keys()).toContain("key1")
|
|
461
|
+
expect(draft.testMap.keys()).toContain("key2")
|
|
462
|
+
expect(draft.testMap.values()).toContain("value1")
|
|
463
|
+
expect(draft.testMap.values()).toContain(123)
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it("should handle container insertion in maps", () => {
|
|
468
|
+
const schema = Shape.doc({
|
|
469
|
+
containerMap: Shape.map({
|
|
470
|
+
textField: Shape.text(),
|
|
471
|
+
}),
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const emptyState = {
|
|
475
|
+
containerMap: {
|
|
476
|
+
textField: "",
|
|
477
|
+
},
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
481
|
+
|
|
482
|
+
typedDoc.change(draft => {
|
|
483
|
+
// Note: setContainer expects actual container instances
|
|
484
|
+
// For testing purposes, we'll just verify the map exists
|
|
485
|
+
expect(draft.containerMap).toBeDefined()
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
const rawValue = typedDoc.rawValue
|
|
489
|
+
// Since no container was actually set, containerMap might be undefined
|
|
490
|
+
expect(rawValue.containerMap).toBeUndefined()
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
describe("Tree Operations", () => {
|
|
495
|
+
it("should handle basic tree operations", () => {
|
|
496
|
+
const schema = Shape.doc({
|
|
497
|
+
tree: Shape.tree(Shape.map({ name: Shape.text() })),
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
const emptyState = {
|
|
501
|
+
tree: [],
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
505
|
+
|
|
506
|
+
typedDoc.change(draft => {
|
|
507
|
+
const root = draft.tree.createNode()
|
|
508
|
+
expect(root).toBeDefined()
|
|
509
|
+
|
|
510
|
+
// Note: Tree operations have complex type requirements
|
|
511
|
+
// For testing purposes, we'll just verify basic creation works
|
|
512
|
+
expect(root.id).toBeDefined()
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("should handle tree node movement and deletion", () => {
|
|
517
|
+
const schema = Shape.doc({
|
|
518
|
+
hierarchy: Shape.tree(Shape.map({ name: Shape.text() })),
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
const emptyState = {
|
|
522
|
+
hierarchy: [],
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
526
|
+
|
|
527
|
+
typedDoc.change(draft => {
|
|
528
|
+
const parent1 = draft.hierarchy.createNode()
|
|
529
|
+
const parent2 = draft.hierarchy.createNode()
|
|
530
|
+
|
|
531
|
+
// Note: Tree operations have complex type requirements
|
|
532
|
+
// For testing purposes, we'll just verify basic creation works
|
|
533
|
+
expect(parent1.id).toBeDefined()
|
|
534
|
+
expect(parent2.id).toBeDefined()
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it("should handle tree node lookup by ID", () => {
|
|
539
|
+
const schema = Shape.doc({
|
|
540
|
+
searchableTree: Shape.tree(Shape.map({ name: Shape.text() })),
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const emptyState = {
|
|
544
|
+
searchableTree: [],
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
548
|
+
|
|
549
|
+
typedDoc.change(draft => {
|
|
550
|
+
const node = draft.searchableTree.createNode()
|
|
551
|
+
|
|
552
|
+
// Note: getNodeByID might not be available in all versions
|
|
553
|
+
// For testing purposes, we'll just verify basic creation works
|
|
554
|
+
expect(node.id).toBeDefined()
|
|
555
|
+
})
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
describe("Nested Operations", () => {
|
|
561
|
+
describe("Nested Maps", () => {
|
|
562
|
+
it("should handle deeply nested map structures", () => {
|
|
563
|
+
const schema = Shape.doc({
|
|
564
|
+
article: Shape.map({
|
|
565
|
+
title: Shape.text(),
|
|
566
|
+
metadata: Shape.map({
|
|
567
|
+
views: Shape.counter(),
|
|
568
|
+
author: Shape.map({
|
|
569
|
+
name: Shape.plain.string(),
|
|
570
|
+
email: Shape.plain.string(),
|
|
571
|
+
}),
|
|
572
|
+
}),
|
|
573
|
+
}),
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
const emptyState = {
|
|
577
|
+
article: {
|
|
578
|
+
title: "",
|
|
579
|
+
metadata: {
|
|
580
|
+
views: 0,
|
|
581
|
+
author: {
|
|
582
|
+
name: "",
|
|
583
|
+
email: "",
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
590
|
+
|
|
591
|
+
const result = typedDoc.change(draft => {
|
|
592
|
+
draft.article.title.insert(0, "Nested Article")
|
|
593
|
+
draft.article.metadata.views.increment(10)
|
|
594
|
+
draft.article.metadata.author.set("name", "John Doe")
|
|
595
|
+
draft.article.metadata.author.set("email", "john@example.com")
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
expect(result.article.title).toBe("Nested Article")
|
|
599
|
+
expect(result.article.metadata.views).toBe(10)
|
|
600
|
+
expect(result.article.metadata.author.name).toBe("John Doe")
|
|
601
|
+
expect(result.article.metadata.author.email).toBe("john@example.com")
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it("should handle maps with mixed Zod and Loro schemas", () => {
|
|
605
|
+
const schema = Shape.doc({
|
|
606
|
+
mixed: Shape.map({
|
|
607
|
+
plainString: Shape.plain.string(),
|
|
608
|
+
plainArray: Shape.plain.array(Shape.plain.number()),
|
|
609
|
+
loroText: Shape.text(),
|
|
610
|
+
loroCounter: Shape.counter(),
|
|
611
|
+
}),
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
const emptyState = {
|
|
615
|
+
mixed: {
|
|
616
|
+
plainString: "",
|
|
617
|
+
plainArray: [],
|
|
618
|
+
loroText: "",
|
|
619
|
+
loroCounter: 0,
|
|
620
|
+
},
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
624
|
+
|
|
625
|
+
const result = typedDoc.change(draft => {
|
|
626
|
+
draft.mixed.set("plainString", "Hello")
|
|
627
|
+
draft.mixed.set("plainArray", [1, 2, 3])
|
|
628
|
+
draft.mixed.loroText.insert(0, "Loro Text")
|
|
629
|
+
draft.mixed.loroCounter.increment(5)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
expect(result.mixed.plainString).toBe("Hello")
|
|
633
|
+
expect(result.mixed.plainArray).toEqual([1, 2, 3])
|
|
634
|
+
expect(result.mixed.loroText).toBe("Loro Text")
|
|
635
|
+
expect(result.mixed.loroCounter).toBe(5)
|
|
636
|
+
})
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
describe("Lists with Complex Items", () => {
|
|
640
|
+
it("should handle lists of maps with nested structures", () => {
|
|
641
|
+
const schema = Shape.doc({
|
|
642
|
+
articles: Shape.list(
|
|
643
|
+
Shape.map({
|
|
644
|
+
title: Shape.text(),
|
|
645
|
+
tags: Shape.list(Shape.plain.string()),
|
|
646
|
+
metadata: Shape.map({
|
|
647
|
+
views: Shape.counter(),
|
|
648
|
+
published: Shape.plain.boolean(),
|
|
649
|
+
}),
|
|
650
|
+
}),
|
|
651
|
+
),
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
const emptyState = {
|
|
655
|
+
articles: [],
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
659
|
+
|
|
660
|
+
const result = typedDoc.change(draft => {
|
|
661
|
+
draft.articles.push({
|
|
662
|
+
title: "First Article",
|
|
663
|
+
tags: ["tech", "programming"],
|
|
664
|
+
metadata: {
|
|
665
|
+
views: 100,
|
|
666
|
+
published: true,
|
|
667
|
+
},
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
draft.articles.push({
|
|
671
|
+
title: "Second Article",
|
|
672
|
+
tags: ["design"],
|
|
673
|
+
metadata: {
|
|
674
|
+
views: 50,
|
|
675
|
+
published: false,
|
|
676
|
+
},
|
|
677
|
+
})
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
expect(result.articles).toHaveLength(2)
|
|
681
|
+
expect(result.articles[0].title).toBe("First Article")
|
|
682
|
+
expect(result.articles[0].tags).toEqual(["tech", "programming"])
|
|
683
|
+
expect(result.articles[0].metadata.views).toBe(100)
|
|
684
|
+
expect(result.articles[0].metadata.published).toBe(true)
|
|
685
|
+
expect(result.articles[1].title).toBe("Second Article")
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
it("should handle nested plain value maps", () => {
|
|
689
|
+
const schema = Shape.doc({
|
|
690
|
+
articles: Shape.map({
|
|
691
|
+
metadata: Shape.plain.object({
|
|
692
|
+
views: Shape.plain.object({
|
|
693
|
+
page: Shape.plain.number(),
|
|
694
|
+
}),
|
|
695
|
+
}),
|
|
696
|
+
}),
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
const emptyState = {
|
|
700
|
+
articles: {
|
|
701
|
+
metadata: {
|
|
702
|
+
views: {
|
|
703
|
+
page: 0,
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
710
|
+
|
|
711
|
+
const result1 = typedDoc.change(draft => {
|
|
712
|
+
// natural object access & assignment for Value nodes
|
|
713
|
+
draft.articles.metadata.views.page = 1
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
expect(result1).toEqual({
|
|
717
|
+
articles: { metadata: { views: { page: 1 } } },
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const result2 = typedDoc.change(draft => {
|
|
721
|
+
// natural object access & assignment for Value nodes
|
|
722
|
+
draft.articles.metadata = { views: { page: 2 } }
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
expect(result2).toEqual({
|
|
726
|
+
articles: { metadata: { views: { page: 2 } } },
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
expect(typedDoc.rawValue).toEqual({
|
|
730
|
+
articles: { metadata: { views: { page: 2 } } },
|
|
731
|
+
})
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
it("should handle lists of lists", () => {
|
|
735
|
+
const schema = Shape.doc({
|
|
736
|
+
matrix: Shape.list(Shape.list(Shape.plain.number())),
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
const emptyState = {
|
|
740
|
+
matrix: [],
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
744
|
+
|
|
745
|
+
const result = typedDoc.change(draft => {
|
|
746
|
+
draft.matrix.push([1, 2, 3])
|
|
747
|
+
draft.matrix.push([4, 5, 6])
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
const correctResult = {
|
|
751
|
+
matrix: [
|
|
752
|
+
[1, 2, 3],
|
|
753
|
+
[4, 5, 6],
|
|
754
|
+
],
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
expect(result).toEqual(correctResult)
|
|
758
|
+
expect(typedDoc.rawValue).toEqual(correctResult)
|
|
759
|
+
})
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
describe("Maps with List Values", () => {
|
|
763
|
+
it("should handle maps containing lists", () => {
|
|
764
|
+
const schema = Shape.doc({
|
|
765
|
+
categories: Shape.map({
|
|
766
|
+
tech: Shape.list(Shape.plain.string()),
|
|
767
|
+
design: Shape.list(Shape.plain.string()),
|
|
768
|
+
}),
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
const emptyState = {
|
|
772
|
+
categories: {
|
|
773
|
+
tech: [],
|
|
774
|
+
design: [],
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
779
|
+
|
|
780
|
+
const result = typedDoc.change(draft => {
|
|
781
|
+
draft.categories.tech.push("JavaScript")
|
|
782
|
+
draft.categories.tech.push("TypeScript")
|
|
783
|
+
draft.categories.design.push("UI/UX")
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
expect(result.categories.tech).toEqual(["JavaScript", "TypeScript"])
|
|
787
|
+
expect(result.categories.design).toEqual(["UI/UX"])
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
describe("TypedLoroDoc", () => {
|
|
793
|
+
describe("Empty State Overlay", () => {
|
|
794
|
+
it("should return empty state when document is empty", () => {
|
|
795
|
+
const schema = Shape.doc({
|
|
796
|
+
title: Shape.text(),
|
|
797
|
+
count: Shape.counter(),
|
|
798
|
+
items: Shape.list(Shape.plain.string()),
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
const emptyState = {
|
|
802
|
+
title: "Default Title",
|
|
803
|
+
count: 0,
|
|
804
|
+
items: ["default"],
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
808
|
+
|
|
809
|
+
expect(typedDoc.value).toEqual({
|
|
810
|
+
title: "Default Title",
|
|
811
|
+
count: 0,
|
|
812
|
+
items: ["default"],
|
|
813
|
+
})
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
it("should overlay CRDT values over empty state", () => {
|
|
817
|
+
const schema = Shape.doc({
|
|
818
|
+
title: Shape.text(),
|
|
819
|
+
count: Shape.counter(),
|
|
820
|
+
items: Shape.list(Shape.plain.string()),
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
const emptyState = {
|
|
824
|
+
title: "Default Title",
|
|
825
|
+
count: 0,
|
|
826
|
+
items: ["default"],
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
830
|
+
|
|
831
|
+
const result = typedDoc.change(draft => {
|
|
832
|
+
draft.title.insert(0, "Hello World")
|
|
833
|
+
draft.count.increment(5)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
expect(result.title).toBe("Hello World")
|
|
837
|
+
expect(result.count).toBe(5)
|
|
838
|
+
expect(result.items).toEqual(["default"]) // Empty state preserved
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
it("should handle nested empty state structures", () => {
|
|
842
|
+
const schema = Shape.doc({
|
|
843
|
+
article: Shape.map({
|
|
844
|
+
title: Shape.text(),
|
|
845
|
+
metadata: Shape.map({
|
|
846
|
+
views: Shape.counter(),
|
|
847
|
+
tags: Shape.plain.array(Shape.plain.string()),
|
|
848
|
+
author: Shape.plain.string(),
|
|
849
|
+
}),
|
|
850
|
+
}),
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
const emptyState = {
|
|
854
|
+
article: {
|
|
855
|
+
title: "Default Title",
|
|
856
|
+
metadata: {
|
|
857
|
+
views: 0,
|
|
858
|
+
tags: ["default-tag"],
|
|
859
|
+
author: "Anonymous",
|
|
860
|
+
},
|
|
861
|
+
},
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
865
|
+
|
|
866
|
+
expect(typedDoc.value).toEqual(emptyState)
|
|
867
|
+
|
|
868
|
+
const result = typedDoc.change(draft => {
|
|
869
|
+
draft.article.title.insert(0, "New Title")
|
|
870
|
+
draft.article.metadata.views.increment(10)
|
|
871
|
+
draft.article.metadata.set("author", "John Doe")
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
expect(result.article.title).toBe("New Title")
|
|
875
|
+
expect(result.article.metadata.views).toBe(10)
|
|
876
|
+
expect(result.article.metadata.tags).toEqual(["default-tag"]) // Preserved
|
|
877
|
+
expect(result.article.metadata.author).toBe("John Doe")
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it("should handle empty state with optional fields", () => {
|
|
881
|
+
const schema = Shape.doc({
|
|
882
|
+
profile: Shape.map({
|
|
883
|
+
name: Shape.plain.string(),
|
|
884
|
+
email: Shape.plain.union([Shape.plain.string(), Shape.plain.null()]),
|
|
885
|
+
age: Shape.plain.union([Shape.plain.number(), Shape.plain.null()]),
|
|
886
|
+
}),
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
const emptyState = {
|
|
890
|
+
profile: {
|
|
891
|
+
name: "Anonymous",
|
|
892
|
+
email: null,
|
|
893
|
+
age: null,
|
|
894
|
+
},
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
898
|
+
|
|
899
|
+
const result = typedDoc.change(draft => {
|
|
900
|
+
draft.profile.set("name", "John Doe")
|
|
901
|
+
draft.profile.set("email", "john@example.com")
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
expect(result.profile.name).toBe("John Doe")
|
|
905
|
+
expect(result.profile.email).toBe("john@example.com")
|
|
906
|
+
expect(result.profile.age).toBeNull()
|
|
907
|
+
})
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
describe("Raw vs Overlaid Values", () => {
|
|
911
|
+
it("should distinguish between raw CRDT and overlaid values", () => {
|
|
912
|
+
const schema = Shape.doc({
|
|
913
|
+
title: Shape.text(),
|
|
914
|
+
metadata: Shape.map({
|
|
915
|
+
optional: Shape.plain.string(),
|
|
916
|
+
}),
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
const emptyState = {
|
|
920
|
+
title: "Default",
|
|
921
|
+
metadata: {
|
|
922
|
+
optional: "default-optional",
|
|
923
|
+
},
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
927
|
+
|
|
928
|
+
typedDoc.change(draft => {
|
|
929
|
+
draft.title.insert(0, "Hello")
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
// Raw value should only contain what was actually set in CRDT
|
|
933
|
+
const rawValue = typedDoc.rawValue
|
|
934
|
+
expect(rawValue.title).toBe("Hello")
|
|
935
|
+
expect(rawValue.metadata).toBeUndefined()
|
|
936
|
+
|
|
937
|
+
// Overlaid value should include empty state defaults
|
|
938
|
+
const overlaidValue = typedDoc.value
|
|
939
|
+
expect(overlaidValue.title).toBe("Hello")
|
|
940
|
+
expect(overlaidValue.metadata.optional).toBe("default-optional")
|
|
941
|
+
})
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
describe("Validation", () => {
|
|
945
|
+
it("should validate empty state against schema", () => {
|
|
946
|
+
const schema = Shape.doc({
|
|
947
|
+
title: Shape.text(),
|
|
948
|
+
count: Shape.counter(),
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
const validEmptyState = {
|
|
952
|
+
title: "",
|
|
953
|
+
count: 0,
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
expect(() => {
|
|
957
|
+
createTypedDoc(schema, validEmptyState)
|
|
958
|
+
}).not.toThrow()
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
it("should throw on invalid empty state", () => {
|
|
962
|
+
const schema = Shape.doc({
|
|
963
|
+
title: Shape.text(),
|
|
964
|
+
count: Shape.counter(),
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
const invalidEmptyState = {
|
|
968
|
+
title: 123, // Should be string
|
|
969
|
+
count: "invalid", // Should be number
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
expect(() => {
|
|
973
|
+
createTypedDoc(schema, invalidEmptyState as any)
|
|
974
|
+
}).toThrow()
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
describe("Multiple Changes", () => {
|
|
979
|
+
it("should persist state across multiple change calls", () => {
|
|
980
|
+
const schema = Shape.doc({
|
|
981
|
+
title: Shape.text(),
|
|
982
|
+
count: Shape.counter(),
|
|
983
|
+
items: Shape.list(Shape.plain.string()),
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
const emptyState = {
|
|
987
|
+
title: "",
|
|
988
|
+
count: 0,
|
|
989
|
+
items: [],
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
993
|
+
|
|
994
|
+
// First change
|
|
995
|
+
let result = typedDoc.change(draft => {
|
|
996
|
+
draft.title.insert(0, "Hello")
|
|
997
|
+
draft.count.increment(5)
|
|
998
|
+
draft.items.push("first")
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
expect(result.title).toBe("Hello")
|
|
1002
|
+
expect(result.count).toBe(5)
|
|
1003
|
+
expect(result.items).toEqual(["first"])
|
|
1004
|
+
|
|
1005
|
+
// Second change - should build on previous state
|
|
1006
|
+
result = typedDoc.change(draft => {
|
|
1007
|
+
draft.title.insert(5, " World")
|
|
1008
|
+
draft.count.increment(3)
|
|
1009
|
+
draft.items.push("second")
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
expect(result.title).toBe("Hello World")
|
|
1013
|
+
expect(result.count).toBe(8) // 5 + 3
|
|
1014
|
+
expect(result.items).toEqual(["first", "second"])
|
|
1015
|
+
})
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
describe("Schema-Aware Input Conversion", () => {
|
|
1019
|
+
it("should convert plain objects to map containers in lists", () => {
|
|
1020
|
+
const schema = Shape.doc({
|
|
1021
|
+
articles: Shape.list(
|
|
1022
|
+
Shape.map({
|
|
1023
|
+
title: Shape.text(),
|
|
1024
|
+
tags: Shape.list(Shape.plain.string()),
|
|
1025
|
+
}),
|
|
1026
|
+
),
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
const emptyState = {
|
|
1030
|
+
articles: [],
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1034
|
+
|
|
1035
|
+
const result = typedDoc.change(draft => {
|
|
1036
|
+
draft.articles.push({
|
|
1037
|
+
title: "Hello World",
|
|
1038
|
+
tags: ["hello", "world"],
|
|
1039
|
+
})
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
expect(result.articles).toHaveLength(1)
|
|
1043
|
+
expect(result.articles[0].title).toBe("Hello World")
|
|
1044
|
+
expect(result.articles[0].tags).toEqual(["hello", "world"])
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
it("should handle nested conversion in movable lists", () => {
|
|
1048
|
+
const schema = Shape.doc({
|
|
1049
|
+
tasks: Shape.movableList(
|
|
1050
|
+
Shape.map({
|
|
1051
|
+
title: Shape.text(),
|
|
1052
|
+
completed: Shape.plain.boolean(),
|
|
1053
|
+
subtasks: Shape.list(Shape.plain.string()),
|
|
1054
|
+
}),
|
|
1055
|
+
),
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
const emptyState = {
|
|
1059
|
+
tasks: [],
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1063
|
+
|
|
1064
|
+
const result = typedDoc.change(draft => {
|
|
1065
|
+
draft.tasks.push({
|
|
1066
|
+
title: "Main Task",
|
|
1067
|
+
completed: false,
|
|
1068
|
+
subtasks: ["subtask1", "subtask2"],
|
|
1069
|
+
})
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
expect(result.tasks).toHaveLength(1)
|
|
1073
|
+
expect(result.tasks[0].title).toBe("Main Task")
|
|
1074
|
+
expect(result.tasks[0].completed).toBe(false)
|
|
1075
|
+
expect(result.tasks[0].subtasks).toEqual(["subtask1", "subtask2"])
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
it("should handle deeply nested conversion", () => {
|
|
1079
|
+
const schema = Shape.doc({
|
|
1080
|
+
posts: Shape.list(
|
|
1081
|
+
Shape.map({
|
|
1082
|
+
title: Shape.text(),
|
|
1083
|
+
metadata: Shape.map({
|
|
1084
|
+
views: Shape.counter(),
|
|
1085
|
+
tags: Shape.plain.array(Shape.plain.string()),
|
|
1086
|
+
}),
|
|
1087
|
+
}),
|
|
1088
|
+
),
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
const emptyState = {
|
|
1092
|
+
posts: [],
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1096
|
+
|
|
1097
|
+
const result = typedDoc.change(draft => {
|
|
1098
|
+
draft.posts.push({
|
|
1099
|
+
title: "Complex Post",
|
|
1100
|
+
metadata: {
|
|
1101
|
+
views: 42,
|
|
1102
|
+
tags: ["complex", "nested"],
|
|
1103
|
+
},
|
|
1104
|
+
})
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
expect(result.posts).toHaveLength(1)
|
|
1108
|
+
expect(result.posts[0].title).toBe("Complex Post")
|
|
1109
|
+
expect(result.posts[0].metadata.views).toBe(42)
|
|
1110
|
+
expect(result.posts[0].metadata.tags).toEqual(["complex", "nested"])
|
|
1111
|
+
})
|
|
1112
|
+
})
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
describe("Edge Cases and Error Handling", () => {
|
|
1116
|
+
describe("Type Safety", () => {
|
|
1117
|
+
it("should maintain type safety with complex schemas", () => {
|
|
1118
|
+
const schema = Shape.doc({
|
|
1119
|
+
title: Shape.text(),
|
|
1120
|
+
metadata: Shape.map({
|
|
1121
|
+
author: Shape.plain.string(),
|
|
1122
|
+
publishedAt: Shape.plain.string(),
|
|
1123
|
+
}),
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
const emptyState = {
|
|
1127
|
+
title: "",
|
|
1128
|
+
metadata: {
|
|
1129
|
+
author: "Anonymous",
|
|
1130
|
+
publishedAt: "2024-01-01",
|
|
1131
|
+
},
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1135
|
+
|
|
1136
|
+
// Multiple changes
|
|
1137
|
+
typedDoc.change(draft => {
|
|
1138
|
+
draft.title.insert(0, "First Title")
|
|
1139
|
+
draft.metadata.set("author", "John Doe")
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
let result = typedDoc.value
|
|
1143
|
+
expect(result.title).toBe("First Title")
|
|
1144
|
+
expect(result.metadata.author).toBe("John Doe")
|
|
1145
|
+
expect(result.metadata.publishedAt).toBe("2024-01-01")
|
|
1146
|
+
|
|
1147
|
+
// More changes
|
|
1148
|
+
typedDoc.change(draft => {
|
|
1149
|
+
draft.title.update("Updated Title")
|
|
1150
|
+
draft.metadata.set("publishedAt", "2024-12-01")
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
result = typedDoc.value
|
|
1154
|
+
expect(result.title).toBe("Updated Title")
|
|
1155
|
+
expect(result.metadata.author).toBe("John Doe") // Preserved from previous change
|
|
1156
|
+
expect(result.metadata.publishedAt).toBe("2024-12-01")
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
it("should handle empty containers gracefully", () => {
|
|
1160
|
+
const schema = Shape.doc({
|
|
1161
|
+
todos: Shape.list(
|
|
1162
|
+
Shape.map({
|
|
1163
|
+
text: Shape.text(),
|
|
1164
|
+
completed: Shape.plain.boolean(),
|
|
1165
|
+
}),
|
|
1166
|
+
),
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
const emptyState = {
|
|
1170
|
+
todos: [],
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1174
|
+
|
|
1175
|
+
// Add a todo item with minimal data
|
|
1176
|
+
const result = typedDoc.change(draft => {
|
|
1177
|
+
draft.todos.push({
|
|
1178
|
+
text: "Test Todo",
|
|
1179
|
+
completed: false,
|
|
1180
|
+
})
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
expect(result.todos).toHaveLength(1)
|
|
1184
|
+
expect(result.todos[0].text).toBe("Test Todo")
|
|
1185
|
+
expect(result.todos[0].completed).toBe(false)
|
|
1186
|
+
})
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
describe("Performance and Memory", () => {
|
|
1190
|
+
it("should handle large numbers of operations efficiently", () => {
|
|
1191
|
+
const schema = Shape.doc({
|
|
1192
|
+
items: Shape.list(Shape.plain.string()),
|
|
1193
|
+
counter: Shape.counter(),
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
const emptyState = {
|
|
1197
|
+
items: [],
|
|
1198
|
+
counter: 0,
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1202
|
+
|
|
1203
|
+
const result = typedDoc.change(draft => {
|
|
1204
|
+
// Add many items
|
|
1205
|
+
for (let i = 0; i < 100; i++) {
|
|
1206
|
+
draft.items.push(`item-${i}`)
|
|
1207
|
+
draft.counter.increment(1)
|
|
1208
|
+
}
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
expect(result.items).toHaveLength(100)
|
|
1212
|
+
expect(result.counter).toBe(100)
|
|
1213
|
+
expect(result.items[0]).toBe("item-0")
|
|
1214
|
+
expect(result.items[99]).toBe("item-99")
|
|
1215
|
+
})
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
describe("Boundary Conditions", () => {
|
|
1219
|
+
it("should handle empty strings and zero values", () => {
|
|
1220
|
+
const schema = Shape.doc({
|
|
1221
|
+
text: Shape.text(),
|
|
1222
|
+
count: Shape.counter(),
|
|
1223
|
+
items: Shape.list(Shape.plain.string()),
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
const emptyState = {
|
|
1227
|
+
text: "",
|
|
1228
|
+
count: 0,
|
|
1229
|
+
items: [],
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1233
|
+
|
|
1234
|
+
const result = typedDoc.change(draft => {
|
|
1235
|
+
draft.text.insert(0, "")
|
|
1236
|
+
draft.count.increment(0)
|
|
1237
|
+
draft.items.push("")
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
expect(result.text).toBe("")
|
|
1241
|
+
expect(result.count).toBe(0)
|
|
1242
|
+
expect(result.items).toEqual([""])
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
it("should handle special characters and unicode", () => {
|
|
1246
|
+
const schema = Shape.doc({
|
|
1247
|
+
unicode: Shape.text(),
|
|
1248
|
+
emoji: Shape.list(Shape.plain.string()),
|
|
1249
|
+
})
|
|
1250
|
+
|
|
1251
|
+
const emptyState = {
|
|
1252
|
+
unicode: "",
|
|
1253
|
+
emoji: [],
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1257
|
+
|
|
1258
|
+
const result = typedDoc.change(draft => {
|
|
1259
|
+
draft.unicode.insert(0, "Hello 世界 🌍")
|
|
1260
|
+
draft.emoji.push("🚀")
|
|
1261
|
+
draft.emoji.push("⭐")
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
expect(result.unicode).toBe("Hello 世界 🌍")
|
|
1265
|
+
expect(result.emoji).toEqual(["🚀", "⭐"])
|
|
1266
|
+
})
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
describe("Array-like Methods for Lists", () => {
|
|
1270
|
+
describe("Basic Array Methods", () => {
|
|
1271
|
+
it("should support find() method on lists", () => {
|
|
1272
|
+
const schema = Shape.doc({
|
|
1273
|
+
items: Shape.list(Shape.plain.string()),
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
const emptyState = {
|
|
1277
|
+
items: [],
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1281
|
+
|
|
1282
|
+
typedDoc.change(draft => {
|
|
1283
|
+
draft.items.push("apple")
|
|
1284
|
+
draft.items.push("banana")
|
|
1285
|
+
draft.items.push("cherry")
|
|
1286
|
+
|
|
1287
|
+
// Test find method
|
|
1288
|
+
const found = draft.items.find(item => item.startsWith("b"))
|
|
1289
|
+
expect(found).toBe("banana")
|
|
1290
|
+
|
|
1291
|
+
const notFound = draft.items.find(item => item.startsWith("z"))
|
|
1292
|
+
expect(notFound).toBeUndefined()
|
|
1293
|
+
})
|
|
1294
|
+
})
|
|
1295
|
+
|
|
1296
|
+
it("should support findIndex() method on lists", () => {
|
|
1297
|
+
const schema = Shape.doc({
|
|
1298
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
const emptyState = {
|
|
1302
|
+
numbers: [],
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1306
|
+
|
|
1307
|
+
typedDoc.change(draft => {
|
|
1308
|
+
draft.numbers.push(10)
|
|
1309
|
+
draft.numbers.push(20)
|
|
1310
|
+
draft.numbers.push(30)
|
|
1311
|
+
|
|
1312
|
+
// Test findIndex method
|
|
1313
|
+
const foundIndex = draft.numbers.findIndex(num => num > 15)
|
|
1314
|
+
expect(foundIndex).toBe(1) // Should find 20 at index 1
|
|
1315
|
+
|
|
1316
|
+
const notFoundIndex = draft.numbers.findIndex(num => num > 100)
|
|
1317
|
+
expect(notFoundIndex).toBe(-1)
|
|
1318
|
+
})
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
it("should support map() method on lists", () => {
|
|
1322
|
+
const schema = Shape.doc({
|
|
1323
|
+
words: Shape.list(Shape.plain.string()),
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
const emptyState = {
|
|
1327
|
+
words: [],
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1331
|
+
|
|
1332
|
+
typedDoc.change(draft => {
|
|
1333
|
+
draft.words.push("hello")
|
|
1334
|
+
draft.words.push("world")
|
|
1335
|
+
|
|
1336
|
+
// Test map method
|
|
1337
|
+
const uppercased = draft.words.map(word => word.toUpperCase())
|
|
1338
|
+
expect(uppercased).toEqual(["HELLO", "WORLD"])
|
|
1339
|
+
|
|
1340
|
+
const lengths = draft.words.map((word, index) => ({
|
|
1341
|
+
word,
|
|
1342
|
+
index,
|
|
1343
|
+
length: word.length,
|
|
1344
|
+
}))
|
|
1345
|
+
expect(lengths).toEqual([
|
|
1346
|
+
{ word: "hello", index: 0, length: 5 },
|
|
1347
|
+
{ word: "world", index: 1, length: 5 },
|
|
1348
|
+
])
|
|
1349
|
+
})
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
it("should support filter() method on lists", () => {
|
|
1353
|
+
const schema = Shape.doc({
|
|
1354
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
1355
|
+
})
|
|
1356
|
+
|
|
1357
|
+
const emptyState = {
|
|
1358
|
+
numbers: [],
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1362
|
+
|
|
1363
|
+
typedDoc.change(draft => {
|
|
1364
|
+
draft.numbers.push(1)
|
|
1365
|
+
draft.numbers.push(2)
|
|
1366
|
+
draft.numbers.push(3)
|
|
1367
|
+
draft.numbers.push(4)
|
|
1368
|
+
draft.numbers.push(5)
|
|
1369
|
+
|
|
1370
|
+
// Test filter method
|
|
1371
|
+
const evens = draft.numbers.filter(num => num % 2 === 0)
|
|
1372
|
+
expect(evens).toEqual([2, 4])
|
|
1373
|
+
|
|
1374
|
+
const withIndex = draft.numbers.filter((_num, index) => index > 2)
|
|
1375
|
+
expect(withIndex).toEqual([4, 5])
|
|
1376
|
+
})
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
it("should support forEach() method on lists", () => {
|
|
1380
|
+
const schema = Shape.doc({
|
|
1381
|
+
items: Shape.list(Shape.plain.string()),
|
|
1382
|
+
})
|
|
1383
|
+
|
|
1384
|
+
const emptyState = {
|
|
1385
|
+
items: [],
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1389
|
+
|
|
1390
|
+
typedDoc.change(draft => {
|
|
1391
|
+
draft.items.push("a")
|
|
1392
|
+
draft.items.push("b")
|
|
1393
|
+
draft.items.push("c")
|
|
1394
|
+
|
|
1395
|
+
// Test forEach method
|
|
1396
|
+
const collected: Array<{ item: string; index: number }> = []
|
|
1397
|
+
draft.items.forEach((item, index) => {
|
|
1398
|
+
collected.push({ item, index })
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
expect(collected).toEqual([
|
|
1402
|
+
{ item: "a", index: 0 },
|
|
1403
|
+
{ item: "b", index: 1 },
|
|
1404
|
+
{ item: "c", index: 2 },
|
|
1405
|
+
])
|
|
1406
|
+
})
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
it("should support some() method on lists", () => {
|
|
1410
|
+
const schema = Shape.doc({
|
|
1411
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
const emptyState = {
|
|
1415
|
+
numbers: [],
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1419
|
+
|
|
1420
|
+
typedDoc.change(draft => {
|
|
1421
|
+
draft.numbers.push(1)
|
|
1422
|
+
draft.numbers.push(3)
|
|
1423
|
+
draft.numbers.push(5)
|
|
1424
|
+
|
|
1425
|
+
// Test some method
|
|
1426
|
+
const hasEven = draft.numbers.some(num => num % 2 === 0)
|
|
1427
|
+
expect(hasEven).toBe(false)
|
|
1428
|
+
|
|
1429
|
+
const hasOdd = draft.numbers.some(num => num % 2 === 1)
|
|
1430
|
+
expect(hasOdd).toBe(true)
|
|
1431
|
+
|
|
1432
|
+
const hasLargeNumber = draft.numbers.some(
|
|
1433
|
+
(num, index) => num > index * 2,
|
|
1434
|
+
)
|
|
1435
|
+
expect(hasLargeNumber).toBe(true)
|
|
1436
|
+
})
|
|
1437
|
+
})
|
|
1438
|
+
|
|
1439
|
+
it("should support every() method on lists", () => {
|
|
1440
|
+
const schema = Shape.doc({
|
|
1441
|
+
numbers: Shape.list(Shape.plain.number()),
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
const emptyState = {
|
|
1445
|
+
numbers: [],
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1449
|
+
|
|
1450
|
+
typedDoc.change(draft => {
|
|
1451
|
+
draft.numbers.push(2)
|
|
1452
|
+
draft.numbers.push(4)
|
|
1453
|
+
draft.numbers.push(6)
|
|
1454
|
+
|
|
1455
|
+
// Test every method
|
|
1456
|
+
const allEven = draft.numbers.every(num => num % 2 === 0)
|
|
1457
|
+
expect(allEven).toBe(true)
|
|
1458
|
+
|
|
1459
|
+
const allOdd = draft.numbers.every(num => num % 2 === 1)
|
|
1460
|
+
expect(allOdd).toBe(false)
|
|
1461
|
+
|
|
1462
|
+
const allPositive = draft.numbers.every((num, _index) => num > 0)
|
|
1463
|
+
expect(allPositive).toBe(true)
|
|
1464
|
+
})
|
|
1465
|
+
})
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
describe("Array Methods with Complex Objects", () => {
|
|
1469
|
+
it("should work with lists of plain objects", () => {
|
|
1470
|
+
const schema = Shape.doc({
|
|
1471
|
+
todos: Shape.list(
|
|
1472
|
+
Shape.plain.object({
|
|
1473
|
+
id: Shape.plain.string(),
|
|
1474
|
+
text: Shape.plain.string(),
|
|
1475
|
+
completed: Shape.plain.boolean(),
|
|
1476
|
+
}),
|
|
1477
|
+
),
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
const emptyState = {
|
|
1481
|
+
todos: [],
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1485
|
+
|
|
1486
|
+
typedDoc.change(draft => {
|
|
1487
|
+
draft.todos.push({ id: "1", text: "Buy milk", completed: false })
|
|
1488
|
+
draft.todos.push({ id: "2", text: "Walk dog", completed: true })
|
|
1489
|
+
draft.todos.push({ id: "3", text: "Write code", completed: false })
|
|
1490
|
+
|
|
1491
|
+
// Test find with objects
|
|
1492
|
+
const foundTodo = draft.todos.find(todo => todo.id === "2")
|
|
1493
|
+
expect(foundTodo).toEqual({
|
|
1494
|
+
id: "2",
|
|
1495
|
+
text: "Walk dog",
|
|
1496
|
+
completed: true,
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
// Test findIndex with objects
|
|
1500
|
+
const completedIndex = draft.todos.findIndex(todo => todo.completed)
|
|
1501
|
+
expect(completedIndex).toBe(1)
|
|
1502
|
+
|
|
1503
|
+
// Test filter with objects
|
|
1504
|
+
const incompleteTodos = draft.todos.filter(todo => !todo.completed)
|
|
1505
|
+
expect(incompleteTodos).toHaveLength(2)
|
|
1506
|
+
expect(incompleteTodos[0].text).toBe("Buy milk")
|
|
1507
|
+
expect(incompleteTodos[1].text).toBe("Write code")
|
|
1508
|
+
|
|
1509
|
+
// Test map with objects
|
|
1510
|
+
const todoTexts = draft.todos.map(todo => todo.text)
|
|
1511
|
+
expect(todoTexts).toEqual(["Buy milk", "Walk dog", "Write code"])
|
|
1512
|
+
|
|
1513
|
+
// Test some with objects
|
|
1514
|
+
const hasCompleted = draft.todos.some(todo => todo.completed)
|
|
1515
|
+
expect(hasCompleted).toBe(true)
|
|
1516
|
+
|
|
1517
|
+
// Test every with objects
|
|
1518
|
+
const allCompleted = draft.todos.every(todo => todo.completed)
|
|
1519
|
+
expect(allCompleted).toBe(false)
|
|
1520
|
+
})
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
it("should work with lists of maps (nested containers)", () => {
|
|
1524
|
+
const schema = Shape.doc({
|
|
1525
|
+
articles: Shape.list(
|
|
1526
|
+
Shape.map({
|
|
1527
|
+
title: Shape.text(),
|
|
1528
|
+
published: Shape.plain.boolean(),
|
|
1529
|
+
}),
|
|
1530
|
+
),
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
const emptyState = {
|
|
1534
|
+
articles: [],
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1538
|
+
|
|
1539
|
+
typedDoc.change(draft => {
|
|
1540
|
+
draft.articles.push({
|
|
1541
|
+
title: "First Article",
|
|
1542
|
+
published: true,
|
|
1543
|
+
})
|
|
1544
|
+
draft.articles.push({
|
|
1545
|
+
title: "Second Article",
|
|
1546
|
+
published: false,
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
// Test find with nested containers
|
|
1550
|
+
const publishedArticle = draft.articles.find(
|
|
1551
|
+
article => article.published,
|
|
1552
|
+
)
|
|
1553
|
+
expect(publishedArticle?.published).toBe(true)
|
|
1554
|
+
|
|
1555
|
+
// Test map with nested containers
|
|
1556
|
+
const titles = draft.articles.map(article => article.title)
|
|
1557
|
+
expect(titles).toEqual(["First Article", "Second Article"])
|
|
1558
|
+
|
|
1559
|
+
// Test filter with nested containers
|
|
1560
|
+
const unpublished = draft.articles.filter(
|
|
1561
|
+
article => !article.published,
|
|
1562
|
+
)
|
|
1563
|
+
expect(unpublished).toHaveLength(1)
|
|
1564
|
+
})
|
|
1565
|
+
})
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
describe("Array Methods with MovableList", () => {
|
|
1569
|
+
it("should support all array methods on movable lists", () => {
|
|
1570
|
+
const schema = Shape.doc({
|
|
1571
|
+
tasks: Shape.movableList(
|
|
1572
|
+
Shape.plain.object({
|
|
1573
|
+
id: Shape.plain.string(),
|
|
1574
|
+
priority: Shape.plain.number(),
|
|
1575
|
+
}),
|
|
1576
|
+
),
|
|
1577
|
+
})
|
|
1578
|
+
|
|
1579
|
+
const emptyState = {
|
|
1580
|
+
tasks: [],
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1584
|
+
|
|
1585
|
+
typedDoc.change(draft => {
|
|
1586
|
+
draft.tasks.push({ id: "1", priority: 1 })
|
|
1587
|
+
draft.tasks.push({ id: "2", priority: 3 })
|
|
1588
|
+
draft.tasks.push({ id: "3", priority: 2 })
|
|
1589
|
+
|
|
1590
|
+
// Test find
|
|
1591
|
+
const highPriorityTask = draft.tasks.find(task => task.priority === 3)
|
|
1592
|
+
expect(highPriorityTask?.id).toBe("2")
|
|
1593
|
+
|
|
1594
|
+
// Test findIndex
|
|
1595
|
+
const mediumPriorityIndex = draft.tasks.findIndex(
|
|
1596
|
+
task => task.priority === 2,
|
|
1597
|
+
)
|
|
1598
|
+
expect(mediumPriorityIndex).toBe(2)
|
|
1599
|
+
|
|
1600
|
+
// Test filter
|
|
1601
|
+
const lowPriorityTasks = draft.tasks.filter(
|
|
1602
|
+
task => task.priority <= 2,
|
|
1603
|
+
)
|
|
1604
|
+
expect(lowPriorityTasks).toHaveLength(2)
|
|
1605
|
+
|
|
1606
|
+
// Test map
|
|
1607
|
+
const priorities = draft.tasks.map(task => task.priority)
|
|
1608
|
+
expect(priorities).toEqual([1, 3, 2])
|
|
1609
|
+
|
|
1610
|
+
// Test some
|
|
1611
|
+
const hasHighPriority = draft.tasks.some(task => task.priority > 2)
|
|
1612
|
+
expect(hasHighPriority).toBe(true)
|
|
1613
|
+
|
|
1614
|
+
// Test every
|
|
1615
|
+
const allHavePriority = draft.tasks.every(task => task.priority > 0)
|
|
1616
|
+
expect(allHavePriority).toBe(true)
|
|
1617
|
+
})
|
|
1618
|
+
})
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
describe("Edge Cases", () => {
|
|
1622
|
+
it("should handle empty lists correctly", () => {
|
|
1623
|
+
const schema = Shape.doc({
|
|
1624
|
+
items: Shape.list(Shape.plain.string()),
|
|
1625
|
+
})
|
|
1626
|
+
|
|
1627
|
+
const emptyState = {
|
|
1628
|
+
items: [],
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1632
|
+
|
|
1633
|
+
typedDoc.change(draft => {
|
|
1634
|
+
// Test all methods on empty list
|
|
1635
|
+
expect(draft.items.find(_item => true)).toBeUndefined()
|
|
1636
|
+
expect(draft.items.findIndex(_item => true)).toBe(-1)
|
|
1637
|
+
expect(draft.items.map(item => item)).toEqual([])
|
|
1638
|
+
expect(draft.items.filter(_item => true)).toEqual([])
|
|
1639
|
+
expect(draft.items.some(_item => true)).toBe(false)
|
|
1640
|
+
expect(draft.items.every(_item => true)).toBe(true) // vacuous truth
|
|
1641
|
+
|
|
1642
|
+
let forEachCalled = false
|
|
1643
|
+
draft.items.forEach(() => {
|
|
1644
|
+
forEachCalled = true
|
|
1645
|
+
})
|
|
1646
|
+
expect(forEachCalled).toBe(false)
|
|
1647
|
+
})
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it("should handle single item lists correctly", () => {
|
|
1651
|
+
const schema = Shape.doc({
|
|
1652
|
+
items: Shape.list(Shape.plain.number()),
|
|
1653
|
+
})
|
|
1654
|
+
|
|
1655
|
+
const emptyState = {
|
|
1656
|
+
items: [],
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1660
|
+
|
|
1661
|
+
typedDoc.change(draft => {
|
|
1662
|
+
draft.items.push(42)
|
|
1663
|
+
|
|
1664
|
+
// Test all methods on single item list
|
|
1665
|
+
expect(draft.items.find(item => item === 42)).toBe(42)
|
|
1666
|
+
expect(draft.items.find(item => item === 99)).toBeUndefined()
|
|
1667
|
+
expect(draft.items.map(item => item * 2)).toEqual([84])
|
|
1668
|
+
expect(draft.items.filter(item => item > 0)).toEqual([42])
|
|
1669
|
+
expect(draft.items.filter(item => item < 0)).toEqual([])
|
|
1670
|
+
expect(draft.items.some(item => item === 42)).toBe(true)
|
|
1671
|
+
expect(draft.items.some(item => item === 99)).toBe(false)
|
|
1672
|
+
expect(draft.items.every(item => item === 42)).toBe(true)
|
|
1673
|
+
expect(draft.items.every(item => item > 0)).toBe(true)
|
|
1674
|
+
expect(draft.items.every(item => item < 0)).toBe(false)
|
|
1675
|
+
|
|
1676
|
+
const collected: number[] = []
|
|
1677
|
+
// biome-ignore lint/suspicious/useIterableCallbackReturn: draft does not have iterable
|
|
1678
|
+
draft.items.forEach(item => collected.push(item))
|
|
1679
|
+
expect(collected).toEqual([42])
|
|
1680
|
+
})
|
|
1681
|
+
})
|
|
1682
|
+
|
|
1683
|
+
it("should provide correct index parameter in callbacks", () => {
|
|
1684
|
+
const schema = Shape.doc({
|
|
1685
|
+
items: Shape.list(Shape.plain.string()),
|
|
1686
|
+
})
|
|
1687
|
+
|
|
1688
|
+
const emptyState = {
|
|
1689
|
+
items: [],
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1693
|
+
|
|
1694
|
+
typedDoc.change(draft => {
|
|
1695
|
+
draft.items.push("a")
|
|
1696
|
+
draft.items.push("b")
|
|
1697
|
+
draft.items.push("c")
|
|
1698
|
+
|
|
1699
|
+
// Test that index parameter is correct in all methods
|
|
1700
|
+
const findResult = draft.items.find((_item, index) => index === 1)
|
|
1701
|
+
expect(findResult).toBe("b")
|
|
1702
|
+
|
|
1703
|
+
const findIndexResult = draft.items.findIndex(
|
|
1704
|
+
(_item, index) => index === 2,
|
|
1705
|
+
)
|
|
1706
|
+
expect(findIndexResult).toBe(2)
|
|
1707
|
+
|
|
1708
|
+
const mapResult = draft.items.map((item, index) => `${index}:${item}`)
|
|
1709
|
+
expect(mapResult).toEqual(["0:a", "1:b", "2:c"])
|
|
1710
|
+
|
|
1711
|
+
const filterResult = draft.items.filter(
|
|
1712
|
+
(_item, index) => index % 2 === 0,
|
|
1713
|
+
)
|
|
1714
|
+
expect(filterResult).toEqual(["a", "c"])
|
|
1715
|
+
|
|
1716
|
+
const someResult = draft.items.some(
|
|
1717
|
+
(item, index) => index === 1 && item === "b",
|
|
1718
|
+
)
|
|
1719
|
+
expect(someResult).toBe(true)
|
|
1720
|
+
|
|
1721
|
+
const everyResult = draft.items.every((_item, index) => index < 3)
|
|
1722
|
+
expect(everyResult).toBe(true)
|
|
1723
|
+
|
|
1724
|
+
const forEachResults: Array<{ item: string; index: number }> = []
|
|
1725
|
+
draft.items.forEach((item, index) => {
|
|
1726
|
+
forEachResults.push({ item, index })
|
|
1727
|
+
})
|
|
1728
|
+
expect(forEachResults).toEqual([
|
|
1729
|
+
{ item: "a", index: 0 },
|
|
1730
|
+
{ item: "b", index: 1 },
|
|
1731
|
+
{ item: "c", index: 2 },
|
|
1732
|
+
])
|
|
1733
|
+
})
|
|
1734
|
+
})
|
|
1735
|
+
|
|
1736
|
+
describe("Find-and-Mutate Patterns", () => {
|
|
1737
|
+
it("should allow mutation of items found via array methods", () => {
|
|
1738
|
+
const schema = Shape.doc({
|
|
1739
|
+
todos: Shape.list(
|
|
1740
|
+
Shape.plain.object({
|
|
1741
|
+
id: Shape.plain.string(),
|
|
1742
|
+
text: Shape.plain.string(),
|
|
1743
|
+
completed: Shape.plain.boolean(),
|
|
1744
|
+
}),
|
|
1745
|
+
),
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
const emptyState = {
|
|
1749
|
+
todos: [],
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1753
|
+
|
|
1754
|
+
// Add initial todos
|
|
1755
|
+
typedDoc.change(draft => {
|
|
1756
|
+
draft.todos.push({ id: "1", text: "Buy milk", completed: false })
|
|
1757
|
+
draft.todos.push({ id: "2", text: "Walk dog", completed: false })
|
|
1758
|
+
draft.todos.push({ id: "3", text: "Write code", completed: true })
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
// Test the key developer expectation: find + mutate
|
|
1762
|
+
const result = typedDoc.change(draft => {
|
|
1763
|
+
// Find a todo and toggle its completion status
|
|
1764
|
+
const todo = draft.todos.find(t => t.id === "2")
|
|
1765
|
+
if (todo) {
|
|
1766
|
+
todo.completed = !todo.completed // This should work and persist!
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Find another todo and change its text
|
|
1770
|
+
const codeTodo = draft.todos.find(t => t.text === "Write code")
|
|
1771
|
+
if (codeTodo) {
|
|
1772
|
+
codeTodo.text = "Write better code"
|
|
1773
|
+
}
|
|
1774
|
+
})
|
|
1775
|
+
|
|
1776
|
+
// Verify the mutations persisted to the document state
|
|
1777
|
+
expect(result.todos[0]).toEqual({
|
|
1778
|
+
id: "1",
|
|
1779
|
+
text: "Buy milk",
|
|
1780
|
+
completed: false,
|
|
1781
|
+
})
|
|
1782
|
+
expect(result.todos[1]).toEqual({
|
|
1783
|
+
id: "2",
|
|
1784
|
+
text: "Walk dog",
|
|
1785
|
+
completed: true,
|
|
1786
|
+
}) // Should be toggled
|
|
1787
|
+
expect(result.todos[2]).toEqual({
|
|
1788
|
+
id: "3",
|
|
1789
|
+
text: "Write better code",
|
|
1790
|
+
completed: true,
|
|
1791
|
+
}) // Text should be changed
|
|
1792
|
+
|
|
1793
|
+
// Also verify via typedDoc.value
|
|
1794
|
+
const finalState = typedDoc.value
|
|
1795
|
+
expect(finalState.todos[1].completed).toBe(true)
|
|
1796
|
+
expect(finalState.todos[2].text).toBe("Write better code")
|
|
1797
|
+
})
|
|
1798
|
+
|
|
1799
|
+
it("should allow mutation of nested container items found via array methods", () => {
|
|
1800
|
+
const schema = Shape.doc({
|
|
1801
|
+
articles: Shape.list(
|
|
1802
|
+
Shape.map({
|
|
1803
|
+
title: Shape.text(),
|
|
1804
|
+
viewCount: Shape.counter(),
|
|
1805
|
+
metadata: Shape.plain.object({
|
|
1806
|
+
author: Shape.plain.string(),
|
|
1807
|
+
published: Shape.plain.boolean(),
|
|
1808
|
+
}),
|
|
1809
|
+
}),
|
|
1810
|
+
),
|
|
1811
|
+
})
|
|
1812
|
+
|
|
1813
|
+
const emptyState = {
|
|
1814
|
+
articles: [],
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1818
|
+
|
|
1819
|
+
// Add initial articles
|
|
1820
|
+
typedDoc.change(draft => {
|
|
1821
|
+
draft.articles.push({
|
|
1822
|
+
title: "First Article",
|
|
1823
|
+
viewCount: 0,
|
|
1824
|
+
metadata: { author: "Alice", published: false },
|
|
1825
|
+
})
|
|
1826
|
+
draft.articles.push({
|
|
1827
|
+
title: "Second Article",
|
|
1828
|
+
viewCount: 5,
|
|
1829
|
+
metadata: { author: "Bob", published: true },
|
|
1830
|
+
})
|
|
1831
|
+
})
|
|
1832
|
+
|
|
1833
|
+
// Test mutation of nested containers found via array methods
|
|
1834
|
+
const result = typedDoc.change(draft => {
|
|
1835
|
+
// Find article by author and modify its nested properties
|
|
1836
|
+
const aliceArticle = draft.articles.find(
|
|
1837
|
+
article => article.metadata.author === "Alice",
|
|
1838
|
+
)
|
|
1839
|
+
if (aliceArticle) {
|
|
1840
|
+
// Mutate text container
|
|
1841
|
+
aliceArticle.title.insert(0, "📝 ")
|
|
1842
|
+
// Mutate counter container
|
|
1843
|
+
aliceArticle.viewCount.increment(10)
|
|
1844
|
+
// Mutate plain object property
|
|
1845
|
+
aliceArticle.metadata.published = true
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Find article by publication status and modify it
|
|
1849
|
+
const publishedArticle = draft.articles.find(
|
|
1850
|
+
article =>
|
|
1851
|
+
article.metadata.published === true &&
|
|
1852
|
+
article.metadata.author === "Bob",
|
|
1853
|
+
)
|
|
1854
|
+
if (publishedArticle) {
|
|
1855
|
+
publishedArticle.title.update("Updated Second Article")
|
|
1856
|
+
publishedArticle.viewCount.increment(3)
|
|
1857
|
+
}
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
// Verify all mutations persisted correctly
|
|
1861
|
+
expect(result.articles[0].title).toBe("📝 First Article")
|
|
1862
|
+
expect(result.articles[0].viewCount).toBe(10)
|
|
1863
|
+
expect(result.articles[0].metadata.published).toBe(true)
|
|
1864
|
+
expect(result.articles[1].title).toBe("Updated Second Article")
|
|
1865
|
+
expect(result.articles[1].viewCount).toBe(8) // 5 + 3
|
|
1866
|
+
|
|
1867
|
+
// Verify via typedDoc.value as well
|
|
1868
|
+
const finalState = typedDoc.value
|
|
1869
|
+
expect(finalState.articles[0].title).toBe("📝 First Article")
|
|
1870
|
+
expect(finalState.articles[0].viewCount).toBe(10)
|
|
1871
|
+
expect(finalState.articles[1].viewCount).toBe(8)
|
|
1872
|
+
})
|
|
1873
|
+
|
|
1874
|
+
it("should support common developer patterns with array methods", () => {
|
|
1875
|
+
const schema = Shape.doc({
|
|
1876
|
+
users: Shape.list(
|
|
1877
|
+
Shape.plain.object({
|
|
1878
|
+
id: Shape.plain.string(),
|
|
1879
|
+
name: Shape.plain.string(),
|
|
1880
|
+
active: Shape.plain.boolean(),
|
|
1881
|
+
score: Shape.plain.number(),
|
|
1882
|
+
}),
|
|
1883
|
+
),
|
|
1884
|
+
})
|
|
1885
|
+
|
|
1886
|
+
const emptyState = {
|
|
1887
|
+
users: [],
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1891
|
+
|
|
1892
|
+
// Add initial users
|
|
1893
|
+
typedDoc.change(draft => {
|
|
1894
|
+
draft.users.push({
|
|
1895
|
+
id: "1",
|
|
1896
|
+
name: "Alice",
|
|
1897
|
+
active: true,
|
|
1898
|
+
score: 100,
|
|
1899
|
+
})
|
|
1900
|
+
draft.users.push({ id: "2", name: "Bob", active: false, score: 85 })
|
|
1901
|
+
draft.users.push({
|
|
1902
|
+
id: "3",
|
|
1903
|
+
name: "Charlie",
|
|
1904
|
+
active: true,
|
|
1905
|
+
score: 120,
|
|
1906
|
+
})
|
|
1907
|
+
})
|
|
1908
|
+
|
|
1909
|
+
const result = typedDoc.change(draft => {
|
|
1910
|
+
// Pattern 1: Find and toggle boolean
|
|
1911
|
+
const inactiveUser = draft.users.find(user => !user.active)
|
|
1912
|
+
if (inactiveUser) {
|
|
1913
|
+
inactiveUser.active = true
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// Pattern 2: Find by condition and update multiple properties
|
|
1917
|
+
const highScorer = draft.users.find(user => user.score > 110)
|
|
1918
|
+
if (highScorer) {
|
|
1919
|
+
highScorer.name = `${highScorer.name} (VIP)`
|
|
1920
|
+
highScorer.score += 50
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Pattern 3: Filter and modify multiple items
|
|
1924
|
+
const activeUsers = draft.users.filter(user => user.active)
|
|
1925
|
+
activeUsers.forEach(user => {
|
|
1926
|
+
user.score += 10 // Bonus points for active users
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
// Pattern 4: Find by index-based condition
|
|
1930
|
+
const firstUser = draft.users.find((_user, index) => index === 0)
|
|
1931
|
+
if (firstUser) {
|
|
1932
|
+
firstUser.name = `👑 ${firstUser.name}`
|
|
1933
|
+
}
|
|
1934
|
+
})
|
|
1935
|
+
|
|
1936
|
+
// Verify all patterns worked
|
|
1937
|
+
expect(result.users[0].name).toBe("👑 Alice")
|
|
1938
|
+
expect(result.users[0].score).toBe(110) // 100 + 10 bonus
|
|
1939
|
+
expect(result.users[1].active).toBe(true) // Was toggled from false
|
|
1940
|
+
expect(result.users[1].score).toBe(95) // 85 + 10 bonus
|
|
1941
|
+
expect(result.users[2].name).toBe("Charlie (VIP)")
|
|
1942
|
+
expect(result.users[2].score).toBe(180) // 120 + 50 VIP + 10 bonus
|
|
1943
|
+
|
|
1944
|
+
// Verify persistence
|
|
1945
|
+
const finalState = typedDoc.value
|
|
1946
|
+
expect(finalState.users.every(user => user.active)).toBe(true)
|
|
1947
|
+
expect(finalState.users[2].name).toContain("VIP")
|
|
1948
|
+
})
|
|
1949
|
+
|
|
1950
|
+
it("should handle edge cases in find-and-mutate patterns", () => {
|
|
1951
|
+
const schema = Shape.doc({
|
|
1952
|
+
items: Shape.list(
|
|
1953
|
+
Shape.plain.object({
|
|
1954
|
+
id: Shape.plain.string(),
|
|
1955
|
+
value: Shape.plain.number(),
|
|
1956
|
+
}),
|
|
1957
|
+
),
|
|
1958
|
+
})
|
|
1959
|
+
|
|
1960
|
+
const emptyState = {
|
|
1961
|
+
items: [],
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const typedDoc = createTypedDoc(schema, emptyState)
|
|
1965
|
+
|
|
1966
|
+
const result = typedDoc.change(draft => {
|
|
1967
|
+
// Add some items
|
|
1968
|
+
draft.items.push({ id: "1", value: 10 })
|
|
1969
|
+
draft.items.push({ id: "2", value: 20 })
|
|
1970
|
+
|
|
1971
|
+
// Try to find non-existent item - should not crash
|
|
1972
|
+
const nonExistent = draft.items.find(item => item.id === "999")
|
|
1973
|
+
if (nonExistent) {
|
|
1974
|
+
nonExistent.value = 999 // This shouldn't execute
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Find existing item and mutate
|
|
1978
|
+
const existing = draft.items.find(item => item.id === "1")
|
|
1979
|
+
if (existing) {
|
|
1980
|
+
existing.value *= 2
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Use findIndex to locate and mutate
|
|
1984
|
+
// Note: After the first mutation, item with id "1" now has value 20,
|
|
1985
|
+
// so findIndex will find that item (index 0), not the original item with id "2"
|
|
1986
|
+
const index = draft.items.findIndex(item => item.value === 20)
|
|
1987
|
+
if (index !== -1) {
|
|
1988
|
+
const item = draft.items.get(index)
|
|
1989
|
+
if (item) {
|
|
1990
|
+
item.value += 5
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
})
|
|
1994
|
+
|
|
1995
|
+
// Verify mutations worked correctly
|
|
1996
|
+
expect(result.items).toHaveLength(2)
|
|
1997
|
+
expect(result.items[0].value).toBe(25) // 10 * 2 + 5 (found by findIndex)
|
|
1998
|
+
expect(result.items[1].value).toBe(20) // 20 (unchanged)
|
|
1999
|
+
|
|
2000
|
+
// Verify no phantom items were created
|
|
2001
|
+
expect(result.items.find(item => item.id === "999")).toBeUndefined()
|
|
2002
|
+
})
|
|
2003
|
+
})
|
|
2004
|
+
})
|
|
2005
|
+
})
|
|
2006
|
+
})
|