mvc-kit 2.12.0 → 2.12.1

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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +10 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,503 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { ViewModel } from './ViewModel';
3
+ import { Resource } from './Resource';
4
+ import { Collection } from './Collection';
5
+ import { singleton, teardownAll } from './singleton';
6
+
7
+ type Item = { id: number; name: string; status: 'active' | 'inactive' };
8
+
9
+ const alice: Item = { id: 1, name: 'Alice', status: 'active' };
10
+ const bob: Item = { id: 2, name: 'Bob', status: 'inactive' };
11
+ const charlie: Item = { id: 3, name: 'Charlie', status: 'active' };
12
+
13
+ beforeEach(() => {
14
+ teardownAll();
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────────
19
+
20
+ class TestResource extends Resource<Item> {
21
+ async loadAll() {
22
+ const data = [alice, bob, charlie];
23
+ this.reset(data);
24
+ }
25
+
26
+ async loadFiltered(status: Item['status']) {
27
+ const data = [alice, bob, charlie].filter(i => i.status === status);
28
+ this.reset(data);
29
+ }
30
+
31
+ async failingLoad() {
32
+ throw new Error('Network error');
33
+ }
34
+ }
35
+
36
+ interface TestState {
37
+ search: string;
38
+ statusFilter: 'all' | Item['status'];
39
+ }
40
+
41
+ class TestViewModel extends ViewModel<TestState> {
42
+ resource = singleton(TestResource);
43
+
44
+ // Prescribed pattern: each getter accesses the subscribable directly
45
+ get items(): Item[] {
46
+ return this.resource.items as Item[];
47
+ }
48
+
49
+ get filtered(): Item[] {
50
+ const { search, statusFilter } = this.state;
51
+ let result = this.resource.items as Item[];
52
+
53
+ if (search) {
54
+ const q = search.toLowerCase();
55
+ result = result.filter(i => i.name.toLowerCase().includes(q));
56
+ }
57
+ if (statusFilter !== 'all') {
58
+ result = result.filter(i => i.status === statusFilter);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ get total(): number {
64
+ return this.resource.length;
65
+ }
66
+
67
+ get activeCount(): number {
68
+ return (this.resource.items as Item[]).filter(i => i.status === 'active').length;
69
+ }
70
+
71
+ protected onInit() {
72
+ if (this.resource.length === 0) this.resource.loadAll();
73
+ }
74
+
75
+ setSearch(search: string) { this.set({ search }); }
76
+ setStatusFilter(statusFilter: TestState['statusFilter']) { this.set({ statusFilter }); }
77
+ }
78
+
79
+ // ── Tests ────────────────────────────────────────────────────────
80
+
81
+ describe('Resource + ViewModel — getter auto-tracking', () => {
82
+ it('ViewModel getter reads Resource items and recomputes on mutation', () => {
83
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
84
+ vm.init();
85
+
86
+ expect(vm.items).toEqual([alice, bob, charlie]);
87
+ expect(vm.total).toBe(3);
88
+
89
+ vm.resource.add({ id: 4, name: 'Dave', status: 'active' });
90
+ expect(vm.total).toBe(4);
91
+ expect(vm.items).toHaveLength(4);
92
+
93
+ vm.dispose();
94
+ });
95
+
96
+ it('ViewModel getter invalidates when Resource.reset() is called', () => {
97
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
98
+ vm.init();
99
+
100
+ expect(vm.total).toBe(3);
101
+
102
+ vm.resource.reset([alice]);
103
+ expect(vm.total).toBe(1);
104
+ expect(vm.items).toEqual([alice]);
105
+
106
+ vm.dispose();
107
+ });
108
+
109
+ it('ViewModel getter invalidates on Resource.update()', () => {
110
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
111
+ vm.init();
112
+
113
+ expect(vm.activeCount).toBe(2);
114
+
115
+ vm.resource.update(2, { status: 'active' });
116
+ expect(vm.activeCount).toBe(3);
117
+
118
+ vm.dispose();
119
+ });
120
+
121
+ it('ViewModel getter invalidates on Resource.remove()', () => {
122
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
123
+ vm.init();
124
+
125
+ expect(vm.total).toBe(3);
126
+
127
+ vm.resource.remove(1);
128
+ expect(vm.total).toBe(2);
129
+
130
+ vm.dispose();
131
+ });
132
+
133
+ it('ViewModel getter combines state deps + Resource deps correctly', () => {
134
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
135
+ vm.init();
136
+
137
+ expect(vm.filtered).toEqual([alice, bob, charlie]);
138
+
139
+ // Change state dep (search)
140
+ vm.setSearch('ali');
141
+ expect(vm.filtered).toEqual([alice]);
142
+
143
+ // Change Resource dep (add item matching search)
144
+ vm.resource.add({ id: 4, name: 'Alicia', status: 'active' });
145
+ expect(vm.filtered).toHaveLength(2);
146
+
147
+ // Change status filter
148
+ vm.setStatusFilter('inactive');
149
+ expect(vm.filtered).toEqual([]);
150
+
151
+ vm.dispose();
152
+ });
153
+ });
154
+
155
+ describe('Resource + ViewModel — getter memoization', () => {
156
+ it('getter is memoized when neither state nor Resource changes', () => {
157
+ let callCount = 0;
158
+
159
+ class CountingVM extends ViewModel<TestState> {
160
+ resource = singleton(TestResource);
161
+
162
+ get items(): Item[] {
163
+ callCount++;
164
+ return this.resource.items as Item[];
165
+ }
166
+
167
+ setSearch(search: string) { this.set({ search }); }
168
+ }
169
+
170
+ const vm = new CountingVM({ search: '', statusFilter: 'all' });
171
+ vm.init();
172
+
173
+ callCount = 0;
174
+ const first = vm.items;
175
+ const second = vm.items;
176
+ expect(first).toBe(second);
177
+ expect(callCount).toBe(1); // computed once, then cached
178
+
179
+ vm.dispose();
180
+ });
181
+
182
+ it('getter recomputes only when Resource changes, not on unrelated state changes', () => {
183
+ let callCount = 0;
184
+
185
+ class CountingVM extends ViewModel<{ search: string; unrelated: number }> {
186
+ resource = singleton(TestResource);
187
+
188
+ get items(): Item[] {
189
+ callCount++;
190
+ return this.resource.items as Item[];
191
+ }
192
+
193
+ setUnrelated(n: number) { this.set({ unrelated: n }); }
194
+ }
195
+
196
+ const vm = new CountingVM({ search: '', unrelated: 0 });
197
+ vm.init();
198
+
199
+ callCount = 0;
200
+ vm.items; // compute + cache
201
+ expect(callCount).toBe(1);
202
+
203
+ // Unrelated state change — items getter only depends on resource, not 'unrelated'
204
+ vm.setUnrelated(42);
205
+ vm.items;
206
+ expect(callCount).toBe(1); // tier 2 cache hit — deps didn't change
207
+
208
+ // Resource mutation — should recompute
209
+ vm.resource.add({ id: 4, name: 'Dave', status: 'active' });
210
+ vm.items;
211
+ expect(callCount).toBe(2);
212
+
213
+ vm.dispose();
214
+ });
215
+ });
216
+
217
+ describe('Resource + ViewModel — lifecycle integration', () => {
218
+ it('onInit smart-load pattern: loads Resource data on first init', async () => {
219
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
220
+ await vm.init();
221
+
222
+ expect(vm.total).toBe(3);
223
+ expect(vm.items).toEqual([alice, bob, charlie]);
224
+
225
+ vm.dispose();
226
+ });
227
+
228
+ it('onInit smart-load pattern: skips load when Resource already has data', async () => {
229
+ // Pre-populate the resource
230
+ const resource = singleton(TestResource);
231
+ resource.reset([alice]);
232
+
233
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
234
+
235
+ const loadSpy = vi.spyOn(resource, 'loadAll');
236
+ await vm.init();
237
+
238
+ expect(loadSpy).not.toHaveBeenCalled();
239
+ expect(vm.total).toBe(1);
240
+
241
+ vm.dispose();
242
+ });
243
+
244
+ it('ViewModel dispose does not dispose singleton Resource', async () => {
245
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
246
+ await vm.init();
247
+
248
+ const resource = singleton(TestResource);
249
+ expect(resource.disposed).toBe(false);
250
+
251
+ vm.dispose();
252
+ expect(resource.disposed).toBe(false);
253
+ expect(resource.items).toEqual([alice, bob, charlie]);
254
+ });
255
+ });
256
+
257
+ describe('Resource + ViewModel — shared singleton', () => {
258
+ it('two ViewModels share the same Resource data', async () => {
259
+ class OtherVM extends ViewModel {
260
+ resource = singleton(TestResource);
261
+
262
+ get total(): number {
263
+ return this.resource.length;
264
+ }
265
+ }
266
+
267
+ const vm1 = new TestViewModel({ search: '', statusFilter: 'all' });
268
+ await vm1.init();
269
+
270
+ const vm2 = new OtherVM();
271
+ vm2.init();
272
+
273
+ // Both see the same data loaded by vm1's onInit
274
+ expect(vm1.total).toBe(3);
275
+ expect(vm2.total).toBe(3);
276
+
277
+ // Mutation through vm1's resource is visible to vm2
278
+ vm1.resource.add({ id: 4, name: 'Dave', status: 'active' });
279
+ expect(vm2.total).toBe(4);
280
+
281
+ vm1.dispose();
282
+ vm2.dispose();
283
+ });
284
+
285
+ it('Resource data persists across ViewModel lifecycle', async () => {
286
+ const vm1 = new TestViewModel({ search: '', statusFilter: 'all' });
287
+ await vm1.init();
288
+ expect(vm1.total).toBe(3);
289
+ vm1.dispose();
290
+
291
+ // New ViewModel sees existing Resource data
292
+ const vm2 = new TestViewModel({ search: '', statusFilter: 'all' });
293
+ await vm2.init();
294
+ expect(vm2.total).toBe(3); // smart-init skips load since data exists
295
+
296
+ vm2.dispose();
297
+ });
298
+ });
299
+
300
+ describe('Resource + ViewModel — subscribeTo for imperative side effects', () => {
301
+ it('subscribeTo fires when Resource mutates', async () => {
302
+ const sideEffects: string[] = [];
303
+
304
+ class ImperativeVM extends ViewModel {
305
+ resource = singleton(TestResource);
306
+
307
+ protected onInit() {
308
+ this.subscribeTo(this.resource, () => {
309
+ sideEffects.push('resource-changed');
310
+ });
311
+ }
312
+ }
313
+
314
+ const vm = new ImperativeVM();
315
+ vm.init();
316
+
317
+ sideEffects.length = 0;
318
+
319
+ const resource = singleton(TestResource);
320
+ resource.reset([alice, bob]);
321
+ expect(sideEffects).toEqual(['resource-changed']);
322
+
323
+ resource.add(charlie);
324
+ expect(sideEffects).toEqual(['resource-changed', 'resource-changed']);
325
+
326
+ vm.dispose();
327
+ });
328
+
329
+ it('subscribeTo cleanup runs on ViewModel dispose', () => {
330
+ const sideEffects: string[] = [];
331
+
332
+ class ImperativeVM extends ViewModel {
333
+ resource = singleton(TestResource);
334
+
335
+ protected onInit() {
336
+ this.subscribeTo(this.resource, () => {
337
+ sideEffects.push('changed');
338
+ });
339
+ }
340
+ }
341
+
342
+ const vm = new ImperativeVM();
343
+ vm.init();
344
+
345
+ const resource = singleton(TestResource);
346
+ resource.reset([alice]);
347
+ expect(sideEffects).toEqual(['changed']);
348
+
349
+ vm.dispose();
350
+ sideEffects.length = 0;
351
+
352
+ // After dispose, subscription should be cleaned up
353
+ resource.reset([bob]);
354
+ expect(sideEffects).toEqual([]);
355
+ });
356
+ });
357
+
358
+ describe('Resource + ViewModel — Resource async tracking from ViewModel', () => {
359
+ it('Resource.async is accessible from ViewModel for loading state', async () => {
360
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
361
+ const resource = singleton(TestResource);
362
+ resource.init();
363
+
364
+ // Before loading
365
+ expect(resource.async.loadAll.loading).toBe(false);
366
+
367
+ const promise = resource.loadAll();
368
+ // During loading (already resolved since loadAll is sync internally, but the wrapper tracks it)
369
+ await promise;
370
+
371
+ expect(resource.async.loadAll.loading).toBe(false);
372
+ expect(resource.async.loadAll.error).toBeNull();
373
+
374
+ vm.dispose();
375
+ });
376
+
377
+ it('Resource.async tracks errors from failed methods', async () => {
378
+ const resource = singleton(TestResource);
379
+ resource.init();
380
+
381
+ try {
382
+ await resource.failingLoad();
383
+ } catch {
384
+ // expected
385
+ }
386
+
387
+ expect(resource.async.failingLoad.loading).toBe(false);
388
+ expect(resource.async.failingLoad.error).toBe('Network error');
389
+
390
+ resource.dispose();
391
+ });
392
+ });
393
+
394
+ describe('Resource + ViewModel — external Collection injection', () => {
395
+ it('ViewModel auto-tracks Resource with external Collection', () => {
396
+ const shared = new Collection<Item>([alice, bob]);
397
+
398
+ class InjectedResource extends Resource<Item> {}
399
+
400
+ class VMWithInjected extends ViewModel {
401
+ resource = new InjectedResource(shared);
402
+
403
+ get total(): number {
404
+ return this.resource.length;
405
+ }
406
+
407
+ get items(): Item[] {
408
+ return this.resource.items as Item[];
409
+ }
410
+ }
411
+
412
+ const vm = new VMWithInjected();
413
+ vm.init();
414
+
415
+ expect(vm.total).toBe(2);
416
+ expect(vm.items).toEqual([alice, bob]);
417
+
418
+ // Mutation through the external collection propagates to ViewModel
419
+ shared.add(charlie);
420
+ expect(vm.total).toBe(3);
421
+
422
+ // Mutation through the Resource also works (delegates to external)
423
+ vm.resource.remove(1);
424
+ expect(vm.total).toBe(2);
425
+ expect(shared.length).toBe(2); // external collection is mutated
426
+
427
+ vm.dispose();
428
+ });
429
+
430
+ it('ViewModel getter recomputes when external Collection changes', () => {
431
+ const shared = new Collection<Item>([]);
432
+
433
+ class InjectedResource extends Resource<Item> {}
434
+
435
+ class VMWithFilter extends ViewModel<{ statusFilter: 'all' | Item['status'] }> {
436
+ resource = new InjectedResource(shared);
437
+
438
+ get active(): Item[] {
439
+ return (this.resource.items as Item[]).filter(i => i.status === 'active');
440
+ }
441
+
442
+ setFilter(f: 'all' | Item['status']) { this.set({ statusFilter: f }); }
443
+ }
444
+
445
+ const vm = new VMWithFilter({ statusFilter: 'all' });
446
+ vm.init();
447
+
448
+ expect(vm.active).toEqual([]);
449
+
450
+ shared.reset([alice, bob, charlie]);
451
+ expect(vm.active).toEqual([alice, charlie]);
452
+
453
+ shared.update(2, { status: 'active' });
454
+ expect(vm.active).toHaveLength(3);
455
+
456
+ vm.dispose();
457
+ });
458
+
459
+ it('ViewModel dispose does not dispose external Collection', () => {
460
+ const shared = new Collection<Item>([alice]);
461
+
462
+ class InjectedResource extends Resource<Item> {}
463
+
464
+ class SimpleVM extends ViewModel {
465
+ resource = new InjectedResource(shared);
466
+
467
+ get total(): number { return this.resource.length; }
468
+ }
469
+
470
+ const vm = new SimpleVM();
471
+ vm.init();
472
+ expect(vm.total).toBe(1);
473
+
474
+ vm.dispose();
475
+
476
+ // External collection is still alive
477
+ expect(shared.disposed).toBe(false);
478
+ expect(shared.items).toEqual([alice]);
479
+
480
+ // Resource field is NOT disposed by ViewModel — only subscriptions are cleaned up
481
+ expect(vm.resource.disposed).toBe(false);
482
+ });
483
+ });
484
+
485
+ describe('Resource + ViewModel — subscribe notifications to React', () => {
486
+ it('ViewModel notifies listeners when Resource mutates (simulates React re-render)', async () => {
487
+ const vm = new TestViewModel({ search: '', statusFilter: 'all' });
488
+ await vm.init();
489
+
490
+ const states: number[] = [];
491
+ vm.subscribe(() => {
492
+ states.push(vm.total);
493
+ });
494
+
495
+ vm.resource.add({ id: 4, name: 'Dave', status: 'active' });
496
+ vm.resource.remove(1);
497
+
498
+ // Each Resource mutation should trigger a ViewModel notification
499
+ expect(states).toEqual([4, 3]);
500
+
501
+ vm.dispose();
502
+ });
503
+ });