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,589 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import {
3
+ types,
4
+ registerModel,
5
+ unregisterModel,
6
+ isModelRegistered,
7
+ resolveModel,
8
+ tryResolveModel,
9
+ resolveModelAsync,
10
+ getModelMetadata,
11
+ getRegisteredModelNames,
12
+ onModelRegistered,
13
+ clearModelRegistry,
14
+ lateModel,
15
+ dynamicReference,
16
+ safeDynamicReference,
17
+ getSnapshot,
18
+ resolveIdentifier,
19
+ } from "../index";
20
+
21
+ describe("Model Registry", () => {
22
+ beforeEach(() => {
23
+ // Clear registry before each test
24
+ clearModelRegistry();
25
+ });
26
+
27
+ describe("registerModel / unregisterModel", () => {
28
+ it("should register a model", () => {
29
+ const User = types.model("User", {
30
+ id: types.identifier,
31
+ name: types.string,
32
+ });
33
+
34
+ registerModel("User", User);
35
+ expect(isModelRegistered("User")).toBe(true);
36
+ });
37
+
38
+ it("should register a model with metadata", () => {
39
+ const User = types.model("User", {
40
+ id: types.identifier,
41
+ name: types.string,
42
+ });
43
+
44
+ registerModel("User", User, { version: "1.0.0", author: "test" });
45
+ expect(isModelRegistered("User")).toBe(true);
46
+ expect(getModelMetadata("User")).toEqual({
47
+ version: "1.0.0",
48
+ author: "test",
49
+ });
50
+ });
51
+
52
+ it("should throw when registering duplicate model name", () => {
53
+ const User = types.model("User", {
54
+ id: types.identifier,
55
+ name: types.string,
56
+ });
57
+
58
+ registerModel("User", User);
59
+ expect(() => registerModel("User", User)).toThrow(
60
+ 'Model "User" is already registered',
61
+ );
62
+ });
63
+
64
+ it("should unregister a model", () => {
65
+ const User = types.model("User", {
66
+ id: types.identifier,
67
+ name: types.string,
68
+ });
69
+
70
+ registerModel("User", User);
71
+ expect(isModelRegistered("User")).toBe(true);
72
+
73
+ const result = unregisterModel("User");
74
+ expect(result).toBe(true);
75
+ expect(isModelRegistered("User")).toBe(false);
76
+ });
77
+
78
+ it("should return false when unregistering non-existent model", () => {
79
+ const result = unregisterModel("NonExistent");
80
+ expect(result).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("resolveModel / tryResolveModel", () => {
85
+ it("should resolve a registered model", () => {
86
+ const User = types.model("User", {
87
+ id: types.identifier,
88
+ name: types.string,
89
+ });
90
+
91
+ registerModel("User", User);
92
+ const resolved = resolveModel("User");
93
+ expect(resolved).toBe(User);
94
+ });
95
+
96
+ it("should throw when resolving non-existent model", () => {
97
+ expect(() => resolveModel("NonExistent")).toThrow(
98
+ 'Model "NonExistent" is not registered',
99
+ );
100
+ });
101
+
102
+ it("should return undefined with tryResolveModel for non-existent model", () => {
103
+ const result = tryResolveModel("NonExistent");
104
+ expect(result).toBeUndefined();
105
+ });
106
+
107
+ it("should return model with tryResolveModel for existing model", () => {
108
+ const User = types.model("User", {
109
+ id: types.identifier,
110
+ name: types.string,
111
+ });
112
+
113
+ registerModel("User", User);
114
+ const result = tryResolveModel("User");
115
+ expect(result).toBe(User);
116
+ });
117
+ });
118
+
119
+ describe("resolveModelAsync", () => {
120
+ it("should resolve immediately if model is registered", async () => {
121
+ const User = types.model("User", {
122
+ id: types.identifier,
123
+ name: types.string,
124
+ });
125
+
126
+ registerModel("User", User);
127
+ const resolved = await resolveModelAsync("User");
128
+ expect(resolved).toBe(User);
129
+ });
130
+
131
+ it("should wait for model to be registered", async () => {
132
+ const User = types.model("User", {
133
+ id: types.identifier,
134
+ name: types.string,
135
+ });
136
+
137
+ // Start waiting, then register after a delay
138
+ const promise = resolveModelAsync("User", 1000);
139
+
140
+ setTimeout(() => {
141
+ registerModel("User", User);
142
+ }, 50);
143
+
144
+ const resolved = await promise;
145
+ expect(resolved).toBe(User);
146
+ });
147
+
148
+ it("should timeout if model is not registered in time", async () => {
149
+ await expect(resolveModelAsync("NonExistent", 100)).rejects.toThrow(
150
+ 'Timeout waiting for model "NonExistent" to be registered',
151
+ );
152
+ });
153
+ });
154
+
155
+ describe("getRegisteredModelNames", () => {
156
+ it("should return empty array when no models registered", () => {
157
+ expect(getRegisteredModelNames()).toEqual([]);
158
+ });
159
+
160
+ it("should return all registered model names", () => {
161
+ const User = types.model("User", { id: types.identifier });
162
+ const Post = types.model("Post", { id: types.identifier });
163
+ const Comment = types.model("Comment", { id: types.identifier });
164
+
165
+ registerModel("User", User);
166
+ registerModel("Post", Post);
167
+ registerModel("Comment", Comment);
168
+
169
+ const names = getRegisteredModelNames();
170
+ expect(names).toHaveLength(3);
171
+ expect(names).toContain("User");
172
+ expect(names).toContain("Post");
173
+ expect(names).toContain("Comment");
174
+ });
175
+ });
176
+
177
+ describe("onModelRegistered", () => {
178
+ it("should call listener when model is registered", () => {
179
+ const listener = vi.fn();
180
+ const unsubscribe = onModelRegistered(listener);
181
+
182
+ const User = types.model("User", {
183
+ id: types.identifier,
184
+ name: types.string,
185
+ });
186
+
187
+ registerModel("User", User);
188
+
189
+ expect(listener).toHaveBeenCalledTimes(1);
190
+ expect(listener).toHaveBeenCalledWith("User", User);
191
+
192
+ unsubscribe();
193
+ });
194
+
195
+ it("should not call listener after unsubscribe", () => {
196
+ const listener = vi.fn();
197
+ const unsubscribe = onModelRegistered(listener);
198
+
199
+ unsubscribe();
200
+
201
+ const User = types.model("User", {
202
+ id: types.identifier,
203
+ name: types.string,
204
+ });
205
+
206
+ registerModel("User", User);
207
+
208
+ expect(listener).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it("should support multiple listeners", () => {
212
+ const listener1 = vi.fn();
213
+ const listener2 = vi.fn();
214
+
215
+ onModelRegistered(listener1);
216
+ onModelRegistered(listener2);
217
+
218
+ const User = types.model("User", { id: types.identifier });
219
+ registerModel("User", User);
220
+
221
+ expect(listener1).toHaveBeenCalledTimes(1);
222
+ expect(listener2).toHaveBeenCalledTimes(1);
223
+ });
224
+ });
225
+
226
+ describe("clearModelRegistry", () => {
227
+ it("should clear all registered models", () => {
228
+ const User = types.model("User", { id: types.identifier });
229
+ const Post = types.model("Post", { id: types.identifier });
230
+
231
+ registerModel("User", User);
232
+ registerModel("Post", Post);
233
+
234
+ expect(getRegisteredModelNames()).toHaveLength(2);
235
+
236
+ clearModelRegistry();
237
+
238
+ expect(getRegisteredModelNames()).toHaveLength(0);
239
+ expect(isModelRegistered("User")).toBe(false);
240
+ expect(isModelRegistered("Post")).toBe(false);
241
+ });
242
+ });
243
+
244
+ describe("types.lateModel", () => {
245
+ it("should resolve registered model lazily", () => {
246
+ const User = types.model("User", {
247
+ id: types.identifier,
248
+ name: types.string,
249
+ });
250
+
251
+ // Create a model that uses lateModel before User is registered
252
+ const Post = types.model("Post", {
253
+ id: types.identifier,
254
+ title: types.string,
255
+ author: lateModel("User"),
256
+ });
257
+
258
+ // Register User after Post is defined
259
+ registerModel("User", User);
260
+
261
+ // Create instances
262
+ const user = User.create({ id: "user-1", name: "Alice" });
263
+ const post = Post.create({
264
+ id: "post-1",
265
+ title: "Hello",
266
+ author: { id: "user-2", name: "Bob" },
267
+ });
268
+
269
+ expect(post.author.name).toBe("Bob");
270
+ });
271
+
272
+ it("should throw when model is not registered at creation time", () => {
273
+ const Post = types.model("Post", {
274
+ id: types.identifier,
275
+ author: lateModel("NonExistent"),
276
+ });
277
+
278
+ expect(() =>
279
+ Post.create({
280
+ id: "post-1",
281
+ author: { id: "user-1" },
282
+ }),
283
+ ).toThrow('Model "NonExistent" is not registered');
284
+ });
285
+ });
286
+
287
+ describe("types.dynamicReference", () => {
288
+ it("should create dynamic reference type with custom resolver", () => {
289
+ const User = types.model("User", {
290
+ id: types.identifier,
291
+ name: types.string,
292
+ });
293
+
294
+ registerModel("User", User);
295
+
296
+ // Create a simple lookup map
297
+ const usersById = new Map<string, unknown>();
298
+ const user1 = User.create({ id: "user-1", name: "Alice" });
299
+ const user2 = User.create({ id: "user-2", name: "Bob" });
300
+ usersById.set("user-1", user1);
301
+ usersById.set("user-2", user2);
302
+
303
+ // Test that dynamic reference type is valid
304
+ const refType = dynamicReference("User", {
305
+ get: (identifier) => usersById.get(String(identifier)),
306
+ });
307
+
308
+ expect(refType).toBeDefined();
309
+ expect(refType.name).toBe('dynamicReference("User")');
310
+ });
311
+
312
+ it("should support custom get resolver", () => {
313
+ const User = types.model("User", {
314
+ id: types.identifier,
315
+ name: types.string,
316
+ });
317
+
318
+ registerModel("User", User);
319
+
320
+ const usersById = new Map<string, unknown>();
321
+
322
+ const Post = types.model("Post", {
323
+ id: types.identifier,
324
+ authorId: dynamicReference("User", {
325
+ get: (identifier) => usersById.get(String(identifier)),
326
+ }),
327
+ });
328
+
329
+ const user = User.create({ id: "user-1", name: "Alice" });
330
+ usersById.set("user-1", user);
331
+
332
+ const post = Post.create({
333
+ id: "post-1",
334
+ authorId: "user-1",
335
+ });
336
+
337
+ // Access through the dynamic reference proxy
338
+ expect(post.authorId).toBeDefined();
339
+ });
340
+
341
+ it("should call onInvalidated when reference cannot be resolved", () => {
342
+ const User = types.model("User", {
343
+ id: types.identifier,
344
+ name: types.string,
345
+ });
346
+
347
+ registerModel("User", User);
348
+
349
+ const onInvalidated = vi
350
+ .fn()
351
+ .mockReturnValue({ id: "fallback", name: "Fallback User" });
352
+
353
+ const Post = types.model("Post", {
354
+ id: types.identifier,
355
+ authorId: dynamicReference("User", {
356
+ get: () => undefined, // Always return undefined
357
+ onInvalidated,
358
+ }),
359
+ });
360
+
361
+ const post = Post.create({
362
+ id: "post-1",
363
+ authorId: "non-existent",
364
+ });
365
+
366
+ // Access the reference - should trigger onInvalidated
367
+ const name = post.authorId.name;
368
+ expect(onInvalidated).toHaveBeenCalled();
369
+ expect(name).toBe("Fallback User");
370
+ });
371
+ });
372
+
373
+ describe("types.safeDynamicReference", () => {
374
+ it("should return undefined for unresolved reference", () => {
375
+ const User = types.model("User", {
376
+ id: types.identifier,
377
+ name: types.string,
378
+ });
379
+
380
+ registerModel("User", User);
381
+
382
+ const Post = types.model("Post", {
383
+ id: types.identifier,
384
+ authorId: safeDynamicReference("User", {
385
+ get: () => undefined, // Always return undefined
386
+ }),
387
+ });
388
+
389
+ const post = Post.create({
390
+ id: "post-1",
391
+ authorId: "non-existent",
392
+ });
393
+
394
+ expect(post.authorId).toBeUndefined();
395
+ });
396
+
397
+ it("should resolve when reference exists", () => {
398
+ const User = types.model("User", {
399
+ id: types.identifier,
400
+ name: types.string,
401
+ });
402
+
403
+ registerModel("User", User);
404
+
405
+ const usersById = new Map<string, unknown>();
406
+
407
+ const Post = types.model("Post", {
408
+ id: types.identifier,
409
+ authorId: safeDynamicReference("User", {
410
+ get: (identifier) => usersById.get(String(identifier)),
411
+ }),
412
+ });
413
+
414
+ const user = User.create({ id: "user-1", name: "Alice" });
415
+ usersById.set("user-1", user);
416
+
417
+ const post = Post.create({
418
+ id: "post-1",
419
+ authorId: "user-1",
420
+ });
421
+
422
+ expect(post.authorId).toBeDefined();
423
+ });
424
+
425
+ it("should allow undefined in create", () => {
426
+ const User = types.model("User", {
427
+ id: types.identifier,
428
+ name: types.string,
429
+ });
430
+
431
+ registerModel("User", User);
432
+
433
+ const Post = types.model("Post", {
434
+ id: types.identifier,
435
+ authorId: safeDynamicReference("User"),
436
+ });
437
+
438
+ const post = Post.create({
439
+ id: "post-1",
440
+ authorId: undefined,
441
+ });
442
+
443
+ expect(post.authorId).toBeUndefined();
444
+ });
445
+ });
446
+
447
+ describe("Integration: Plugin Architecture", () => {
448
+ it("should support lazy-loading plugin models", async () => {
449
+ // Core model defined upfront
450
+ const CoreStore = types.model("CoreStore", {
451
+ name: types.string,
452
+ plugins: types.array(types.string),
453
+ });
454
+
455
+ // Create store before plugins are loaded
456
+ const store = CoreStore.create({
457
+ name: "MyApp",
458
+ plugins: [],
459
+ });
460
+
461
+ expect(store.name).toBe("MyApp");
462
+
463
+ // Simulate plugin loading
464
+ const loadPlugin = async (pluginName: string) => {
465
+ // Simulate async module loading
466
+ await new Promise((resolve) => setTimeout(resolve, 10));
467
+
468
+ if (pluginName === "UserPlugin") {
469
+ const UserPlugin = types.model("UserPlugin", {
470
+ id: types.identifier,
471
+ users: types.array(
472
+ types.model({
473
+ id: types.identifier,
474
+ name: types.string,
475
+ }),
476
+ ),
477
+ });
478
+ registerModel("UserPlugin", UserPlugin);
479
+ return UserPlugin;
480
+ }
481
+
482
+ throw new Error(`Unknown plugin: ${pluginName}`);
483
+ };
484
+
485
+ // Load plugin dynamically
486
+ await loadPlugin("UserPlugin");
487
+
488
+ // Verify plugin is now available
489
+ expect(isModelRegistered("UserPlugin")).toBe(true);
490
+ const UserPlugin = resolveModel("UserPlugin");
491
+ expect(UserPlugin).toBeDefined();
492
+
493
+ // Create plugin instance
494
+ const pluginInstance = UserPlugin.create({
495
+ id: "user-plugin-1",
496
+ users: [{ id: "user-1", name: "Alice" }],
497
+ });
498
+
499
+ expect(getSnapshot(pluginInstance)).toMatchObject({
500
+ id: "user-plugin-1",
501
+ users: [{ id: "user-1", name: "Alice" }],
502
+ });
503
+ });
504
+
505
+ it("should handle multiple dependent plugins", async () => {
506
+ // Register base model
507
+ const BaseEntity = types.model("BaseEntity", {
508
+ id: types.identifier,
509
+ createdAt: types.optional(types.string, () => new Date().toISOString()),
510
+ });
511
+ registerModel("BaseEntity", BaseEntity);
512
+
513
+ // Plugin A depends on BaseEntity
514
+ const PluginA = types.compose(
515
+ "PluginA",
516
+ resolveModel("BaseEntity"),
517
+ types.model({
518
+ pluginAData: types.string,
519
+ }),
520
+ );
521
+ registerModel("PluginA", PluginA);
522
+
523
+ // Plugin B also depends on BaseEntity
524
+ const PluginB = types.compose(
525
+ "PluginB",
526
+ resolveModel("BaseEntity"),
527
+ types.model({
528
+ pluginBData: types.number,
529
+ }),
530
+ );
531
+ registerModel("PluginB", PluginB);
532
+
533
+ // Create instances
534
+ const instanceA = resolveModel("PluginA").create({
535
+ id: "a-1",
536
+ pluginAData: "hello",
537
+ });
538
+
539
+ const instanceB = resolveModel("PluginB").create({
540
+ id: "b-1",
541
+ pluginBData: 42,
542
+ });
543
+
544
+ expect(instanceA.pluginAData).toBe("hello");
545
+ expect(instanceB.pluginBData).toBe(42);
546
+ expect(instanceA.id).toBe("a-1");
547
+ expect(instanceB.id).toBe("b-1");
548
+ });
549
+
550
+ it("should support code splitting with resolveModelAsync", async () => {
551
+ // Start waiting for a model that will be "loaded" later
552
+ const modelPromise = resolveModelAsync("LazyLoadedModel", 5000);
553
+
554
+ // Simulate code splitting / dynamic import
555
+ setTimeout(() => {
556
+ const LazyModel = types.model("LazyLoadedModel", {
557
+ id: types.identifier,
558
+ data: types.frozen(),
559
+ });
560
+ registerModel("LazyLoadedModel", LazyModel);
561
+ }, 50);
562
+
563
+ // Wait for the model to be available
564
+ const LazyModel = await modelPromise;
565
+
566
+ expect(LazyModel).toBeDefined();
567
+ const instance = LazyModel.create({
568
+ id: "lazy-1",
569
+ data: { foo: "bar" },
570
+ });
571
+
572
+ expect(instance.data).toEqual({ foo: "bar" });
573
+ });
574
+ });
575
+
576
+ describe("Registry with types namespace", () => {
577
+ it("should have lateModel available on types namespace", () => {
578
+ expect(types.lateModel).toBe(lateModel);
579
+ });
580
+
581
+ it("should have dynamicReference available on types namespace", () => {
582
+ expect(types.dynamicReference).toBe(dynamicReference);
583
+ });
584
+
585
+ it("should have safeDynamicReference available on types namespace", () => {
586
+ expect(types.safeDynamicReference).toBe(safeDynamicReference);
587
+ });
588
+ });
589
+ });