jotai-state-tree 0.1.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,1371 @@
1
+ /**
2
+ * Tests for jotai-state-tree
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import {
7
+ types,
8
+ getSnapshot,
9
+ applySnapshot,
10
+ onSnapshot,
11
+ onPatch,
12
+ getRoot,
13
+ getParent,
14
+ getEnv,
15
+ isAlive,
16
+ destroy,
17
+ flow,
18
+ clone,
19
+ getPath,
20
+ getPathParts,
21
+ detach,
22
+ walk,
23
+ isStateTreeNode,
24
+ getIdentifier,
25
+ addMiddleware,
26
+ recordActions,
27
+ protect,
28
+ unprotect,
29
+ isProtected,
30
+ applyPatch,
31
+ cast,
32
+ // Advanced tree utilities
33
+ getRelativePath,
34
+ isAncestor,
35
+ findAll,
36
+ getTreeStats,
37
+ cloneDeep,
38
+ // Undo/Time travel
39
+ createUndoManager,
40
+ createTimeTravelManager,
41
+ } from '../index';
42
+
43
+ describe('Primitive Types', () => {
44
+ it('should create string type', () => {
45
+ const StringModel = types.model('StringModel', {
46
+ value: types.string,
47
+ });
48
+
49
+ const instance = StringModel.create({ value: 'hello' });
50
+ expect(instance.value).toBe('hello');
51
+ });
52
+
53
+ it('should create number type', () => {
54
+ const NumberModel = types.model('NumberModel', {
55
+ value: types.number,
56
+ });
57
+
58
+ const instance = NumberModel.create({ value: 42 });
59
+ expect(instance.value).toBe(42);
60
+ });
61
+
62
+ it('should create boolean type', () => {
63
+ const BoolModel = types.model('BoolModel', {
64
+ value: types.boolean,
65
+ });
66
+
67
+ const instance = BoolModel.create({ value: true });
68
+ expect(instance.value).toBe(true);
69
+ });
70
+
71
+ it('should create integer type', () => {
72
+ const IntModel = types.model('IntModel', {
73
+ value: types.integer,
74
+ });
75
+
76
+ const instance = IntModel.create({ value: 42 });
77
+ expect(instance.value).toBe(42);
78
+ });
79
+
80
+ it('should create literal type', () => {
81
+ const LiteralModel = types.model('LiteralModel', {
82
+ status: types.literal('active'),
83
+ });
84
+
85
+ const instance = LiteralModel.create({ status: 'active' });
86
+ expect(instance.status).toBe('active');
87
+ });
88
+
89
+ it('should create enumeration type', () => {
90
+ const Priority = types.enumeration('Priority', ['low', 'medium', 'high']);
91
+ const EnumModel = types.model('EnumModel', {
92
+ priority: Priority,
93
+ });
94
+
95
+ const instance = EnumModel.create({ priority: 'high' });
96
+ expect(instance.priority).toBe('high');
97
+ });
98
+ });
99
+
100
+ describe('Optional Types', () => {
101
+ it('should use default value when not provided', () => {
102
+ const Model = types.model('Model', {
103
+ name: types.optional(types.string, 'default'),
104
+ });
105
+
106
+ const instance = Model.create({});
107
+ expect(instance.name).toBe('default');
108
+ });
109
+
110
+ it('should use provided value over default', () => {
111
+ const Model = types.model('Model', {
112
+ name: types.optional(types.string, 'default'),
113
+ });
114
+
115
+ const instance = Model.create({ name: 'custom' });
116
+ expect(instance.name).toBe('custom');
117
+ });
118
+
119
+ it('should handle maybe type', () => {
120
+ const Model = types.model('Model', {
121
+ name: types.maybe(types.string),
122
+ });
123
+
124
+ const instance = Model.create({});
125
+ expect(instance.name).toBeUndefined();
126
+
127
+ const instance2 = Model.create({ name: 'hello' });
128
+ expect(instance2.name).toBe('hello');
129
+ });
130
+
131
+ it('should handle maybeNull type', () => {
132
+ const Model = types.model('Model', {
133
+ name: types.maybeNull(types.string),
134
+ });
135
+
136
+ const instance = Model.create({});
137
+ expect(instance.name).toBeNull();
138
+
139
+ const instance2 = Model.create({ name: 'hello' });
140
+ expect(instance2.name).toBe('hello');
141
+ });
142
+ });
143
+
144
+ describe('Model Type', () => {
145
+ it('should create a simple model', () => {
146
+ const User = types.model('User', {
147
+ name: types.string,
148
+ age: types.number,
149
+ });
150
+
151
+ const user = User.create({ name: 'John', age: 30 });
152
+ expect(user.name).toBe('John');
153
+ expect(user.age).toBe(30);
154
+ });
155
+
156
+ it('should support views', () => {
157
+ const User = types
158
+ .model('User', {
159
+ firstName: types.string,
160
+ lastName: types.string,
161
+ })
162
+ .views((self) => ({
163
+ get fullName() {
164
+ return `${self.firstName} ${self.lastName}`;
165
+ },
166
+ }));
167
+
168
+ const user = User.create({ firstName: 'John', lastName: 'Doe' });
169
+ expect(user.fullName).toBe('John Doe');
170
+ });
171
+
172
+ it('should support actions', () => {
173
+ const Counter = types
174
+ .model('Counter', {
175
+ count: types.optional(types.number, 0),
176
+ })
177
+ .actions((self) => ({
178
+ increment() {
179
+ self.count++;
180
+ },
181
+ decrement() {
182
+ self.count--;
183
+ },
184
+ setCount(value: number) {
185
+ self.count = value;
186
+ },
187
+ }));
188
+
189
+ const counter = Counter.create({});
190
+ expect(counter.count).toBe(0);
191
+
192
+ counter.increment();
193
+ expect(counter.count).toBe(1);
194
+
195
+ counter.increment();
196
+ expect(counter.count).toBe(2);
197
+
198
+ counter.decrement();
199
+ expect(counter.count).toBe(1);
200
+
201
+ counter.setCount(10);
202
+ expect(counter.count).toBe(10);
203
+ });
204
+
205
+ it('should support volatile state', () => {
206
+ const Form = types
207
+ .model('Form', {
208
+ data: types.frozen<Record<string, string>>(),
209
+ })
210
+ .volatile(() => ({
211
+ isSubmitting: false,
212
+ errors: [] as string[],
213
+ }))
214
+ .actions((self) => ({
215
+ setSubmitting(value: boolean) {
216
+ self.isSubmitting = value;
217
+ },
218
+ addError(error: string) {
219
+ self.errors.push(error);
220
+ },
221
+ }));
222
+
223
+ const form = Form.create({ data: {} });
224
+ expect(form.isSubmitting).toBe(false);
225
+
226
+ form.setSubmitting(true);
227
+ expect(form.isSubmitting).toBe(true);
228
+
229
+ // Volatile state should not be in snapshot
230
+ const snapshot = getSnapshot(form);
231
+ expect('isSubmitting' in snapshot).toBe(false);
232
+ });
233
+
234
+ it('should support nested models', () => {
235
+ const Address = types.model('Address', {
236
+ street: types.string,
237
+ city: types.string,
238
+ });
239
+
240
+ const Person = types.model('Person', {
241
+ name: types.string,
242
+ address: Address,
243
+ });
244
+
245
+ const person = Person.create({
246
+ name: 'John',
247
+ address: { street: '123 Main St', city: 'Boston' },
248
+ });
249
+
250
+ expect(person.name).toBe('John');
251
+ expect(person.address.street).toBe('123 Main St');
252
+ expect(person.address.city).toBe('Boston');
253
+ });
254
+ });
255
+
256
+ describe('Array Type', () => {
257
+ it('should create array of primitives', () => {
258
+ const Model = types.model('Model', {
259
+ items: types.array(types.string),
260
+ });
261
+
262
+ const instance = Model.create({ items: ['a', 'b', 'c'] });
263
+ expect(instance.items.length).toBe(3);
264
+ expect(instance.items[0]).toBe('a');
265
+ });
266
+
267
+ it('should create array of models', () => {
268
+ const Item = types.model('Item', {
269
+ id: types.identifier,
270
+ name: types.string,
271
+ });
272
+
273
+ const Store = types.model('Store', {
274
+ items: types.array(Item),
275
+ });
276
+
277
+ const store = Store.create({
278
+ items: [
279
+ { id: '1', name: 'First' },
280
+ { id: '2', name: 'Second' },
281
+ ],
282
+ });
283
+
284
+ expect(store.items.length).toBe(2);
285
+ expect(store.items[0].name).toBe('First');
286
+ });
287
+
288
+ it('should support array mutations', () => {
289
+ const Model = types
290
+ .model('Model', {
291
+ items: types.array(types.string),
292
+ })
293
+ .actions((self) => ({
294
+ addItem(item: string) {
295
+ self.items.push(item);
296
+ },
297
+ removeItem(index: number) {
298
+ self.items.splice(index, 1);
299
+ },
300
+ }));
301
+
302
+ const instance = Model.create({ items: ['a', 'b'] });
303
+
304
+ instance.addItem('c');
305
+ expect(instance.items.length).toBe(3);
306
+
307
+ instance.removeItem(1);
308
+ expect(instance.items.length).toBe(2);
309
+ expect(instance.items[1]).toBe('c');
310
+ });
311
+ });
312
+
313
+ describe('Map Type', () => {
314
+ it('should create map of primitives', () => {
315
+ const Model = types.model('Model', {
316
+ scores: types.map(types.number),
317
+ });
318
+
319
+ const instance = Model.create({
320
+ scores: { alice: 100, bob: 90 },
321
+ });
322
+
323
+ expect(instance.scores.get('alice')).toBe(100);
324
+ expect(instance.scores.get('bob')).toBe(90);
325
+ });
326
+
327
+ it('should support map mutations', () => {
328
+ const Model = types
329
+ .model('Model', {
330
+ scores: types.map(types.number),
331
+ })
332
+ .actions((self) => ({
333
+ setScore(name: string, score: number) {
334
+ self.scores.set(name, score);
335
+ },
336
+ }));
337
+
338
+ const instance = Model.create({ scores: {} });
339
+
340
+ instance.setScore('charlie', 85);
341
+ expect(instance.scores.get('charlie')).toBe(85);
342
+ });
343
+ });
344
+
345
+ describe('Snapshots', () => {
346
+ it('should get snapshot', () => {
347
+ const Todo = types.model('Todo', {
348
+ id: types.identifier,
349
+ title: types.string,
350
+ done: types.optional(types.boolean, false),
351
+ });
352
+
353
+ const todo = Todo.create({ id: '1', title: 'Test' });
354
+ const snapshot = getSnapshot(todo);
355
+
356
+ expect(snapshot).toEqual({
357
+ id: '1',
358
+ title: 'Test',
359
+ done: false,
360
+ });
361
+ });
362
+
363
+ it('should apply snapshot', () => {
364
+ const Counter = types
365
+ .model('Counter', {
366
+ count: types.optional(types.number, 0),
367
+ })
368
+ .actions((self) => ({
369
+ setCount(value: number) {
370
+ self.count = value;
371
+ },
372
+ }));
373
+
374
+ const counter = Counter.create({ count: 5 });
375
+ expect(counter.count).toBe(5);
376
+
377
+ applySnapshot(counter, { count: 10 });
378
+ expect(counter.count).toBe(10);
379
+ });
380
+
381
+ it('should listen to snapshot changes', () => {
382
+ const Counter = types
383
+ .model('Counter', {
384
+ count: types.optional(types.number, 0),
385
+ })
386
+ .actions((self) => ({
387
+ increment() {
388
+ self.count++;
389
+ },
390
+ }));
391
+
392
+ const counter = Counter.create({});
393
+ const snapshots: unknown[] = [];
394
+
395
+ const disposer = onSnapshot(counter, (snapshot) => {
396
+ snapshots.push(snapshot);
397
+ });
398
+
399
+ counter.increment();
400
+ counter.increment();
401
+
402
+ expect(snapshots.length).toBeGreaterThan(0);
403
+
404
+ disposer();
405
+ });
406
+ });
407
+
408
+ describe('Tree Navigation', () => {
409
+ it('should get root', () => {
410
+ const Child = types.model('Child', {
411
+ name: types.string,
412
+ });
413
+
414
+ const Parent = types.model('Parent', {
415
+ child: Child,
416
+ });
417
+
418
+ const parent = Parent.create({
419
+ child: { name: 'child' },
420
+ });
421
+
422
+ const root = getRoot(parent.child);
423
+ expect(root).toBe(parent);
424
+ });
425
+
426
+ it('should get parent', () => {
427
+ const Child = types.model('Child', {
428
+ name: types.string,
429
+ });
430
+
431
+ const Parent = types.model('Parent', {
432
+ child: Child,
433
+ });
434
+
435
+ const parent = Parent.create({
436
+ child: { name: 'child' },
437
+ });
438
+
439
+ const retrievedParent = getParent(parent.child);
440
+ expect(retrievedParent).toBe(parent);
441
+ });
442
+
443
+ it('should pass environment', () => {
444
+ const Model = types
445
+ .model('Model', {
446
+ name: types.string,
447
+ })
448
+ .actions((self) => ({
449
+ getApiUrl() {
450
+ const env = getEnv<{ apiUrl: string }>(self);
451
+ return env.apiUrl;
452
+ },
453
+ }));
454
+
455
+ const instance = Model.create({ name: 'test' }, { apiUrl: 'http://localhost' });
456
+ expect(instance.getApiUrl()).toBe('http://localhost');
457
+ });
458
+ });
459
+
460
+ describe('Lifecycle', () => {
461
+ it('should track alive status', () => {
462
+ const Model = types.model('Model', {
463
+ name: types.string,
464
+ });
465
+
466
+ const instance = Model.create({ name: 'test' });
467
+ expect(isAlive(instance)).toBe(true);
468
+
469
+ destroy(instance);
470
+ expect(isAlive(instance)).toBe(false);
471
+ });
472
+
473
+ it('should clone instances', () => {
474
+ const Model = types.model('Model', {
475
+ name: types.string,
476
+ count: types.number,
477
+ });
478
+
479
+ const original = Model.create({ name: 'test', count: 42 });
480
+ const cloned = clone(original);
481
+
482
+ expect(cloned.name).toBe('test');
483
+ expect(cloned.count).toBe(42);
484
+ expect(cloned).not.toBe(original);
485
+ });
486
+ });
487
+
488
+ describe('Async Actions (flow)', () => {
489
+ it('should handle async actions', async () => {
490
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
491
+
492
+ const Model = types
493
+ .model('Model', {
494
+ data: types.maybeNull(types.string),
495
+ loading: types.optional(types.boolean, false),
496
+ })
497
+ .actions((self) => ({
498
+ setData(data: string | null) {
499
+ self.data = data;
500
+ },
501
+ setLoading(loading: boolean) {
502
+ self.loading = loading;
503
+ },
504
+ }))
505
+ .actions((self) => ({
506
+ fetchData: flow(function* () {
507
+ self.setLoading(true);
508
+ yield delay(10);
509
+ self.setData('fetched data');
510
+ self.setLoading(false);
511
+ }),
512
+ }));
513
+
514
+ const instance = Model.create({});
515
+ expect(instance.loading).toBe(false);
516
+ expect(instance.data).toBeNull();
517
+
518
+ await instance.fetchData();
519
+
520
+ expect(instance.loading).toBe(false);
521
+ expect(instance.data).toBe('fetched data');
522
+ });
523
+ });
524
+
525
+ describe('Union Types', () => {
526
+ it('should handle union of literals', () => {
527
+ const Status = types.union(
528
+ types.literal('pending'),
529
+ types.literal('active'),
530
+ types.literal('done')
531
+ );
532
+
533
+ const Task = types.model('Task', {
534
+ status: Status,
535
+ });
536
+
537
+ const task = Task.create({ status: 'pending' });
538
+ expect(task.status).toBe('pending');
539
+ });
540
+
541
+ it('should handle union of models', () => {
542
+ const Circle = types.model('Circle', {
543
+ type: types.literal('circle'),
544
+ radius: types.number,
545
+ });
546
+
547
+ const Square = types.model('Square', {
548
+ type: types.literal('square'),
549
+ side: types.number,
550
+ });
551
+
552
+ const Shape = types.union(Circle, Square);
553
+
554
+ const ShapeContainer = types.model('ShapeContainer', {
555
+ shape: Shape,
556
+ });
557
+
558
+ const circleContainer = ShapeContainer.create({
559
+ shape: { type: 'circle', radius: 10 },
560
+ });
561
+
562
+ expect(circleContainer.shape.type).toBe('circle');
563
+ expect((circleContainer.shape as any).radius).toBe(10);
564
+ });
565
+ });
566
+
567
+ describe('References', () => {
568
+ it('should resolve references', () => {
569
+ const Author = types.model('Author', {
570
+ id: types.identifier,
571
+ name: types.string,
572
+ });
573
+
574
+ const Book = types.model('Book', {
575
+ id: types.identifier,
576
+ title: types.string,
577
+ authorId: types.string, // Store as string for now
578
+ });
579
+
580
+ const Store = types
581
+ .model('Store', {
582
+ authors: types.array(Author),
583
+ books: types.array(Book),
584
+ })
585
+ .views((self) => ({
586
+ getAuthorById(id: string) {
587
+ return self.authors.find((a) => a.id === id);
588
+ },
589
+ }));
590
+
591
+ const store = Store.create({
592
+ authors: [{ id: 'a1', name: 'John Doe' }],
593
+ books: [{ id: 'b1', title: 'Great Book', authorId: 'a1' }],
594
+ });
595
+
596
+ const book = store.books[0];
597
+ const author = store.getAuthorById(book.authorId);
598
+
599
+ expect(author?.name).toBe('John Doe');
600
+ });
601
+ });
602
+
603
+ describe('Late Types (Recursive)', () => {
604
+ it('should handle recursive types', () => {
605
+ const TreeNode = types.model('TreeNode', {
606
+ id: types.identifier,
607
+ value: types.string,
608
+ children: types.optional(types.array(types.late(() => TreeNode)), []),
609
+ });
610
+
611
+ const tree = TreeNode.create({
612
+ id: '1',
613
+ value: 'root',
614
+ children: [
615
+ {
616
+ id: '2',
617
+ value: 'child1',
618
+ children: [],
619
+ },
620
+ {
621
+ id: '3',
622
+ value: 'child2',
623
+ children: [
624
+ {
625
+ id: '4',
626
+ value: 'grandchild',
627
+ children: [],
628
+ },
629
+ ],
630
+ },
631
+ ],
632
+ });
633
+
634
+ expect(tree.value).toBe('root');
635
+ expect(tree.children.length).toBe(2);
636
+ expect(tree.children[1].children[0].value).toBe('grandchild');
637
+ });
638
+ });
639
+
640
+ describe('Frozen Type', () => {
641
+ it('should handle frozen objects', () => {
642
+ const Model = types.model('Model', {
643
+ config: types.frozen<{ setting1: boolean; setting2: string }>(),
644
+ });
645
+
646
+ const instance = Model.create({
647
+ config: { setting1: true, setting2: 'value' },
648
+ });
649
+
650
+ expect(instance.config.setting1).toBe(true);
651
+ expect(instance.config.setting2).toBe('value');
652
+
653
+ // Frozen objects should be immutable
654
+ expect(() => {
655
+ (instance.config as any).setting1 = false;
656
+ }).toThrow();
657
+ });
658
+ });
659
+
660
+ describe('Patches', () => {
661
+ it('should listen to patches', () => {
662
+ const Model = types
663
+ .model('Model', {
664
+ value: types.number,
665
+ })
666
+ .actions((self) => ({
667
+ setValue(v: number) {
668
+ self.value = v;
669
+ },
670
+ }));
671
+
672
+ const instance = Model.create({ value: 0 });
673
+ const patches: unknown[] = [];
674
+
675
+ const disposer = onPatch(instance, (patch) => {
676
+ patches.push(patch);
677
+ });
678
+
679
+ instance.setValue(10);
680
+
681
+ expect(patches.length).toBeGreaterThan(0);
682
+
683
+ disposer();
684
+ });
685
+
686
+ it('should apply patches', () => {
687
+ const Model = types.model('Model', {
688
+ value: types.number,
689
+ name: types.string,
690
+ });
691
+
692
+ const instance = Model.create({ value: 0, name: 'test' });
693
+
694
+ applyPatch(instance, { op: 'replace', path: '/value', value: 42 });
695
+ expect(instance.value).toBe(42);
696
+ });
697
+ });
698
+
699
+ describe('Lifecycle Hooks', () => {
700
+ it('should call afterCreate hook', () => {
701
+ const afterCreateSpy = vi.fn();
702
+
703
+ const Model = types
704
+ .model('Model', {
705
+ name: types.string,
706
+ })
707
+ .afterCreate(afterCreateSpy);
708
+
709
+ const instance = Model.create({ name: 'test' });
710
+
711
+ expect(afterCreateSpy).toHaveBeenCalledTimes(1);
712
+ expect(afterCreateSpy).toHaveBeenCalledWith(instance);
713
+ });
714
+
715
+ it('should support chaining lifecycle hooks', () => {
716
+ const afterCreateSpy = vi.fn();
717
+
718
+ const Model = types
719
+ .model('Model', {
720
+ name: types.string,
721
+ count: types.optional(types.number, 0),
722
+ })
723
+ .views((self) => ({
724
+ get upperName() {
725
+ return self.name.toUpperCase();
726
+ },
727
+ }))
728
+ .actions((self) => ({
729
+ increment() {
730
+ self.count++;
731
+ },
732
+ }))
733
+ .afterCreate((self) => {
734
+ afterCreateSpy(self.name);
735
+ self.increment();
736
+ });
737
+
738
+ const instance = Model.create({ name: 'test' });
739
+
740
+ expect(afterCreateSpy).toHaveBeenCalledWith('test');
741
+ expect(instance.count).toBe(1);
742
+ expect(instance.upperName).toBe('TEST');
743
+ });
744
+ });
745
+
746
+ describe('Tree Utilities', () => {
747
+ it('should get path', () => {
748
+ const Child = types.model('Child', {
749
+ name: types.string,
750
+ });
751
+
752
+ const Parent = types.model('Parent', {
753
+ child: Child,
754
+ });
755
+
756
+ const parent = Parent.create({
757
+ child: { name: 'child' },
758
+ });
759
+
760
+ const path = getPath(parent.child);
761
+ expect(path).toBe('/child');
762
+ });
763
+
764
+ it('should get path parts', () => {
765
+ const GrandChild = types.model('GrandChild', {
766
+ value: types.number,
767
+ });
768
+
769
+ const Child = types.model('Child', {
770
+ grandChild: GrandChild,
771
+ });
772
+
773
+ const Parent = types.model('Parent', {
774
+ child: Child,
775
+ });
776
+
777
+ const parent = Parent.create({
778
+ child: { grandChild: { value: 42 } },
779
+ });
780
+
781
+ const parts = getPathParts(parent.child.grandChild);
782
+ expect(parts).toEqual(['child', 'grandChild']);
783
+ });
784
+
785
+ it('should check if value is state tree node', () => {
786
+ const Model = types.model('Model', {
787
+ name: types.string,
788
+ });
789
+
790
+ const instance = Model.create({ name: 'test' });
791
+
792
+ expect(isStateTreeNode(instance)).toBe(true);
793
+ expect(isStateTreeNode({ name: 'test' })).toBe(false);
794
+ expect(isStateTreeNode(null)).toBe(false);
795
+ expect(isStateTreeNode(42)).toBe(false);
796
+ });
797
+
798
+ it('should get identifier', () => {
799
+ const Model = types.model('Model', {
800
+ id: types.identifier,
801
+ name: types.string,
802
+ });
803
+
804
+ const instance = Model.create({ id: 'unique-id', name: 'test' });
805
+
806
+ expect(getIdentifier(instance)).toBe('unique-id');
807
+ });
808
+
809
+ it('should walk tree', () => {
810
+ const Item = types.model('Item', {
811
+ id: types.identifier,
812
+ name: types.string,
813
+ });
814
+
815
+ const Container = types.model('Container', {
816
+ items: types.array(Item),
817
+ });
818
+
819
+ const container = Container.create({
820
+ items: [
821
+ { id: '1', name: 'First' },
822
+ { id: '2', name: 'Second' },
823
+ ],
824
+ });
825
+
826
+ const visited: string[] = [];
827
+ walk(container, (node) => {
828
+ if (isStateTreeNode(node)) {
829
+ const snapshot = getSnapshot(node);
830
+ if (typeof snapshot === 'object' && snapshot !== null && 'name' in snapshot) {
831
+ visited.push((snapshot as { name: string }).name);
832
+ }
833
+ }
834
+ });
835
+
836
+ expect(visited).toContain('First');
837
+ expect(visited).toContain('Second');
838
+ });
839
+
840
+ it('should detach node', () => {
841
+ const Child = types.model('Child', {
842
+ name: types.string,
843
+ });
844
+
845
+ const Parent = types
846
+ .model('Parent', {
847
+ child: types.maybe(Child),
848
+ })
849
+ .actions((self) => ({
850
+ removeChild() {
851
+ if (self.child) {
852
+ detach(self.child);
853
+ }
854
+ },
855
+ }));
856
+
857
+ const parent = Parent.create({
858
+ child: { name: 'test' },
859
+ });
860
+
861
+ const child = parent.child!;
862
+ detach(child);
863
+
864
+ expect(isAlive(child)).toBe(true);
865
+ });
866
+ });
867
+
868
+ describe('Cast Utility', () => {
869
+ it('should cast values', () => {
870
+ const value: unknown = { name: 'test', count: 42 };
871
+ const typed = cast<{ name: string; count: number }>(value);
872
+
873
+ expect(typed.name).toBe('test');
874
+ expect(typed.count).toBe(42);
875
+ });
876
+ });
877
+
878
+ describe('Refinement Type', () => {
879
+ it('should validate refined types', () => {
880
+ const PositiveNumber = types.refinement(
881
+ types.number,
882
+ (value) => value > 0,
883
+ 'Value must be positive'
884
+ );
885
+
886
+ const Model = types.model('Model', {
887
+ value: PositiveNumber,
888
+ });
889
+
890
+ const instance = Model.create({ value: 10 });
891
+ expect(instance.value).toBe(10);
892
+
893
+ expect(() => Model.create({ value: -5 })).toThrow('Value must be positive');
894
+ });
895
+ });
896
+
897
+ describe('Pre/Post Process Snapshot', () => {
898
+ it('should preprocess snapshot', () => {
899
+ const Model = types
900
+ .model('Model', {
901
+ name: types.string,
902
+ createdAt: types.number,
903
+ })
904
+ .preProcessSnapshot((snapshot: { name: string }) => ({
905
+ ...snapshot,
906
+ createdAt: Date.now(),
907
+ }));
908
+
909
+ const instance = Model.create({ name: 'test' });
910
+ expect(instance.name).toBe('test');
911
+ expect(instance.createdAt).toBeGreaterThan(0);
912
+ });
913
+ });
914
+
915
+ describe('Enumeration', () => {
916
+ it('should create enumeration with name', () => {
917
+ const Status = types.enumeration('Status', ['pending', 'active', 'done']);
918
+
919
+ const Task = types.model('Task', {
920
+ status: Status,
921
+ });
922
+
923
+ const task = Task.create({ status: 'active' });
924
+ expect(task.status).toBe('active');
925
+ });
926
+
927
+ it('should create enumeration without name', () => {
928
+ const Priority = types.enumeration(['low', 'medium', 'high']);
929
+
930
+ const Task = types.model('Task', {
931
+ priority: Priority,
932
+ });
933
+
934
+ const task = Task.create({ priority: 'high' });
935
+ expect(task.priority).toBe('high');
936
+ });
937
+
938
+ it('should reject invalid enum values', () => {
939
+ const Status = types.enumeration('Status', ['pending', 'active', 'done']);
940
+
941
+ const Task = types.model('Task', {
942
+ status: Status,
943
+ });
944
+
945
+ expect(() => Task.create({ status: 'invalid' as any })).toThrow();
946
+ });
947
+ });
948
+
949
+ describe('Complex Nested Structures', () => {
950
+ it('should handle deeply nested models', () => {
951
+ const Address = types.model('Address', {
952
+ street: types.string,
953
+ city: types.string,
954
+ zip: types.string,
955
+ });
956
+
957
+ const Contact = types.model('Contact', {
958
+ email: types.string,
959
+ phone: types.optional(types.string, ''),
960
+ address: Address,
961
+ });
962
+
963
+ const User = types.model('User', {
964
+ id: types.identifier,
965
+ name: types.string,
966
+ contact: Contact,
967
+ });
968
+
969
+ const user = User.create({
970
+ id: '1',
971
+ name: 'John Doe',
972
+ contact: {
973
+ email: 'john@example.com',
974
+ address: {
975
+ street: '123 Main St',
976
+ city: 'Boston',
977
+ zip: '02101',
978
+ },
979
+ },
980
+ });
981
+
982
+ expect(user.id).toBe('1');
983
+ expect(user.name).toBe('John Doe');
984
+ expect(user.contact.email).toBe('john@example.com');
985
+ expect(user.contact.address.city).toBe('Boston');
986
+
987
+ const snapshot = getSnapshot(user);
988
+ expect(snapshot).toMatchObject({
989
+ id: '1',
990
+ name: 'John Doe',
991
+ contact: {
992
+ email: 'john@example.com',
993
+ address: {
994
+ city: 'Boston',
995
+ },
996
+ },
997
+ });
998
+ });
999
+
1000
+ it('should handle arrays of nested models', () => {
1001
+ const OrderItem = types.model('OrderItem', {
1002
+ id: types.identifier,
1003
+ productName: types.string,
1004
+ quantity: types.number,
1005
+ price: types.number,
1006
+ });
1007
+
1008
+ const Order = types
1009
+ .model('Order', {
1010
+ id: types.identifier,
1011
+ items: types.array(OrderItem),
1012
+ })
1013
+ .views((self) => ({
1014
+ get total() {
1015
+ return self.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
1016
+ },
1017
+ get itemCount() {
1018
+ return self.items.length;
1019
+ },
1020
+ }))
1021
+ .actions((self) => ({
1022
+ addItem(item: { id: string; productName: string; quantity: number; price: number }) {
1023
+ self.items.push(item);
1024
+ },
1025
+ }));
1026
+
1027
+ const order = Order.create({
1028
+ id: 'order-1',
1029
+ items: [
1030
+ { id: 'item-1', productName: 'Widget', quantity: 2, price: 10 },
1031
+ { id: 'item-2', productName: 'Gadget', quantity: 1, price: 25 },
1032
+ ],
1033
+ });
1034
+
1035
+ expect(order.total).toBe(45);
1036
+ expect(order.itemCount).toBe(2);
1037
+
1038
+ order.addItem({ id: 'item-3', productName: 'Doohickey', quantity: 3, price: 5 });
1039
+
1040
+ expect(order.total).toBe(60);
1041
+ expect(order.itemCount).toBe(3);
1042
+ });
1043
+ });
1044
+
1045
+ describe('Map with Model Values', () => {
1046
+ it('should handle map of models', () => {
1047
+ const User = types.model('User', {
1048
+ id: types.identifier,
1049
+ name: types.string,
1050
+ });
1051
+
1052
+ const UserStore = types
1053
+ .model('UserStore', {
1054
+ users: types.map(User),
1055
+ })
1056
+ .actions((self) => ({
1057
+ addUser(id: string, name: string) {
1058
+ self.users.set(id, { id, name } as any);
1059
+ },
1060
+ }));
1061
+
1062
+ const store = UserStore.create({
1063
+ users: {
1064
+ 'user-1': { id: 'user-1', name: 'Alice' },
1065
+ },
1066
+ });
1067
+
1068
+ expect(store.users.get('user-1')?.name).toBe('Alice');
1069
+
1070
+ store.addUser('user-2', 'Bob');
1071
+ expect(store.users.get('user-2')?.name).toBe('Bob');
1072
+ });
1073
+ });
1074
+
1075
+ describe('Compose Models', () => {
1076
+ it('should compose multiple models', () => {
1077
+ const Identifiable = types.model('Identifiable', {
1078
+ id: types.identifier,
1079
+ });
1080
+
1081
+ const Named = types.model('Named', {
1082
+ name: types.string,
1083
+ });
1084
+
1085
+ const Timestamped = types.model('Timestamped', {
1086
+ createdAt: types.number,
1087
+ });
1088
+
1089
+ const Entity = types.compose('Entity', Identifiable, Named);
1090
+
1091
+ const entity = Entity.create({ id: 'e-1', name: 'Test Entity' });
1092
+
1093
+ expect(entity.id).toBe('e-1');
1094
+ expect(entity.name).toBe('Test Entity');
1095
+ });
1096
+ });
1097
+
1098
+ describe('Extend Method', () => {
1099
+ it('should extend model with views, actions, and volatile state', () => {
1100
+ const Counter = types
1101
+ .model('Counter', {
1102
+ count: types.optional(types.number, 0),
1103
+ })
1104
+ .extend((self) => {
1105
+ let lastModified = Date.now();
1106
+
1107
+ return {
1108
+ views: {
1109
+ get doubled() {
1110
+ return self.count * 2;
1111
+ },
1112
+ },
1113
+ actions: {
1114
+ increment() {
1115
+ self.count++;
1116
+ lastModified = Date.now();
1117
+ },
1118
+ },
1119
+ state: {
1120
+ get lastModified() {
1121
+ return lastModified;
1122
+ },
1123
+ },
1124
+ };
1125
+ });
1126
+
1127
+ const counter = Counter.create({});
1128
+
1129
+ expect(counter.count).toBe(0);
1130
+ expect(counter.doubled).toBe(0);
1131
+
1132
+ counter.increment();
1133
+
1134
+ expect(counter.count).toBe(1);
1135
+ expect(counter.doubled).toBe(2);
1136
+ });
1137
+ });
1138
+
1139
+ describe('Advanced Tree Utilities', () => {
1140
+ it('should get relative path between nodes', () => {
1141
+ const GrandChild = types.model('GrandChild', {
1142
+ name: types.string,
1143
+ });
1144
+
1145
+ const Child = types.model('Child', {
1146
+ grandChild: GrandChild,
1147
+ });
1148
+
1149
+ const Parent = types.model('Parent', {
1150
+ childA: Child,
1151
+ childB: Child,
1152
+ });
1153
+
1154
+ const parent = Parent.create({
1155
+ childA: { grandChild: { name: 'A' } },
1156
+ childB: { grandChild: { name: 'B' } },
1157
+ });
1158
+
1159
+ const fromNode = parent.childA.grandChild;
1160
+ const toNode = parent.childB.grandChild;
1161
+
1162
+ const relativePath = getRelativePath(fromNode, toNode);
1163
+ expect(relativePath).toBe('../../childB/grandChild');
1164
+ });
1165
+
1166
+ it('should check if node is ancestor', () => {
1167
+ const Child = types.model('Child', {
1168
+ name: types.string,
1169
+ });
1170
+
1171
+ const Parent = types.model('Parent', {
1172
+ child: Child,
1173
+ });
1174
+
1175
+ const parent = Parent.create({
1176
+ child: { name: 'test' },
1177
+ });
1178
+
1179
+ expect(isAncestor(parent, parent.child)).toBe(true);
1180
+ expect(isAncestor(parent.child, parent)).toBe(false);
1181
+ });
1182
+
1183
+ it('should find all nodes matching predicate', () => {
1184
+ const Item = types.model('Item', {
1185
+ value: types.number,
1186
+ });
1187
+
1188
+ const Container = types.model('Container', {
1189
+ items: types.array(Item),
1190
+ });
1191
+
1192
+ const container = Container.create({
1193
+ items: [
1194
+ { value: 1 },
1195
+ { value: 2 },
1196
+ { value: 3 },
1197
+ ],
1198
+ });
1199
+
1200
+ const allNodes = findAll(container, (node: unknown): node is unknown => {
1201
+ if (!isStateTreeNode(node)) return false;
1202
+ const snapshot = getSnapshot(node);
1203
+ return typeof snapshot === 'object' && snapshot !== null && 'value' in snapshot;
1204
+ });
1205
+
1206
+ expect(allNodes.length).toBe(3);
1207
+ });
1208
+
1209
+ it('should get tree stats', () => {
1210
+ const Item = types.model('Item', {
1211
+ name: types.string,
1212
+ });
1213
+
1214
+ const Container = types.model('Container', {
1215
+ items: types.array(Item),
1216
+ });
1217
+
1218
+ const container = Container.create({
1219
+ items: [
1220
+ { name: 'a' },
1221
+ { name: 'b' },
1222
+ ],
1223
+ });
1224
+
1225
+ const stats = getTreeStats(container);
1226
+
1227
+ expect(stats.nodeCount).toBeGreaterThan(0);
1228
+ expect(stats.depth).toBeGreaterThan(0);
1229
+ expect(stats.types).toHaveProperty('Container');
1230
+ });
1231
+
1232
+ it('should clone deep', () => {
1233
+ const Model = types.model('Model', {
1234
+ name: types.string,
1235
+ count: types.number,
1236
+ });
1237
+
1238
+ const original = Model.create({ name: 'test', count: 5 });
1239
+ const cloned = cloneDeep(original);
1240
+
1241
+ expect(cloned.name).toBe('test');
1242
+ expect(cloned.count).toBe(5);
1243
+ expect(cloned).not.toBe(original);
1244
+ });
1245
+ });
1246
+
1247
+ describe('Undo Manager', () => {
1248
+ it('should track history entries', () => {
1249
+ const Counter = types
1250
+ .model('Counter', {
1251
+ count: types.optional(types.number, 0),
1252
+ })
1253
+ .actions((self) => ({
1254
+ increment() {
1255
+ self.count++;
1256
+ },
1257
+ }));
1258
+
1259
+ const counter = Counter.create({});
1260
+ const undoManager = createUndoManager(counter);
1261
+
1262
+ expect(counter.count).toBe(0);
1263
+ expect(undoManager.canUndo).toBe(false);
1264
+ expect(undoManager.undoLevels).toBe(0);
1265
+
1266
+ counter.increment();
1267
+ expect(counter.count).toBe(1);
1268
+ expect(undoManager.canUndo).toBe(true);
1269
+ expect(undoManager.undoLevels).toBe(1);
1270
+
1271
+ counter.increment();
1272
+ expect(counter.count).toBe(2);
1273
+ expect(undoManager.undoLevels).toBe(2);
1274
+
1275
+ undoManager.dispose();
1276
+ });
1277
+
1278
+ it('should group changes', () => {
1279
+ const Counter = types
1280
+ .model('Counter', {
1281
+ count: types.optional(types.number, 0),
1282
+ })
1283
+ .actions((self) => ({
1284
+ increment() {
1285
+ self.count++;
1286
+ },
1287
+ }));
1288
+
1289
+ const counter = Counter.create({});
1290
+ const undoManager = createUndoManager(counter);
1291
+
1292
+ undoManager.startGroup();
1293
+ counter.increment();
1294
+ counter.increment();
1295
+ counter.increment();
1296
+ undoManager.endGroup();
1297
+
1298
+ expect(counter.count).toBe(3);
1299
+ expect(undoManager.undoLevels).toBe(1);
1300
+
1301
+ undoManager.dispose();
1302
+ });
1303
+
1304
+ it('should clear history', () => {
1305
+ const Counter = types
1306
+ .model('Counter', {
1307
+ count: types.optional(types.number, 0),
1308
+ })
1309
+ .actions((self) => ({
1310
+ increment() {
1311
+ self.count++;
1312
+ },
1313
+ }));
1314
+
1315
+ const counter = Counter.create({});
1316
+ const undoManager = createUndoManager(counter);
1317
+
1318
+ counter.increment();
1319
+ counter.increment();
1320
+ expect(undoManager.undoLevels).toBe(2);
1321
+
1322
+ undoManager.clear();
1323
+ expect(undoManager.undoLevels).toBe(0);
1324
+ expect(undoManager.canUndo).toBe(false);
1325
+
1326
+ undoManager.dispose();
1327
+ });
1328
+ });
1329
+
1330
+ describe('Time Travel Manager', () => {
1331
+ it('should record and navigate snapshots', () => {
1332
+ const Counter = types
1333
+ .model('Counter', {
1334
+ count: types.optional(types.number, 0),
1335
+ })
1336
+ .actions((self) => ({
1337
+ setCount(n: number) {
1338
+ self.count = n;
1339
+ },
1340
+ }));
1341
+
1342
+ const counter = Counter.create({});
1343
+ const timeTravel = createTimeTravelManager(counter);
1344
+
1345
+ counter.setCount(1);
1346
+ timeTravel.record();
1347
+
1348
+ counter.setCount(2);
1349
+ timeTravel.record();
1350
+
1351
+ counter.setCount(3);
1352
+ timeTravel.record();
1353
+
1354
+ expect(counter.count).toBe(3);
1355
+ expect(timeTravel.snapshotCount).toBe(4); // Initial + 3 records
1356
+
1357
+ timeTravel.goBack();
1358
+ expect(counter.count).toBe(2);
1359
+
1360
+ timeTravel.goBack();
1361
+ expect(counter.count).toBe(1);
1362
+
1363
+ timeTravel.goForward();
1364
+ expect(counter.count).toBe(2);
1365
+
1366
+ timeTravel.goTo(0);
1367
+ expect(counter.count).toBe(0);
1368
+
1369
+ timeTravel.dispose();
1370
+ });
1371
+ });