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.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
|
@@ -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
|
+
});
|