@pyreon/state-tree 0.9.0 → 0.11.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.
@@ -0,0 +1,715 @@
1
+ import { computed, effect } from "@pyreon/reactivity"
2
+ import type { Patch } from "../index"
3
+ import {
4
+ addMiddleware,
5
+ applyPatch,
6
+ applySnapshot,
7
+ getSnapshot,
8
+ model,
9
+ onPatch,
10
+ resetAllHooks,
11
+ } from "../index"
12
+
13
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
14
+
15
+ const Profile = model({
16
+ state: { name: "", bio: "" },
17
+ actions: (self) => ({
18
+ rename: (n: string) => self.name.set(n),
19
+ setBio: (b: string) => self.bio.set(b),
20
+ }),
21
+ })
22
+
23
+ const App = model({
24
+ state: { profile: Profile, title: "My App" },
25
+ actions: (self) => ({
26
+ setTitle: (t: string) => self.title.set(t),
27
+ replaceProfile: (p: any) => self.profile.set(p),
28
+ }),
29
+ })
30
+
31
+ const Counter = model({
32
+ state: { count: 0 },
33
+ views: (self) => ({
34
+ doubled: computed(() => self.count() * 2),
35
+ }),
36
+ actions: (self) => ({
37
+ inc: () => self.count.update((c: number) => c + 1),
38
+ add: (n: number) => self.count.update((c: number) => c + n),
39
+ reset: () => self.count.set(0),
40
+ }),
41
+ })
42
+
43
+ // ─── 1. Nested model deletion ────────────────────────────────────────────────
44
+
45
+ describe("nested model deletion", () => {
46
+ it("replacing a nested child model updates snapshot correctly", () => {
47
+ const Child = model({
48
+ state: { value: 0 },
49
+ actions: (self) => ({
50
+ setValue: (v: number) => self.value.set(v),
51
+ }),
52
+ })
53
+
54
+ const Parent = model({
55
+ state: { child: Child, label: "parent" },
56
+ actions: (self) => ({
57
+ replaceChild: (c: any) => self.child.set(c),
58
+ setLabel: (l: string) => self.label.set(l),
59
+ }),
60
+ })
61
+
62
+ const parent = Parent.create({
63
+ child: { value: 10 },
64
+ label: "original",
65
+ })
66
+
67
+ expect(getSnapshot(parent)).toEqual({
68
+ child: { value: 10 },
69
+ label: "original",
70
+ })
71
+
72
+ // Replace child with a new instance
73
+ const newChild = Child.create({ value: 99 })
74
+ parent.replaceChild(newChild)
75
+
76
+ // Snapshot should reflect the new child
77
+ expect(getSnapshot(parent)).toEqual({
78
+ child: { value: 99 },
79
+ label: "original",
80
+ })
81
+ })
82
+
83
+ it("nested patches stop propagating after child replacement", () => {
84
+ const Child = model({
85
+ state: { x: 0 },
86
+ actions: (self) => ({
87
+ setX: (v: number) => self.x.set(v),
88
+ }),
89
+ })
90
+
91
+ const Parent = model({
92
+ state: { child: Child },
93
+ actions: (self) => ({
94
+ replaceChild: (c: any) => self.child.set(c),
95
+ }),
96
+ })
97
+
98
+ const parent = Parent.create({ child: { x: 1 } })
99
+ const oldChild = parent.child()
100
+
101
+ const patches: Patch[] = []
102
+ onPatch(parent, (p) => patches.push(p))
103
+
104
+ // Mutate old child — should propagate
105
+ oldChild.setX(2)
106
+ expect(patches).toHaveLength(1)
107
+ expect(patches[0]).toEqual({ op: "replace", path: "/child/x", value: 2 })
108
+
109
+ // Replace child
110
+ const newChild = Child.create({ x: 50 })
111
+ parent.replaceChild(newChild)
112
+ expect(patches).toHaveLength(2)
113
+ expect(patches[1]!.path).toBe("/child")
114
+ })
115
+ })
116
+
117
+ // ─── 2. Snapshot edge cases ──────────────────────────────────────────────────
118
+
119
+ describe("snapshot edge cases", () => {
120
+ it("handles null values in state", () => {
121
+ const M = model({
122
+ state: { data: null as string | null },
123
+ actions: (self) => ({
124
+ setData: (v: string | null) => self.data.set(v),
125
+ }),
126
+ })
127
+
128
+ const m = M.create()
129
+ expect(getSnapshot(m)).toEqual({ data: null })
130
+
131
+ m.setData("hello")
132
+ expect(getSnapshot(m)).toEqual({ data: "hello" })
133
+
134
+ m.setData(null)
135
+ expect(getSnapshot(m)).toEqual({ data: null })
136
+ })
137
+
138
+ it("handles empty arrays in state", () => {
139
+ const M = model({
140
+ state: { items: [] as number[] },
141
+ actions: (self) => ({
142
+ setItems: (v: number[]) => self.items.set(v),
143
+ }),
144
+ })
145
+
146
+ const m = M.create()
147
+ expect(getSnapshot(m)).toEqual({ items: [] })
148
+
149
+ m.setItems([1, 2, 3])
150
+ expect(getSnapshot(m)).toEqual({ items: [1, 2, 3] })
151
+ })
152
+
153
+ it("handles Date objects in state", () => {
154
+ const now = new Date("2025-01-01T00:00:00Z")
155
+ const M = model({
156
+ state: { createdAt: now },
157
+ })
158
+
159
+ const m = M.create()
160
+ const snap = getSnapshot(m)
161
+ expect(snap.createdAt).toBeInstanceOf(Date)
162
+ expect((snap.createdAt as Date).toISOString()).toBe("2025-01-01T00:00:00.000Z")
163
+ })
164
+
165
+ it("handles undefined initial values by falling back to defaults", () => {
166
+ const M = model({
167
+ state: { x: 10, y: "hello", z: true },
168
+ })
169
+
170
+ // Pass undefined for all — should use defaults
171
+ const m = M.create()
172
+ expect(getSnapshot(m)).toEqual({ x: 10, y: "hello", z: true })
173
+ })
174
+
175
+ it("handles complex nested objects in state", () => {
176
+ const M = model({
177
+ state: {
178
+ config: { theme: "dark", fontSize: 14, plugins: ["a", "b"] },
179
+ },
180
+ actions: (self) => ({
181
+ setConfig: (c: any) => self.config.set(c),
182
+ }),
183
+ })
184
+
185
+ const m = M.create()
186
+ expect(getSnapshot(m)).toEqual({
187
+ config: { theme: "dark", fontSize: 14, plugins: ["a", "b"] },
188
+ })
189
+
190
+ m.setConfig({ theme: "light", fontSize: 16, plugins: [] })
191
+ expect(getSnapshot(m)).toEqual({
192
+ config: { theme: "light", fontSize: 16, plugins: [] },
193
+ })
194
+ })
195
+ })
196
+
197
+ // ─── 3. Patch replay ─────────────────────────────────────────────────────────
198
+
199
+ describe("patch replay", () => {
200
+ it("replaying recorded patches on a fresh instance reproduces final state", () => {
201
+ const M = model({
202
+ state: { a: 0, b: "" },
203
+ actions: (self) => ({
204
+ setA: (v: number) => self.a.set(v),
205
+ setB: (v: string) => self.b.set(v),
206
+ }),
207
+ })
208
+
209
+ const original = M.create()
210
+ const patches: Patch[] = []
211
+ onPatch(original, (p) => patches.push({ ...p }))
212
+
213
+ original.setA(1)
214
+ original.setB("hello")
215
+ original.setA(2)
216
+ original.setB("world")
217
+ original.setA(42)
218
+
219
+ expect(patches).toHaveLength(5)
220
+
221
+ // Replay on fresh instance
222
+ const replica = M.create()
223
+ applyPatch(replica, patches)
224
+
225
+ expect(getSnapshot(replica)).toEqual(getSnapshot(original))
226
+ expect(replica.a()).toBe(42)
227
+ expect(replica.b()).toBe("world")
228
+ })
229
+
230
+ it("replaying patches preserves intermediate state transitions", () => {
231
+ const c = Counter.create()
232
+ const patches: Patch[] = []
233
+ onPatch(c, (p) => patches.push({ ...p }))
234
+
235
+ c.inc()
236
+ c.inc()
237
+ c.inc()
238
+ c.add(10)
239
+ c.reset()
240
+ c.add(5)
241
+
242
+ // Final state: 5
243
+ const fresh = Counter.create()
244
+ applyPatch(fresh, patches)
245
+ expect(fresh.count()).toBe(5)
246
+ })
247
+ })
248
+
249
+ // ─── 4. Patch with nested operations ─────────────────────────────────────────
250
+
251
+ describe("patch with nested operations", () => {
252
+ it("applies replace on deeply nested model property", () => {
253
+ const Leaf = model({
254
+ state: { value: 0 },
255
+ actions: (self) => ({
256
+ setValue: (v: number) => self.value.set(v),
257
+ }),
258
+ })
259
+
260
+ const Branch = model({
261
+ state: { leaf: Leaf, tag: "" },
262
+ actions: (self) => ({
263
+ setTag: (t: string) => self.tag.set(t),
264
+ }),
265
+ })
266
+
267
+ const Root = model({
268
+ state: { branch: Branch, name: "root" },
269
+ actions: (self) => ({
270
+ setName: (n: string) => self.name.set(n),
271
+ }),
272
+ })
273
+
274
+ const root = Root.create({
275
+ branch: { leaf: { value: 1 }, tag: "a" },
276
+ name: "root",
277
+ })
278
+
279
+ // Apply patch to deeply nested leaf
280
+ applyPatch(root, { op: "replace", path: "/branch/leaf/value", value: 999 })
281
+ expect(root.branch().leaf().value()).toBe(999)
282
+
283
+ // Apply patch to intermediate level
284
+ applyPatch(root, { op: "replace", path: "/branch/tag", value: "updated" })
285
+ expect(root.branch().tag()).toBe("updated")
286
+
287
+ // Apply patch to top level
288
+ applyPatch(root, { op: "replace", path: "/name", value: "new-root" })
289
+ expect(root.name()).toBe("new-root")
290
+ })
291
+
292
+ it("records nested patches with correct paths", () => {
293
+ const app = App.create({
294
+ profile: { name: "Alice", bio: "dev" },
295
+ title: "Test",
296
+ })
297
+
298
+ const patches: Patch[] = []
299
+ onPatch(app, (p) => patches.push({ ...p }))
300
+
301
+ app.profile().rename("Bob")
302
+ app.profile().setBio("engineer")
303
+ app.setTitle("New Title")
304
+
305
+ expect(patches).toEqual([
306
+ { op: "replace", path: "/profile/name", value: "Bob" },
307
+ { op: "replace", path: "/profile/bio", value: "engineer" },
308
+ { op: "replace", path: "/title", value: "New Title" },
309
+ ])
310
+ })
311
+
312
+ it("replays nested patches on fresh instance", () => {
313
+ const app = App.create({
314
+ profile: { name: "Alice", bio: "" },
315
+ title: "v1",
316
+ })
317
+
318
+ const patches: Patch[] = []
319
+ onPatch(app, (p) => patches.push({ ...p }))
320
+
321
+ app.profile().rename("Carol")
322
+ app.setTitle("v2")
323
+
324
+ const fresh = App.create({
325
+ profile: { name: "Alice", bio: "" },
326
+ title: "v1",
327
+ })
328
+ applyPatch(fresh, patches)
329
+
330
+ expect(fresh.profile().name()).toBe("Carol")
331
+ expect(fresh.title()).toBe("v2")
332
+ })
333
+ })
334
+
335
+ // ─── 5. Middleware error handling ─────────────────────────────────────────────
336
+
337
+ describe("middleware error handling", () => {
338
+ it("throwing middleware does not corrupt state", () => {
339
+ const c = Counter.create({ count: 5 })
340
+
341
+ addMiddleware(c, (_call, _next) => {
342
+ throw new Error("middleware boom")
343
+ })
344
+
345
+ expect(() => c.inc()).toThrow("middleware boom")
346
+ // State should remain unchanged since the action never ran
347
+ expect(c.count()).toBe(5)
348
+ })
349
+
350
+ it("error in middleware after next() does not undo the action", () => {
351
+ const c = Counter.create({ count: 0 })
352
+
353
+ addMiddleware(c, (call, next) => {
354
+ next(call)
355
+ throw new Error("post-action error")
356
+ })
357
+
358
+ expect(() => c.inc()).toThrow("post-action error")
359
+ // The action DID run before the error
360
+ expect(c.count()).toBe(1)
361
+ })
362
+
363
+ it("error in one middleware prevents subsequent middlewares from running", () => {
364
+ const c = Counter.create()
365
+ const log: string[] = []
366
+
367
+ addMiddleware(c, (_call, _next) => {
368
+ log.push("first")
369
+ throw new Error("first fails")
370
+ })
371
+
372
+ addMiddleware(c, (call, next) => {
373
+ log.push("second")
374
+ return next(call)
375
+ })
376
+
377
+ expect(() => c.inc()).toThrow("first fails")
378
+ // Only the first middleware ran (Koa-style: first wraps second)
379
+ expect(log).toEqual(["first"])
380
+ expect(c.count()).toBe(0)
381
+ })
382
+ })
383
+
384
+ // ─── 6. Middleware chain order ────────────────────────────────────────────────
385
+
386
+ describe("middleware chain order", () => {
387
+ it("middlewares fire in registration order (Koa-style onion)", () => {
388
+ const c = Counter.create()
389
+ const log: string[] = []
390
+
391
+ addMiddleware(c, (call, next) => {
392
+ log.push("A:before")
393
+ const result = next(call)
394
+ log.push("A:after")
395
+ return result
396
+ })
397
+
398
+ addMiddleware(c, (call, next) => {
399
+ log.push("B:before")
400
+ const result = next(call)
401
+ log.push("B:after")
402
+ return result
403
+ })
404
+
405
+ addMiddleware(c, (call, next) => {
406
+ log.push("C:before")
407
+ const result = next(call)
408
+ log.push("C:after")
409
+ return result
410
+ })
411
+
412
+ c.inc()
413
+
414
+ expect(log).toEqual(["A:before", "B:before", "C:before", "C:after", "B:after", "A:after"])
415
+ })
416
+
417
+ it("middleware can modify action args before passing to next", () => {
418
+ const c = Counter.create()
419
+
420
+ addMiddleware(c, (call, next) => {
421
+ // Double the argument to add()
422
+ if (call.name === "add") {
423
+ return next({ ...call, args: [(call.args[0] as number) * 2] })
424
+ }
425
+ return next(call)
426
+ })
427
+
428
+ c.add(5)
429
+ expect(c.count()).toBe(10)
430
+ })
431
+
432
+ it("middleware can replace action result", () => {
433
+ const M = model({
434
+ state: { value: "" },
435
+ actions: (self) => ({
436
+ getValue: () => {
437
+ return self.value()
438
+ },
439
+ setValue: (v: string) => self.value.set(v),
440
+ }),
441
+ })
442
+
443
+ const m = M.create()
444
+ m.setValue("original")
445
+
446
+ addMiddleware(m, (call, next) => {
447
+ const result = next(call)
448
+ if (call.name === "getValue") {
449
+ return `intercepted:${result}`
450
+ }
451
+ return result
452
+ })
453
+
454
+ expect(m.getValue()).toBe("intercepted:original")
455
+ })
456
+ })
457
+
458
+ // ─── 7. Hook singleton behavior ──────────────────────────────────────────────
459
+
460
+ describe("hook singleton behavior", () => {
461
+ afterEach(() => resetAllHooks())
462
+
463
+ it("asHook returns the same hook function for the same id", () => {
464
+ const useCounter1 = Counter.asHook("singleton-test")
465
+ const useCounter2 = Counter.asHook("singleton-test")
466
+
467
+ // The hook functions may be different references but the instance they return is the same
468
+ const instance1 = useCounter1()
469
+ const instance2 = useCounter2()
470
+ expect(instance1).toBe(instance2)
471
+ })
472
+
473
+ it("mutations via one hook reference are visible via another", () => {
474
+ const useA = Counter.asHook("shared-hook")
475
+ const useB = Counter.asHook("shared-hook")
476
+
477
+ useA().inc()
478
+ useA().inc()
479
+
480
+ expect(useB().count()).toBe(2)
481
+ expect(useA().doubled()).toBe(4)
482
+ })
483
+
484
+ it("different models with same hook id share the same registry entry", () => {
485
+ // First model claims the id
486
+ const useFirst = Counter.asHook("conflict-id")
487
+ const instance = useFirst()
488
+
489
+ // Second model with same id returns the already-created instance (Counter, not Profile)
490
+ const useSecond = Profile.asHook("conflict-id")
491
+ expect(useSecond()).toBe(instance)
492
+ })
493
+
494
+ it("resetAllHooks makes subsequent calls create fresh instances", () => {
495
+ const useC = Counter.asHook("fresh-test")
496
+ const old = useC()
497
+ old.add(100)
498
+
499
+ resetAllHooks()
500
+
501
+ const fresh = useC()
502
+ expect(fresh).not.toBe(old)
503
+ expect(fresh.count()).toBe(0)
504
+ })
505
+ })
506
+
507
+ // ─── 8. Model with no actions ────────────────────────────────────────────────
508
+
509
+ describe("model with no actions", () => {
510
+ it("creates instance with only state", () => {
511
+ const ReadOnly = model({
512
+ state: { x: 10, y: "hello" },
513
+ })
514
+
515
+ const r = ReadOnly.create()
516
+ expect(r.x()).toBe(10)
517
+ expect(r.y()).toBe("hello")
518
+ })
519
+
520
+ it("supports views without actions", () => {
521
+ const Derived = model({
522
+ state: { a: 3, b: 4 },
523
+ views: (self) => ({
524
+ sum: computed(() => self.a() + self.b()),
525
+ product: computed(() => self.a() * self.b()),
526
+ }),
527
+ })
528
+
529
+ const d = Derived.create()
530
+ expect(d.sum()).toBe(7)
531
+ expect(d.product()).toBe(12)
532
+ })
533
+
534
+ it("snapshots work on action-less models", () => {
535
+ const M = model({ state: { val: 42 } })
536
+ const m = M.create({ val: 100 })
537
+ expect(getSnapshot(m)).toEqual({ val: 100 })
538
+ })
539
+
540
+ it("applySnapshot works on action-less models", () => {
541
+ const M = model({ state: { val: 0 } })
542
+ const m = M.create()
543
+ applySnapshot(m, { val: 999 })
544
+ expect(m.val()).toBe(999)
545
+ })
546
+
547
+ it("onPatch works on action-less models when signals are set directly", () => {
548
+ const M = model({ state: { val: 0 } })
549
+ const m = M.create()
550
+ const patches: Patch[] = []
551
+ onPatch(m, (p) => patches.push(p))
552
+
553
+ // Set via tracked signal directly
554
+ m.val.set(5)
555
+ expect(patches).toHaveLength(1)
556
+ expect(patches[0]).toEqual({ op: "replace", path: "/val", value: 5 })
557
+ })
558
+
559
+ it("middleware can be added to action-less models (no-op since no actions)", () => {
560
+ const M = model({ state: { val: 0 } })
561
+ const m = M.create()
562
+ const log: string[] = []
563
+
564
+ const unsub = addMiddleware(m, (call, next) => {
565
+ log.push(call.name)
566
+ return next(call)
567
+ })
568
+
569
+ // No actions to call, so middleware never fires
570
+ m.val.set(10) // Direct signal set bypasses middleware
571
+ expect(log).toHaveLength(0)
572
+ expect(m.val()).toBe(10)
573
+
574
+ unsub()
575
+ })
576
+ })
577
+
578
+ // ─── 9. applySnapshot with partial data ──────────────────────────────────────
579
+
580
+ describe("applySnapshot with partial data", () => {
581
+ it("only updates specified fields, keeps others unchanged", () => {
582
+ const M = model({
583
+ state: { name: "default", age: 0, active: false },
584
+ actions: (self) => ({
585
+ setName: (n: string) => self.name.set(n),
586
+ setAge: (a: number) => self.age.set(a),
587
+ }),
588
+ })
589
+
590
+ const m = M.create({ name: "Alice", age: 30, active: true })
591
+
592
+ // Only update name
593
+ applySnapshot(m, { name: "Bob" })
594
+ expect(m.name()).toBe("Bob")
595
+ expect(m.age()).toBe(30)
596
+ expect(m.active()).toBe(true)
597
+
598
+ // Only update age
599
+ applySnapshot(m, { age: 25 })
600
+ expect(m.name()).toBe("Bob")
601
+ expect(m.age()).toBe(25)
602
+ expect(m.active()).toBe(true)
603
+ })
604
+
605
+ it("empty snapshot changes nothing", () => {
606
+ const M = model({ state: { x: 1, y: 2 } })
607
+ const m = M.create()
608
+
609
+ applySnapshot(m, {})
610
+ expect(m.x()).toBe(1)
611
+ expect(m.y()).toBe(2)
612
+ })
613
+
614
+ it("partial nested snapshot updates only specified nested fields", () => {
615
+ const app = App.create({
616
+ profile: { name: "Alice", bio: "dev" },
617
+ title: "Old",
618
+ })
619
+
620
+ // Update only the nested profile name, leave bio unchanged
621
+ applySnapshot(app, { profile: { name: "Bob" } } as any)
622
+ expect(app.profile().name()).toBe("Bob")
623
+ expect(app.profile().bio()).toBe("dev")
624
+ expect(app.title()).toBe("Old")
625
+ })
626
+
627
+ it("applySnapshot batches updates — effect fires once", () => {
628
+ const M = model({ state: { a: 0, b: 0, c: 0 } })
629
+ const m = M.create()
630
+
631
+ let effectRuns = 0
632
+ effect(() => {
633
+ m.a()
634
+ m.b()
635
+ m.c()
636
+ effectRuns++
637
+ })
638
+ effectRuns = 0
639
+
640
+ applySnapshot(m, { a: 1, b: 2, c: 3 })
641
+ expect(effectRuns).toBe(1)
642
+ })
643
+ })
644
+
645
+ // ─── 10. onPatch listener cleanup ────────────────────────────────────────────
646
+
647
+ describe("onPatch listener cleanup", () => {
648
+ it("unsubscribe prevents further callbacks", () => {
649
+ const c = Counter.create()
650
+ const patches: Patch[] = []
651
+ const unsub = onPatch(c, (p) => patches.push(p))
652
+
653
+ c.inc()
654
+ expect(patches).toHaveLength(1)
655
+
656
+ unsub()
657
+
658
+ c.inc()
659
+ c.inc()
660
+ expect(patches).toHaveLength(1) // No new patches after unsub
661
+ })
662
+
663
+ it("multiple listeners can be independently unsubscribed", () => {
664
+ const c = Counter.create()
665
+ const logA: Patch[] = []
666
+ const logB: Patch[] = []
667
+
668
+ const unsubA = onPatch(c, (p) => logA.push(p))
669
+ const unsubB = onPatch(c, (p) => logB.push(p))
670
+
671
+ c.inc()
672
+ expect(logA).toHaveLength(1)
673
+ expect(logB).toHaveLength(1)
674
+
675
+ // Unsub A, B still receives
676
+ unsubA()
677
+ c.inc()
678
+ expect(logA).toHaveLength(1)
679
+ expect(logB).toHaveLength(2)
680
+
681
+ // Unsub B
682
+ unsubB()
683
+ c.inc()
684
+ expect(logA).toHaveLength(1)
685
+ expect(logB).toHaveLength(2)
686
+ })
687
+
688
+ it("double unsubscribe is safe (no-op)", () => {
689
+ const c = Counter.create()
690
+ const unsub = onPatch(c, () => {})
691
+
692
+ unsub()
693
+ expect(() => unsub()).not.toThrow()
694
+ })
695
+
696
+ it("listener added after unsub receives only new patches", () => {
697
+ const c = Counter.create()
698
+ const log1: Patch[] = []
699
+ const log2: Patch[] = []
700
+
701
+ const unsub1 = onPatch(c, (p) => log1.push(p))
702
+ c.inc() // log1: 1 patch
703
+
704
+ unsub1()
705
+ c.inc() // No listener
706
+
707
+ onPatch(c, (p) => log2.push(p))
708
+ c.inc() // log2: 1 patch
709
+
710
+ expect(log1).toHaveLength(1)
711
+ expect(log2).toHaveLength(1)
712
+ expect(log1[0]!.value).toBe(1) // First inc
713
+ expect(log2[0]!.value).toBe(3) // Third inc
714
+ })
715
+ })