mvc-kit 2.12.0 → 2.12.2

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 +19 -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,1583 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ViewModel } from './ViewModel';
3
+ import { Collection } from './Collection';
4
+
5
+ // ============================================================================
6
+ // Test Setup Classes
7
+ // ============================================================================
8
+
9
+ interface Item {
10
+ id: string;
11
+ name: string;
12
+ role?: string;
13
+ status?: string;
14
+ }
15
+
16
+ class TestCollection extends Collection<Item> {}
17
+
18
+ interface FilterState {
19
+ search: string;
20
+ statusFilter: string;
21
+ loading: boolean;
22
+ error: string | null;
23
+ }
24
+
25
+ class FilterViewModel extends ViewModel<FilterState> {
26
+ collection = new TestCollection();
27
+
28
+ get filtered(): Item[] {
29
+ const { search, statusFilter } = this.state;
30
+ let items = [...this.collection.items];
31
+ if (search) {
32
+ const q = search.toLowerCase();
33
+ items = items.filter((i) => i.name.toLowerCase().includes(q));
34
+ }
35
+ if (statusFilter !== 'all') {
36
+ items = items.filter((i) => i.status === statusFilter);
37
+ }
38
+ return items;
39
+ }
40
+
41
+ get total(): number {
42
+ return this.collection.length;
43
+ }
44
+
45
+ get hasResults(): boolean {
46
+ return this.filtered.length > 0;
47
+ }
48
+
49
+ get activeFilterCount(): number {
50
+ let count = 0;
51
+ if (this.state.search) count++;
52
+ if (this.state.statusFilter !== 'all') count++;
53
+ return count;
54
+ }
55
+
56
+ setSearch(search: string) {
57
+ this.set({ search });
58
+ }
59
+ setStatusFilter(statusFilter: string) {
60
+ this.set({ statusFilter });
61
+ }
62
+ setLoading(loading: boolean) {
63
+ this.set({ loading });
64
+ }
65
+ }
66
+
67
+ // VM with counter to track getter executions
68
+ class CountedFilterViewModel extends FilterViewModel {
69
+ filteredCallCount = 0;
70
+ totalCallCount = 0;
71
+ hasResultsCallCount = 0;
72
+ activeFilterCountCallCount = 0;
73
+
74
+ override get filtered(): Item[] {
75
+ this.filteredCallCount++;
76
+ return super.filtered;
77
+ }
78
+
79
+ override get total(): number {
80
+ this.totalCallCount++;
81
+ return super.total;
82
+ }
83
+
84
+ override get hasResults(): boolean {
85
+ this.hasResultsCallCount++;
86
+ return super.hasResults;
87
+ }
88
+
89
+ override get activeFilterCount(): number {
90
+ this.activeFilterCountCallCount++;
91
+ return super.activeFilterCount;
92
+ }
93
+ }
94
+
95
+ // ============================================================================
96
+ // State Proxy Tracking
97
+ // ============================================================================
98
+
99
+ describe('State Proxy Tracking', () => {
100
+ it('state proxy records accessed keys during tracking', () => {
101
+ const vm = new CountedFilterViewModel({
102
+ search: '',
103
+ statusFilter: 'all',
104
+ loading: false,
105
+ error: null,
106
+ });
107
+ vm.collection.reset([
108
+ { id: '1', name: 'Alice', status: 'active' },
109
+ { id: '2', name: 'Bob', status: 'inactive' },
110
+ ]);
111
+ vm.init();
112
+
113
+ // First access - tracking records search and statusFilter
114
+ const result1 = vm.filtered;
115
+ expect(result1).toHaveLength(2);
116
+ expect(vm.filteredCallCount).toBe(1);
117
+
118
+ // Change search - should invalidate
119
+ vm.setSearch('ali');
120
+ const result2 = vm.filtered;
121
+ expect(result2).toHaveLength(1);
122
+ expect(vm.filteredCallCount).toBe(2);
123
+
124
+ // Change loading (not accessed in getter) - should NOT invalidate
125
+ vm.setLoading(true);
126
+ const result3 = vm.filtered;
127
+ expect(result3).toBe(result2); // Same reference
128
+ expect(vm.filteredCallCount).toBe(2); // No recompute
129
+ });
130
+
131
+ it('state proxy does not record when tracking is inactive', () => {
132
+ const vm = new FilterViewModel({
133
+ search: 'test',
134
+ statusFilter: 'all',
135
+ loading: false,
136
+ error: null,
137
+ });
138
+
139
+ // Before init, state proxy is just a plain accessor
140
+ const searchValue = vm.state.search;
141
+ expect(searchValue).toBe('test');
142
+
143
+ // No tracking should have occurred
144
+ // (This is implicit - we can't directly check tracking, but we verify
145
+ // that getters work as plain accessors before init)
146
+ });
147
+
148
+ it('state proxy prevents direct mutation', () => {
149
+ const vm = new FilterViewModel({
150
+ search: '',
151
+ statusFilter: 'all',
152
+ loading: false,
153
+ error: null,
154
+ });
155
+ vm.init();
156
+
157
+ expect(() => {
158
+ (vm.state as any).search = 'test';
159
+ }).toThrow(); // Frozen state prevents direct mutation
160
+ });
161
+ });
162
+
163
+ // ============================================================================
164
+ // Auto-Memoized Getters - Basic
165
+ // ============================================================================
166
+
167
+ describe('Auto-Memoized Getters - Basic', () => {
168
+ it('getter computes correct value on first access', () => {
169
+ const vm = new FilterViewModel({
170
+ search: 'ali',
171
+ statusFilter: 'active',
172
+ loading: false,
173
+ error: null,
174
+ });
175
+ vm.collection.reset([
176
+ { id: '1', name: 'Alice', status: 'active' },
177
+ { id: '2', name: 'Bob', status: 'active' },
178
+ { id: '3', name: 'Charlie', status: 'inactive' },
179
+ ]);
180
+ vm.init();
181
+
182
+ const result = vm.filtered;
183
+ expect(result).toEqual([{ id: '1', name: 'Alice', status: 'active' }]);
184
+ });
185
+
186
+ it('getter returns cached reference on repeated access', () => {
187
+ const vm = new CountedFilterViewModel({
188
+ search: '',
189
+ statusFilter: 'all',
190
+ loading: false,
191
+ error: null,
192
+ });
193
+ vm.collection.reset([
194
+ { id: '1', name: 'Alice', status: 'active' },
195
+ { id: '2', name: 'Bob', status: 'inactive' },
196
+ ]);
197
+ vm.init();
198
+
199
+ const result1 = vm.filtered;
200
+ const result2 = vm.filtered;
201
+ const result3 = vm.filtered;
202
+
203
+ expect(result1).toBe(result2);
204
+ expect(result2).toBe(result3);
205
+ expect(vm.filteredCallCount).toBe(1); // Computed only once
206
+ });
207
+
208
+ it('getter recomputes when a dependency changes', () => {
209
+ const vm = new CountedFilterViewModel({
210
+ search: '',
211
+ statusFilter: 'all',
212
+ loading: false,
213
+ error: null,
214
+ });
215
+ vm.collection.reset([
216
+ { id: '1', name: 'Alice', status: 'active' },
217
+ { id: '2', name: 'Bob', status: 'inactive' },
218
+ ]);
219
+ vm.init();
220
+
221
+ const result1 = vm.filtered;
222
+ expect(result1).toHaveLength(2);
223
+ expect(vm.filteredCallCount).toBe(1);
224
+
225
+ vm.setSearch('ali');
226
+ const result2 = vm.filtered;
227
+ expect(result2).toHaveLength(1);
228
+ expect(result2).not.toBe(result1); // Different reference
229
+ expect(vm.filteredCallCount).toBe(2);
230
+ });
231
+
232
+ it('getter does NOT recompute when unrelated state changes', () => {
233
+ const vm = new CountedFilterViewModel({
234
+ search: '',
235
+ statusFilter: 'all',
236
+ loading: false,
237
+ error: null,
238
+ });
239
+ vm.collection.reset([
240
+ { id: '1', name: 'Alice', status: 'active' },
241
+ { id: '2', name: 'Bob', status: 'inactive' },
242
+ ]);
243
+ vm.init();
244
+
245
+ const result1 = vm.filtered;
246
+ expect(vm.filteredCallCount).toBe(1);
247
+
248
+ vm.setLoading(true);
249
+ const result2 = vm.filtered;
250
+ expect(result2).toBe(result1); // Same reference
251
+ expect(vm.filteredCallCount).toBe(1); // No recompute
252
+ });
253
+
254
+ it('getter handles empty initial state', () => {
255
+ const vm = new FilterViewModel({
256
+ search: '',
257
+ statusFilter: 'all',
258
+ loading: false,
259
+ error: null,
260
+ });
261
+ vm.init();
262
+
263
+ const result = vm.filtered;
264
+ expect(result).toEqual([]);
265
+ });
266
+ });
267
+
268
+ // ============================================================================
269
+ // Conditional Dependencies
270
+ // ============================================================================
271
+
272
+ describe('Conditional Dependencies', () => {
273
+ interface ModeState {
274
+ mode: string;
275
+ title: string;
276
+ subtitle: string;
277
+ }
278
+
279
+ class ModeVM extends ViewModel<ModeState> {
280
+ displayCallCount = 0;
281
+
282
+ get display(): string {
283
+ this.displayCallCount++;
284
+ if (this.state.mode === 'compact') return this.state.title;
285
+ return `${this.state.title} — ${this.state.subtitle}`;
286
+ }
287
+
288
+ setMode(mode: string) {
289
+ this.set({ mode });
290
+ }
291
+ setTitle(title: string) {
292
+ this.set({ title });
293
+ }
294
+ setSubtitle(subtitle: string) {
295
+ this.set({ subtitle });
296
+ }
297
+ }
298
+
299
+ it('getter re-tracks deps when conditional branches change', () => {
300
+ const vm = new ModeVM({
301
+ mode: 'compact',
302
+ title: 'Hello',
303
+ subtitle: 'World',
304
+ });
305
+ vm.init();
306
+
307
+ // In compact mode, only title is accessed
308
+ const result1 = vm.display;
309
+ expect(result1).toBe('Hello');
310
+ expect(vm.displayCallCount).toBe(1);
311
+
312
+ // Change subtitle - should NOT recompute (not a dependency in compact mode)
313
+ vm.setSubtitle('Universe');
314
+ const result2 = vm.display;
315
+ expect(result2).toBe(result1); // Same reference
316
+ expect(vm.displayCallCount).toBe(1);
317
+
318
+ // Switch to full mode - recompute and re-track
319
+ vm.setMode('full');
320
+ const result3 = vm.display;
321
+ expect(result3).toBe('Hello — Universe');
322
+ expect(vm.displayCallCount).toBe(2);
323
+
324
+ // Now subtitle IS a dependency - should recompute
325
+ vm.setSubtitle('Galaxy');
326
+ const result4 = vm.display;
327
+ expect(result4).toBe('Hello — Galaxy');
328
+ expect(vm.displayCallCount).toBe(3);
329
+ });
330
+ });
331
+
332
+ // ============================================================================
333
+ // Getter Composition (Nested)
334
+ // ============================================================================
335
+
336
+ describe('Getter Composition (Nested)', () => {
337
+ it('getter reads another getter and composes correctly', () => {
338
+ const vm = new FilterViewModel({
339
+ search: 'ali',
340
+ statusFilter: 'all',
341
+ loading: false,
342
+ error: null,
343
+ });
344
+ vm.collection.reset([
345
+ { id: '1', name: 'Alice', status: 'active' },
346
+ { id: '2', name: 'Bob', status: 'inactive' },
347
+ ]);
348
+ vm.init();
349
+
350
+ const hasResults = vm.hasResults;
351
+ expect(hasResults).toBe(true);
352
+ });
353
+
354
+ it('nested getter invalidates on transitive dependency', () => {
355
+ const vm = new CountedFilterViewModel({
356
+ search: '',
357
+ statusFilter: 'all',
358
+ loading: false,
359
+ error: null,
360
+ });
361
+ vm.collection.reset([
362
+ { id: '1', name: 'Alice', status: 'active' },
363
+ { id: '2', name: 'Bob', status: 'inactive' },
364
+ ]);
365
+ vm.init();
366
+
367
+ const hasResults1 = vm.hasResults;
368
+ expect(hasResults1).toBe(true);
369
+ expect(vm.hasResultsCallCount).toBe(1);
370
+ expect(vm.filteredCallCount).toBe(1);
371
+
372
+ // Set search to something that matches nothing
373
+ vm.setSearch('xyz');
374
+ const hasResults2 = vm.hasResults;
375
+ expect(hasResults2).toBe(false);
376
+ expect(vm.hasResultsCallCount).toBe(2); // hasResults recomputed
377
+ expect(vm.filteredCallCount).toBe(2); // filtered recomputed (dependency of hasResults)
378
+ });
379
+
380
+ it('nested getter caches when non-dependency changes', () => {
381
+ const vm = new CountedFilterViewModel({
382
+ search: '',
383
+ statusFilter: 'all',
384
+ loading: false,
385
+ error: null,
386
+ });
387
+ vm.collection.reset([
388
+ { id: '1', name: 'Alice', status: 'active' },
389
+ { id: '2', name: 'Bob', status: 'inactive' },
390
+ ]);
391
+ vm.init();
392
+
393
+ const hasResults1 = vm.hasResults;
394
+ expect(hasResults1).toBe(true);
395
+ expect(vm.hasResultsCallCount).toBe(1);
396
+
397
+ vm.setLoading(true);
398
+ const hasResults2 = vm.hasResults;
399
+ expect(hasResults2).toBe(hasResults1);
400
+ expect(vm.hasResultsCallCount).toBe(1); // No recompute
401
+ });
402
+
403
+ it('two sibling getters sharing a nested getter both update on collection change', () => {
404
+ // This is the exact bug scenario: filteredCount accesses `filtered` first,
405
+ // then paged accesses `filtered` second via Tier 1 cache hit.
406
+ // Without dep bubbling, paged misses the collection dependency.
407
+ class PaginatedVM extends ViewModel<FilterState> {
408
+ collection = new TestCollection();
409
+ filteredCallCount = 0;
410
+ filteredCountCallCount = 0;
411
+ pagedCallCount = 0;
412
+
413
+ get filtered(): Item[] {
414
+ this.filteredCallCount++;
415
+ const { search } = this.state;
416
+ let items = [...this.collection.items];
417
+ if (search) {
418
+ const q = search.toLowerCase();
419
+ items = items.filter((i) => i.name.toLowerCase().includes(q));
420
+ }
421
+ return items;
422
+ }
423
+
424
+ get filteredCount(): number {
425
+ this.filteredCountCallCount++;
426
+ return this.filtered.length;
427
+ }
428
+
429
+ get paged(): Item[] {
430
+ this.pagedCallCount++;
431
+ return this.filtered.slice(0, 2);
432
+ }
433
+
434
+ setSearch(search: string) { this.set({ search }); }
435
+ }
436
+
437
+ const vm = new PaginatedVM({ search: '', statusFilter: 'all', loading: false, error: null });
438
+ vm.init();
439
+
440
+ // Access filteredCount FIRST, then paged (same order as the component)
441
+ expect(vm.filteredCount).toBe(0);
442
+ expect(vm.paged).toEqual([]);
443
+
444
+ // Populate collection
445
+ vm.collection.reset([
446
+ { id: '1', name: 'Alice', status: 'active' },
447
+ { id: '2', name: 'Bob', status: 'inactive' },
448
+ { id: '3', name: 'Charlie', status: 'active' },
449
+ ]);
450
+
451
+ // BOTH getters must update — this was the bug
452
+ expect(vm.filteredCount).toBe(3);
453
+ expect(vm.paged).toHaveLength(2);
454
+ expect(vm.paged[0].name).toBe('Alice');
455
+ });
456
+
457
+ it('sibling getters update regardless of access order (reversed)', () => {
458
+ class PaginatedVM extends ViewModel<FilterState> {
459
+ collection = new TestCollection();
460
+
461
+ get filtered(): Item[] {
462
+ const { search } = this.state;
463
+ let items = [...this.collection.items];
464
+ if (search) {
465
+ const q = search.toLowerCase();
466
+ items = items.filter((i) => i.name.toLowerCase().includes(q));
467
+ }
468
+ return items;
469
+ }
470
+
471
+ get filteredCount(): number {
472
+ return this.filtered.length;
473
+ }
474
+
475
+ get paged(): Item[] {
476
+ return this.filtered.slice(0, 2);
477
+ }
478
+
479
+ setSearch(search: string) { this.set({ search }); }
480
+ }
481
+
482
+ const vm = new PaginatedVM({ search: '', statusFilter: 'all', loading: false, error: null });
483
+ vm.init();
484
+
485
+ // Access paged FIRST, then filteredCount (reversed order)
486
+ expect(vm.paged).toEqual([]);
487
+ expect(vm.filteredCount).toBe(0);
488
+
489
+ vm.collection.reset([
490
+ { id: '1', name: 'Alice', status: 'active' },
491
+ { id: '2', name: 'Bob', status: 'inactive' },
492
+ ]);
493
+
494
+ // Both must update regardless of access order
495
+ expect(vm.paged).toHaveLength(2);
496
+ expect(vm.filteredCount).toBe(2);
497
+ });
498
+
499
+ it('sibling getters both recompute after state dep change (counter verification)', () => {
500
+ class PaginatedVM extends ViewModel<FilterState> {
501
+ collection = new TestCollection();
502
+ filteredCallCount = 0;
503
+ filteredCountCallCount = 0;
504
+ pagedCallCount = 0;
505
+
506
+ get filtered(): Item[] {
507
+ this.filteredCallCount++;
508
+ const { search } = this.state;
509
+ let items = [...this.collection.items];
510
+ if (search) {
511
+ const q = search.toLowerCase();
512
+ items = items.filter((i) => i.name.toLowerCase().includes(q));
513
+ }
514
+ return items;
515
+ }
516
+
517
+ get filteredCount(): number {
518
+ this.filteredCountCallCount++;
519
+ return this.filtered.length;
520
+ }
521
+
522
+ get paged(): Item[] {
523
+ this.pagedCallCount++;
524
+ return this.filtered.slice(0, 2);
525
+ }
526
+
527
+ setSearch(search: string) { this.set({ search }); }
528
+ }
529
+
530
+ const vm = new PaginatedVM({ search: '', statusFilter: 'all', loading: false, error: null });
531
+ vm.collection.reset([
532
+ { id: '1', name: 'Alice', status: 'active' },
533
+ { id: '2', name: 'Bob', status: 'inactive' },
534
+ { id: '3', name: 'Charlie', status: 'active' },
535
+ ]);
536
+ vm.init();
537
+
538
+ // Initial access: filteredCount first, paged second
539
+ expect(vm.filteredCount).toBe(3);
540
+ expect(vm.paged).toHaveLength(2);
541
+ // filtered called once by filteredCount (Tier 3), then Tier 1 cache hit for paged
542
+ expect(vm.filteredCallCount).toBe(1);
543
+ expect(vm.filteredCountCallCount).toBe(1);
544
+ expect(vm.pagedCallCount).toBe(1);
545
+
546
+ // Change a state dep (search) — both siblings must recompute
547
+ vm.setSearch('ali');
548
+ expect(vm.filteredCount).toBe(1);
549
+ expect(vm.paged).toHaveLength(1);
550
+ expect(vm.filteredCountCallCount).toBe(2);
551
+ expect(vm.pagedCallCount).toBe(2);
552
+ });
553
+
554
+ it('three-level nesting: grandchild deps bubble through all levels', () => {
555
+ // Chain: summary → paged → sorted → filtered → collection
556
+ class DeepChainVM extends ViewModel<FilterState> {
557
+ collection = new TestCollection();
558
+ filteredCallCount = 0;
559
+ sortedCallCount = 0;
560
+ pagedCallCount = 0;
561
+ summaryCallCount = 0;
562
+
563
+ get filtered(): Item[] {
564
+ this.filteredCallCount++;
565
+ const { search } = this.state;
566
+ let items = [...this.collection.items];
567
+ if (search) {
568
+ const q = search.toLowerCase();
569
+ items = items.filter((i) => i.name.toLowerCase().includes(q));
570
+ }
571
+ return items;
572
+ }
573
+
574
+ get sorted(): Item[] {
575
+ this.sortedCallCount++;
576
+ return [...this.filtered].sort((a, b) => a.name.localeCompare(b.name));
577
+ }
578
+
579
+ get paged(): Item[] {
580
+ this.pagedCallCount++;
581
+ return this.sorted.slice(0, 2);
582
+ }
583
+
584
+ get summary(): string {
585
+ this.summaryCallCount++;
586
+ return `Showing ${this.paged.length} of ${this.filtered.length}`;
587
+ }
588
+
589
+ setSearch(search: string) { this.set({ search }); }
590
+ }
591
+
592
+ const vm = new DeepChainVM({ search: '', statusFilter: 'all', loading: false, error: null });
593
+ vm.init();
594
+
595
+ expect(vm.summary).toBe('Showing 0 of 0');
596
+
597
+ vm.collection.reset([
598
+ { id: '1', name: 'Charlie', status: 'active' },
599
+ { id: '2', name: 'Alice', status: 'active' },
600
+ { id: '3', name: 'Bob', status: 'active' },
601
+ ]);
602
+
603
+ expect(vm.summary).toBe('Showing 2 of 3');
604
+ expect(vm.paged[0].name).toBe('Alice'); // sorted
605
+
606
+ vm.setSearch('ali');
607
+ expect(vm.summary).toBe('Showing 1 of 1');
608
+ expect(vm.paged[0].name).toBe('Alice');
609
+ });
610
+ });
611
+
612
+ // ============================================================================
613
+ // Getter Inheritance
614
+ // ============================================================================
615
+
616
+ describe('Getter Inheritance', () => {
617
+ interface BaseState {
618
+ items: Item[];
619
+ search: string;
620
+ }
621
+
622
+ class BaseListVM extends ViewModel<BaseState> {
623
+ isEmptyCallCount = 0;
624
+
625
+ get isEmpty(): boolean {
626
+ this.isEmptyCallCount++;
627
+ return this.state.items.length === 0;
628
+ }
629
+ }
630
+
631
+ class DerivedVM extends BaseListVM {
632
+ filteredCallCount = 0;
633
+
634
+ get filtered(): Item[] {
635
+ this.filteredCallCount++;
636
+ return this.state.items.filter((i) => i.name.includes(this.state.search));
637
+ }
638
+
639
+ setSearch(s: string) {
640
+ this.set({ search: s });
641
+ }
642
+
643
+ setItems(items: Item[]) {
644
+ this.set({ items });
645
+ }
646
+ }
647
+
648
+ it('getters from parent classes are memoized', () => {
649
+ const vm = new DerivedVM({
650
+ items: [],
651
+ search: '',
652
+ });
653
+ vm.init();
654
+
655
+ const isEmpty1 = vm.isEmpty;
656
+ expect(isEmpty1).toBe(true);
657
+ expect(vm.isEmptyCallCount).toBe(1);
658
+
659
+ const filtered1 = vm.filtered;
660
+ expect(filtered1).toEqual([]);
661
+ expect(vm.filteredCallCount).toBe(1);
662
+ });
663
+
664
+ it('subclass getter override replaces parent', () => {
665
+ interface LabelState {
666
+ name: string;
667
+ }
668
+
669
+ class ParentVM extends ViewModel<LabelState> {
670
+ get label(): string {
671
+ return 'parent';
672
+ }
673
+ }
674
+
675
+ class ChildVM extends ParentVM {
676
+ override get label(): string {
677
+ return this.state.name;
678
+ }
679
+
680
+ setName(name: string) {
681
+ this.set({ name });
682
+ }
683
+ }
684
+
685
+ const vm = new ChildVM({ name: 'child' });
686
+ vm.init();
687
+
688
+ expect(vm.label).toBe('child');
689
+ });
690
+ });
691
+
692
+ // ============================================================================
693
+ // Subscribable Auto-Detection
694
+ // ============================================================================
695
+
696
+ describe('Subscribable Auto-Detection', () => {
697
+ it('subscribable members are detected and subscribed', () => {
698
+ const vm = new FilterViewModel({
699
+ search: '',
700
+ statusFilter: 'all',
701
+ loading: false,
702
+ error: null,
703
+ });
704
+ vm.collection.reset([
705
+ { id: '1', name: 'Alice', status: 'active' },
706
+ ]);
707
+ vm.init();
708
+
709
+ const listener = vi.fn();
710
+ vm.subscribe(listener);
711
+
712
+ vm.collection.reset([
713
+ { id: '2', name: 'Bob', status: 'inactive' },
714
+ ]);
715
+
716
+ expect(listener).toHaveBeenCalledTimes(1);
717
+ });
718
+
719
+ it('subscribable notification does not create new state reference', () => {
720
+ const vm = new FilterViewModel({
721
+ search: '',
722
+ statusFilter: 'all',
723
+ loading: false,
724
+ error: null,
725
+ });
726
+ vm.collection.reset([
727
+ { id: '1', name: 'Alice', status: 'active' },
728
+ ]);
729
+ vm.init();
730
+
731
+ const stateBefore = vm.state;
732
+
733
+ vm.collection.reset([
734
+ { id: '2', name: 'Bob', status: 'inactive' },
735
+ ]);
736
+
737
+ // State reference is unchanged — subscribable notifications no longer
738
+ // spread a new object, since React detection uses a version counter
739
+ expect(vm.state).toBe(stateBefore);
740
+ });
741
+
742
+ it('non-subscribable members are ignored', () => {
743
+ interface VMState {
744
+ count: number;
745
+ }
746
+
747
+ class VMWithPlainObject extends ViewModel<VMState> {
748
+ plainObject = { value: 42 };
749
+
750
+ get doubled(): number {
751
+ return this.state.count * 2;
752
+ }
753
+ }
754
+
755
+ const vm = new VMWithPlainObject({ count: 5 });
756
+ vm.init();
757
+
758
+ // Should not throw, plainObject is ignored
759
+ expect(vm.doubled).toBe(10);
760
+ });
761
+
762
+ it('subscribable notification bumps revision and triggers notify', () => {
763
+ const vm = new FilterViewModel({
764
+ search: '',
765
+ statusFilter: 'all',
766
+ loading: false,
767
+ error: null,
768
+ });
769
+ vm.collection.reset([
770
+ { id: '1', name: 'Alice', status: 'active' },
771
+ ]);
772
+ vm.init();
773
+
774
+ const listener = vi.fn();
775
+ vm.subscribe(listener);
776
+
777
+ vm.collection.reset([
778
+ { id: '2', name: 'Bob', status: 'inactive' },
779
+ ]);
780
+
781
+ expect(listener).toHaveBeenCalledTimes(1);
782
+ // Listener called with (state, state) — same object reference
783
+ expect(listener).toHaveBeenCalledWith(vm.state, vm.state);
784
+ });
785
+
786
+ it('multiple subscribable members tracked independently', () => {
787
+ interface TwoCollectionsState {
788
+ label: string;
789
+ }
790
+
791
+ class TwoCollectionsVM extends ViewModel<TwoCollectionsState> {
792
+ collection1 = new TestCollection();
793
+ collection2 = new TestCollection();
794
+
795
+ collection1Count = 0;
796
+ collection2Count = 0;
797
+
798
+ get fromCollection1() {
799
+ this.collection1Count++;
800
+ return this.collection1.items;
801
+ }
802
+
803
+ get fromCollection2() {
804
+ this.collection2Count++;
805
+ return this.collection2.items;
806
+ }
807
+ }
808
+
809
+ const vm = new TwoCollectionsVM({ label: 'test' });
810
+ vm.collection1.reset([{ id: '1', name: 'Alice' }]);
811
+ vm.collection2.reset([{ id: '2', name: 'Bob' }]);
812
+ vm.init();
813
+
814
+ const result1 = vm.fromCollection1;
815
+ expect(result1).toHaveLength(1);
816
+ expect(vm.collection1Count).toBe(1);
817
+
818
+ // Change collection2 - should NOT invalidate fromCollection1
819
+ vm.collection2.reset([{ id: '3', name: 'Charlie' }]);
820
+ const result1Again = vm.fromCollection1;
821
+ expect(result1Again).toBe(result1); // Same reference
822
+ expect(vm.collection1Count).toBe(1); // No recompute
823
+
824
+ // Change collection1 - SHOULD invalidate fromCollection1
825
+ vm.collection1.reset([{ id: '4', name: 'Diana' }]);
826
+ const result1Updated = vm.fromCollection1;
827
+ expect(result1Updated).not.toBe(result1);
828
+ expect(vm.collection1Count).toBe(2);
829
+ });
830
+
831
+ it('subscribable member async state changes invalidate parent getters', async () => {
832
+ let resolveLoad!: () => void;
833
+
834
+ class ChildVM extends ViewModel {
835
+ async loadData() {
836
+ await new Promise<void>((r) => { resolveLoad = r; });
837
+ }
838
+ }
839
+
840
+ class ParentVM extends ViewModel {
841
+ childVM = new ChildVM();
842
+
843
+ get isChildLoading() {
844
+ return this.childVM.async.loadData.loading;
845
+ }
846
+
847
+ onInit() {
848
+ this.childVM.init();
849
+ }
850
+
851
+ onDispose() {
852
+ this.childVM.dispose();
853
+ }
854
+ }
855
+
856
+ const parent = new ParentVM();
857
+ parent.init();
858
+
859
+ // Initially not loading
860
+ expect(parent.isChildLoading).toBe(false);
861
+
862
+ // Start the async operation on the child
863
+ const loadPromise = parent.childVM.loadData();
864
+ // Allow microtask to propagate async tracking
865
+ await Promise.resolve();
866
+
867
+ // Parent getter should now reflect loading = true
868
+ expect(parent.isChildLoading).toBe(true);
869
+
870
+ // Verify parent.subscribe fires on child async changes
871
+ const listener = vi.fn();
872
+ parent.subscribe(listener);
873
+
874
+ // Resolve the async operation
875
+ resolveLoad();
876
+ await loadPromise;
877
+
878
+ // Parent getter should return to false
879
+ expect(parent.isChildLoading).toBe(false);
880
+ // Listener should have been called (child async → parent notification)
881
+ expect(listener).toHaveBeenCalled();
882
+
883
+ parent.dispose();
884
+ });
885
+ });
886
+
887
+ // ============================================================================
888
+ // Subscribable Dependencies in Getters
889
+ // ============================================================================
890
+
891
+ describe('Subscribable Dependencies in Getters', () => {
892
+ it('getter that reads collection depends on collection', () => {
893
+ const vm = new CountedFilterViewModel({
894
+ search: '',
895
+ statusFilter: 'all',
896
+ loading: false,
897
+ error: null,
898
+ });
899
+ vm.collection.reset([
900
+ { id: '1', name: 'Alice', status: 'active' },
901
+ ]);
902
+ vm.init();
903
+
904
+ const result1 = vm.filtered;
905
+ expect(result1).toHaveLength(1);
906
+ expect(vm.filteredCallCount).toBe(1);
907
+
908
+ vm.collection.reset([
909
+ { id: '2', name: 'Bob', status: 'inactive' },
910
+ { id: '3', name: 'Charlie', status: 'active' },
911
+ ]);
912
+ const result2 = vm.filtered;
913
+ expect(result2).toHaveLength(2);
914
+ expect(vm.filteredCallCount).toBe(2);
915
+ });
916
+
917
+ it('getter does not recompute when unrelated subscribable changes', () => {
918
+ interface TwoCollectionsState {
919
+ label: string;
920
+ }
921
+
922
+ class TwoCollectionsVM extends ViewModel<TwoCollectionsState> {
923
+ collection1 = new TestCollection();
924
+ collection2 = new TestCollection();
925
+ callCount = 0;
926
+
927
+ get fromCollection1() {
928
+ this.callCount++;
929
+ return this.collection1.items;
930
+ }
931
+ }
932
+
933
+ const vm = new TwoCollectionsVM({ label: 'test' });
934
+ vm.collection1.reset([{ id: '1', name: 'Alice' }]);
935
+ vm.collection2.reset([{ id: '2', name: 'Bob' }]);
936
+ vm.init();
937
+
938
+ const result1 = vm.fromCollection1;
939
+ expect(result1).toHaveLength(1);
940
+ expect(vm.callCount).toBe(1);
941
+
942
+ // Change unrelated collection2
943
+ vm.collection2.reset([{ id: '3', name: 'Charlie' }]);
944
+ const result2 = vm.fromCollection1;
945
+ expect(result2).toBe(result1); // Same reference
946
+ expect(vm.callCount).toBe(1); // No recompute
947
+ });
948
+
949
+ it('getter with mixed state + subscribable deps', () => {
950
+ const vm = new CountedFilterViewModel({
951
+ search: '',
952
+ statusFilter: 'all',
953
+ loading: false,
954
+ error: null,
955
+ });
956
+ vm.collection.reset([
957
+ { id: '1', name: 'Alice', status: 'active' },
958
+ { id: '2', name: 'Bob', status: 'inactive' },
959
+ ]);
960
+ vm.init();
961
+
962
+ // Initial access
963
+ const result1 = vm.filtered;
964
+ expect(result1).toHaveLength(2);
965
+ expect(vm.filteredCallCount).toBe(1);
966
+
967
+ // 1) Change state dep -> recompute
968
+ vm.setSearch('ali');
969
+ const result2 = vm.filtered;
970
+ expect(result2).toHaveLength(1);
971
+ expect(vm.filteredCallCount).toBe(2);
972
+
973
+ // 2) Change unrelated state -> no recompute
974
+ vm.setLoading(true);
975
+ const result3 = vm.filtered;
976
+ expect(result3).toBe(result2);
977
+ expect(vm.filteredCallCount).toBe(2);
978
+
979
+ // 3) Change collection -> recompute
980
+ vm.collection.reset([
981
+ { id: '3', name: 'Alice Cooper', status: 'active' },
982
+ ]);
983
+ const result4 = vm.filtered;
984
+ expect(result4).toHaveLength(1);
985
+ expect(result4[0].name).toBe('Alice Cooper');
986
+ expect(vm.filteredCallCount).toBe(3);
987
+ });
988
+ });
989
+
990
+ // ============================================================================
991
+ // Cleanup and Dispose
992
+ // ============================================================================
993
+
994
+ describe('Cleanup and Dispose', () => {
995
+ it('dispose unsubscribes from all tracked sources', () => {
996
+ const vm = new FilterViewModel({
997
+ search: '',
998
+ statusFilter: 'all',
999
+ loading: false,
1000
+ error: null,
1001
+ });
1002
+ vm.collection.reset([
1003
+ { id: '1', name: 'Alice', status: 'active' },
1004
+ ]);
1005
+ vm.init();
1006
+
1007
+ const listener = vi.fn();
1008
+ vm.subscribe(listener);
1009
+
1010
+ // Trigger notification before dispose
1011
+ vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
1012
+ expect(listener).toHaveBeenCalledTimes(1);
1013
+
1014
+ vm.dispose();
1015
+
1016
+ // After dispose, collection changes should not trigger VM notifications
1017
+ vm.collection.reset([{ id: '3', name: 'Charlie', status: 'active' }]);
1018
+ expect(listener).toHaveBeenCalledTimes(1); // Still 1, no new calls
1019
+ });
1020
+
1021
+ it('collection changes after dispose do not trigger notify', () => {
1022
+ const vm = new FilterViewModel({
1023
+ search: '',
1024
+ statusFilter: 'all',
1025
+ loading: false,
1026
+ error: null,
1027
+ });
1028
+ vm.collection.reset([
1029
+ { id: '1', name: 'Alice', status: 'active' },
1030
+ ]);
1031
+ vm.init();
1032
+
1033
+ const listener = vi.fn();
1034
+ vm.subscribe(listener);
1035
+
1036
+ vm.dispose();
1037
+
1038
+ vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
1039
+ expect(listener).not.toHaveBeenCalled();
1040
+ });
1041
+
1042
+ it('getter access after dispose returns last cached value', () => {
1043
+ const vm = new CountedFilterViewModel({
1044
+ search: '',
1045
+ statusFilter: 'all',
1046
+ loading: false,
1047
+ error: null,
1048
+ });
1049
+ vm.collection.reset([
1050
+ { id: '1', name: 'Alice', status: 'active' },
1051
+ ]);
1052
+ vm.init();
1053
+
1054
+ const result1 = vm.filtered;
1055
+ expect(result1).toHaveLength(1);
1056
+ expect(vm.filteredCallCount).toBe(1);
1057
+
1058
+ vm.dispose();
1059
+
1060
+ const result2 = vm.filtered;
1061
+ expect(result2).toBe(result1); // Same cached value
1062
+ expect(vm.filteredCallCount).toBe(1); // No recompute
1063
+ });
1064
+ });
1065
+
1066
+ // ============================================================================
1067
+ // Revision Counter
1068
+ // ============================================================================
1069
+
1070
+ describe('Revision Counter', () => {
1071
+ it('revision starts at 0 (implicit via Tier 3 behavior)', () => {
1072
+ const vm = new CountedFilterViewModel({
1073
+ search: '',
1074
+ statusFilter: 'all',
1075
+ loading: false,
1076
+ error: null,
1077
+ });
1078
+ vm.init();
1079
+
1080
+ // First getter access always computes (revision 0 -> Tier 3)
1081
+ // @ts-expect-error
1082
+ const result = vm.filtered;
1083
+ expect(vm.filteredCallCount).toBe(1);
1084
+ });
1085
+
1086
+ it('set() with actual changes increments revision', () => {
1087
+ const vm = new CountedFilterViewModel({
1088
+ search: '',
1089
+ statusFilter: 'all',
1090
+ loading: false,
1091
+ error: null,
1092
+ });
1093
+ vm.init();
1094
+
1095
+ vm.filtered; // Initial compute
1096
+ expect(vm.filteredCallCount).toBe(1);
1097
+
1098
+ vm.setSearch('ali'); // Changes state -> increments revision
1099
+ vm.filtered; // Should recompute
1100
+ expect(vm.filteredCallCount).toBe(2);
1101
+ });
1102
+
1103
+ it('set() with no changes does NOT increment revision', () => {
1104
+ const vm = new CountedFilterViewModel({
1105
+ search: 'ali',
1106
+ statusFilter: 'all',
1107
+ loading: false,
1108
+ error: null,
1109
+ });
1110
+ vm.init();
1111
+
1112
+ vm.filtered; // Initial compute
1113
+ expect(vm.filteredCallCount).toBe(1);
1114
+
1115
+ vm.setSearch('ali'); // Same value -> no change, no revision increment
1116
+ vm.filtered; // Should NOT recompute
1117
+ expect(vm.filteredCallCount).toBe(1);
1118
+ });
1119
+
1120
+ it('subscribable notification increments revision', () => {
1121
+ const vm = new CountedFilterViewModel({
1122
+ search: '',
1123
+ statusFilter: 'all',
1124
+ loading: false,
1125
+ error: null,
1126
+ });
1127
+ vm.collection.reset([{ id: '1', name: 'Alice', status: 'active' }]);
1128
+ vm.init();
1129
+
1130
+ vm.filtered; // Initial compute
1131
+ expect(vm.filteredCallCount).toBe(1);
1132
+
1133
+ vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
1134
+ vm.filtered; // Should recompute due to revision increment
1135
+ expect(vm.filteredCallCount).toBe(2);
1136
+ });
1137
+
1138
+ it('multiple rapid set() calls each increment revision', () => {
1139
+ const vm = new CountedFilterViewModel({
1140
+ search: '',
1141
+ statusFilter: 'all',
1142
+ loading: false,
1143
+ error: null,
1144
+ });
1145
+ vm.init();
1146
+
1147
+ vm.filtered;
1148
+ expect(vm.filteredCallCount).toBe(1);
1149
+
1150
+ vm.setSearch('a');
1151
+ vm.setSearch('ab');
1152
+ vm.setSearch('abc');
1153
+
1154
+ vm.filtered;
1155
+ expect(vm.filteredCallCount).toBe(2); // Only one recompute after all changes
1156
+ });
1157
+ });
1158
+
1159
+ // ============================================================================
1160
+ // Dev-Mode Safety
1161
+ // ============================================================================
1162
+
1163
+ describe(' Dev-Mode Safety', () => {
1164
+ it('set() inside a getter logs error when __MVC_KIT_DEV__ is enabled', () => {
1165
+ interface BadState {
1166
+ count: number;
1167
+ computed: string;
1168
+ }
1169
+
1170
+ class BadVM extends ViewModel<BadState> {
1171
+ get broken(): string {
1172
+ this.publicSet({ count: 999 }); // BAD!
1173
+ return 'bad';
1174
+ }
1175
+
1176
+ publicSet(p: Partial<BadState>) {
1177
+ this.set(p);
1178
+ }
1179
+ }
1180
+
1181
+ const vm = new BadVM({ count: 0, computed: '' });
1182
+ vm.init();
1183
+
1184
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1185
+
1186
+ vm.broken;
1187
+
1188
+ expect(consoleErrorSpy).toHaveBeenCalled();
1189
+ expect(consoleErrorSpy.mock.calls[0][0]).toMatch(/set\(\) called inside a getter/i);
1190
+ expect(vm.state.count).toBe(0); // State NOT mutated
1191
+
1192
+ consoleErrorSpy.mockRestore();
1193
+ });
1194
+ });
1195
+
1196
+ // ============================================================================
1197
+ // Multiple Instances
1198
+ // ============================================================================
1199
+
1200
+ describe(' Multiple Instances', () => {
1201
+ it('two instances of same ViewModel class have independent caches', () => {
1202
+ const vm1 = new CountedFilterViewModel({
1203
+ search: '',
1204
+ statusFilter: 'all',
1205
+ loading: false,
1206
+ error: null,
1207
+ });
1208
+ vm1.collection.reset([{ id: '1', name: 'Alice', status: 'active' }]);
1209
+ vm1.init();
1210
+
1211
+ const vm2 = new CountedFilterViewModel({
1212
+ search: 'bob',
1213
+ statusFilter: 'all',
1214
+ loading: false,
1215
+ error: null,
1216
+ });
1217
+ vm2.collection.reset([
1218
+ { id: '2', name: 'Bob', status: 'inactive' },
1219
+ { id: '3', name: 'Alice', status: 'active' },
1220
+ ]);
1221
+ vm2.init();
1222
+
1223
+ const result1 = vm1.filtered;
1224
+ const result2 = vm2.filtered;
1225
+
1226
+ expect(result1).toHaveLength(1);
1227
+ expect(result1[0].name).toBe('Alice');
1228
+
1229
+ expect(result2).toHaveLength(1);
1230
+ expect(result2[0].name).toBe('Bob');
1231
+
1232
+ // Changes to vm1 should not affect vm2
1233
+ vm1.setSearch('xyz');
1234
+ const result1Updated = vm1.filtered;
1235
+ const result2Again = vm2.filtered;
1236
+
1237
+ expect(result1Updated).toHaveLength(0);
1238
+ expect(result2Again).toBe(result2); // vm2 unchanged
1239
+ });
1240
+
1241
+ it('two instances sharing a singleton collection both react', () => {
1242
+ const sharedCollection = new TestCollection();
1243
+ sharedCollection.reset([
1244
+ { id: '1', name: 'Alice', status: 'active' },
1245
+ { id: '2', name: 'Bob', status: 'inactive' },
1246
+ ]);
1247
+
1248
+ class SharedCollectionVM extends ViewModel<FilterState> {
1249
+ constructor(initialState: FilterState, public collection: TestCollection) {
1250
+ super(initialState);
1251
+ }
1252
+
1253
+ get filtered() {
1254
+ const { search } = this.state;
1255
+ if (!search) return this.collection.items;
1256
+ return this.collection.items.filter((i) =>
1257
+ i.name.toLowerCase().includes(search.toLowerCase())
1258
+ );
1259
+ }
1260
+ }
1261
+
1262
+ const vm1 = new SharedCollectionVM(
1263
+ { search: '', statusFilter: 'all', loading: false, error: null },
1264
+ sharedCollection
1265
+ );
1266
+ const vm2 = new SharedCollectionVM(
1267
+ { search: '', statusFilter: 'all', loading: false, error: null },
1268
+ sharedCollection
1269
+ );
1270
+
1271
+ vm1.init();
1272
+ vm2.init();
1273
+
1274
+ const listener1 = vi.fn();
1275
+ const listener2 = vi.fn();
1276
+ vm1.subscribe(listener1);
1277
+ vm2.subscribe(listener2);
1278
+
1279
+ // Load data into shared collection
1280
+ sharedCollection.reset([
1281
+ { id: '3', name: 'Charlie', status: 'active' },
1282
+ ]);
1283
+
1284
+ expect(listener1).toHaveBeenCalledTimes(1);
1285
+ expect(listener2).toHaveBeenCalledTimes(1);
1286
+
1287
+ expect(vm1.filtered).toHaveLength(1);
1288
+ expect(vm2.filtered).toHaveLength(1);
1289
+ });
1290
+ });
1291
+
1292
+ // ============================================================================
1293
+ // Edge Cases
1294
+ // ============================================================================
1295
+
1296
+ describe(' Edge Cases', () => {
1297
+ it('getter with zero dependencies', () => {
1298
+ interface ZeroDepState {
1299
+ count: number;
1300
+ }
1301
+
1302
+ class ZeroDepVM extends ViewModel<ZeroDepState> {
1303
+ callCount = 0;
1304
+
1305
+ get constant(): string {
1306
+ this.callCount++;
1307
+ return 'always the same';
1308
+ }
1309
+
1310
+ setCount(count: number) {
1311
+ this.set({ count });
1312
+ }
1313
+ }
1314
+
1315
+ const vm = new ZeroDepVM({ count: 0 });
1316
+ vm.init();
1317
+
1318
+ const result1 = vm.constant;
1319
+ expect(result1).toBe('always the same');
1320
+ expect(vm.callCount).toBe(1);
1321
+
1322
+ // Change state - getter should NOT recompute (no dependencies)
1323
+ vm.setCount(10);
1324
+ const result2 = vm.constant;
1325
+ expect(result2).toBe(result1);
1326
+ expect(vm.callCount).toBe(1);
1327
+ });
1328
+
1329
+ it('getter that throws an error', () => {
1330
+ interface ErrorState {
1331
+ shouldThrow: boolean;
1332
+ }
1333
+
1334
+ class ErrorVM extends ViewModel<ErrorState> {
1335
+ callCount = 0;
1336
+
1337
+ get maybeThrow(): string {
1338
+ this.callCount++;
1339
+ if (this.state.shouldThrow) {
1340
+ throw new Error('getter error');
1341
+ }
1342
+ return 'ok';
1343
+ }
1344
+
1345
+ setThrow(shouldThrow: boolean) {
1346
+ this.set({ shouldThrow });
1347
+ }
1348
+ }
1349
+
1350
+ const vm = new ErrorVM({ shouldThrow: true });
1351
+ vm.init();
1352
+
1353
+ expect(() => vm.maybeThrow).toThrow('getter error');
1354
+ expect(vm.callCount).toBe(1);
1355
+
1356
+ // Cache NOT populated, next access re-throws
1357
+ expect(() => vm.maybeThrow).toThrow('getter error');
1358
+ expect(vm.callCount).toBe(2);
1359
+
1360
+ // Fix the state
1361
+ vm.setThrow(false);
1362
+ const result = vm.maybeThrow;
1363
+ expect(result).toBe('ok');
1364
+ expect(vm.callCount).toBe(3);
1365
+ });
1366
+
1367
+ it('getter accessing deeply nested collection data', () => {
1368
+ class DeepAccessVM extends ViewModel<{ label: string }> {
1369
+ collection = new TestCollection();
1370
+
1371
+ get firstName(): string | undefined {
1372
+ return this.collection.items[0]?.name;
1373
+ }
1374
+ }
1375
+
1376
+ const vm = new DeepAccessVM({ label: 'test' });
1377
+ vm.init();
1378
+
1379
+ // Empty collection
1380
+ const result1 = vm.firstName;
1381
+ expect(result1).toBeUndefined();
1382
+
1383
+ // Populate collection
1384
+ vm.collection.reset([{ id: '1', name: 'Alice' }]);
1385
+ const result2 = vm.firstName;
1386
+ expect(result2).toBe('Alice');
1387
+
1388
+ // Clear collection
1389
+ vm.collection.reset([]);
1390
+ const result3 = vm.firstName;
1391
+ expect(result3).toBeUndefined();
1392
+ });
1393
+
1394
+ it('rapid state changes within same synchronous frame', () => {
1395
+ const vm = new CountedFilterViewModel({
1396
+ search: '',
1397
+ statusFilter: 'all',
1398
+ loading: false,
1399
+ error: null,
1400
+ });
1401
+ vm.collection.reset([
1402
+ { id: '1', name: 'Alice', status: 'active' },
1403
+ { id: '2', name: 'Bob', status: 'inactive' },
1404
+ { id: '3', name: 'Alice Smith', status: 'active' },
1405
+ ]);
1406
+ vm.init();
1407
+
1408
+ // Multiple rapid changes
1409
+ vm.setSearch('a');
1410
+ vm.setSearch('al');
1411
+ vm.setSearch('ali');
1412
+
1413
+ // After all changes, getter reflects final state
1414
+ const result = vm.filtered;
1415
+ expect(result).toHaveLength(2); // Alice and Alice Smith
1416
+ expect(vm.filteredCallCount).toBe(1); // Computed once after all changes
1417
+ });
1418
+ });
1419
+
1420
+ // ============================================================================
1421
+ // Collection Data via Getters (without subscribeTo)
1422
+ // ============================================================================
1423
+
1424
+ describe('Collection Data via Getters (without subscribeTo)', () => {
1425
+ it('getters read collection data directly without subscribeTo or set()', () => {
1426
+ // This is the prescribed pattern: getters read from collection members
1427
+ // and auto-tracking handles reactivity. No subscribeTo() + set() needed.
1428
+ const vm = new CountedFilterViewModel({
1429
+ search: '',
1430
+ statusFilter: 'all',
1431
+ loading: false,
1432
+ error: null,
1433
+ });
1434
+ vm.init();
1435
+
1436
+ // Initially empty
1437
+ expect(vm.filtered).toEqual([]);
1438
+ expect(vm.total).toBe(0);
1439
+
1440
+ // Populate collection — getter recomputes via auto-tracked subscribable
1441
+ vm.collection.reset([
1442
+ { id: '1', name: 'Alice', status: 'active' },
1443
+ { id: '2', name: 'Bob', status: 'inactive' },
1444
+ ]);
1445
+ expect(vm.filtered).toHaveLength(2);
1446
+ expect(vm.total).toBe(2);
1447
+
1448
+ // Update collection — getter reflects new data
1449
+ vm.collection.update('1', { id: '1', name: 'Alice Updated', status: 'active' });
1450
+ expect(vm.filtered[0].name).toBe('Alice Updated');
1451
+
1452
+ // Filter via state — works with collection data
1453
+ vm.setSearch('bob');
1454
+ expect(vm.filtered).toHaveLength(1);
1455
+ expect(vm.filtered[0].name).toBe('Bob');
1456
+ });
1457
+
1458
+ it('subscriber notified on collection change without subscribeTo', () => {
1459
+ const vm = new FilterViewModel({
1460
+ search: '',
1461
+ statusFilter: 'all',
1462
+ loading: false,
1463
+ error: null,
1464
+ });
1465
+ vm.init();
1466
+
1467
+ const listener = vi.fn();
1468
+ vm.subscribe(listener);
1469
+
1470
+ // Collection change triggers notification to subscribers
1471
+ // (auto-tracking detects the subscribable and wires it up)
1472
+ vm.collection.reset([{ id: '1', name: 'Alice', status: 'active' }]);
1473
+ expect(listener).toHaveBeenCalledTimes(1);
1474
+
1475
+ // Subsequent changes also notify
1476
+ vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
1477
+ expect(listener).toHaveBeenCalledTimes(2);
1478
+ });
1479
+
1480
+ it('stateless ViewModel (empty state) reads all data from collections', () => {
1481
+ // Pattern: ViewModel with S = {} and all data via getters
1482
+ class StatelessDashboardVM extends ViewModel {
1483
+ collection1 = new TestCollection();
1484
+ collection2 = new TestCollection();
1485
+
1486
+ get totalItems(): number {
1487
+ return this.collection1.length + this.collection2.length;
1488
+ }
1489
+
1490
+ get allItems(): Item[] {
1491
+ return [...this.collection1.items, ...this.collection2.items];
1492
+ }
1493
+ }
1494
+
1495
+ const vm = new StatelessDashboardVM();
1496
+ vm.init();
1497
+
1498
+ expect(vm.totalItems).toBe(0);
1499
+ expect(vm.allItems).toEqual([]);
1500
+
1501
+ vm.collection1.reset([{ id: '1', name: 'Alice' }]);
1502
+ expect(vm.totalItems).toBe(1);
1503
+
1504
+ vm.collection2.reset([{ id: '2', name: 'Bob' }, { id: '3', name: 'Charlie' }]);
1505
+ expect(vm.totalItems).toBe(3);
1506
+ expect(vm.allItems).toHaveLength(3);
1507
+ });
1508
+
1509
+ it('draft mode: getter invalidates correctly on nested mutation', () => {
1510
+ interface ConfigState {
1511
+ config: { theme: string; fontSize: number };
1512
+ count: number;
1513
+ }
1514
+
1515
+ class ConfigVM extends ViewModel<ConfigState> {
1516
+ computeCount = 0;
1517
+
1518
+ get currentTheme() {
1519
+ this.computeCount++;
1520
+ return this.state.config.theme;
1521
+ }
1522
+
1523
+ setTheme(theme: string) {
1524
+ this.set((d) => { d.config.theme = theme; });
1525
+ }
1526
+
1527
+ bumpCount() {
1528
+ this.set((d) => { d.count = d.count + 1; });
1529
+ }
1530
+ }
1531
+
1532
+ const vm = new ConfigVM({ config: { theme: 'dark', fontSize: 14 }, count: 0 });
1533
+ vm.init();
1534
+
1535
+ // First access → Tier 3 recompute
1536
+ expect(vm.currentTheme).toBe('dark');
1537
+ const countAfterFirst = vm.computeCount;
1538
+
1539
+ // Same access → Tier 1 (no revision change)
1540
+ expect(vm.currentTheme).toBe('dark');
1541
+ expect(vm.computeCount).toBe(countAfterFirst); // No recompute
1542
+
1543
+ // Draft mutation on a dep of this getter
1544
+ vm.setTheme('light');
1545
+ expect(vm.currentTheme).toBe('light');
1546
+ expect(vm.computeCount).toBe(countAfterFirst + 1); // Recomputed
1547
+
1548
+ // Draft mutation on unrelated state — getter depends on config, not count
1549
+ vm.bumpCount();
1550
+ expect(vm.currentTheme).toBe('light');
1551
+ // Tier 2 should detect that config reference didn't change → no recompute
1552
+ expect(vm.computeCount).toBe(countAfterFirst + 1);
1553
+ });
1554
+
1555
+ it('smart-init pattern works: skip load when collection has data', () => {
1556
+ class SmartInitVM extends ViewModel {
1557
+ collection = new TestCollection();
1558
+ loadCalled = false;
1559
+
1560
+ protected onInit() {
1561
+ if (this.collection.length === 0) {
1562
+ this.loadCalled = true;
1563
+ }
1564
+ }
1565
+
1566
+ get items() {
1567
+ return this.collection.items
1568
+ }
1569
+ }
1570
+
1571
+ // Scenario 1: empty collection → load is triggered
1572
+ const vm1 = new SmartInitVM();
1573
+ vm1.init();
1574
+ expect(vm1.loadCalled).toBe(true);
1575
+
1576
+ // Scenario 2: pre-populated collection → load is skipped
1577
+ const vm2 = new SmartInitVM();
1578
+ vm2.collection.reset([{ id: '1', name: 'Alice' }]);
1579
+ vm2.init();
1580
+ expect(vm2.loadCalled).toBe(false);
1581
+ expect(vm2.items).toHaveLength(1);
1582
+ });
1583
+ });