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,813 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ViewModel } from './ViewModel';
3
+ import { HttpError } from './errors';
4
+
5
+ // ============================================================================
6
+ // Test Helpers
7
+ // ============================================================================
8
+
9
+ function defer<T = void>() {
10
+ let resolve!: (value: T) => void;
11
+ let reject!: (reason?: unknown) => void;
12
+ const promise = new Promise<T>((res, rej) => {
13
+ resolve = res;
14
+ reject = rej;
15
+ });
16
+ return { promise, resolve, reject };
17
+ }
18
+
19
+ function abortError(): DOMException {
20
+ return new DOMException('The operation was aborted', 'AbortError');
21
+ }
22
+
23
+ // ============================================================================
24
+ // Test Setup Classes
25
+ // ============================================================================
26
+
27
+ interface TestState {
28
+ count: number;
29
+ data: string | null;
30
+ }
31
+
32
+ class AsyncVM extends ViewModel<TestState> {
33
+ async fetchData(): Promise<string> {
34
+ const result = await Promise.resolve('data');
35
+ this.set({ data: result });
36
+ return result;
37
+ }
38
+
39
+ async failingMethod(): Promise<void> {
40
+ throw new Error('something went wrong');
41
+ }
42
+
43
+ async abortableMethod(signal?: AbortSignal): Promise<string> {
44
+ if (signal?.aborted) throw abortError();
45
+ return 'done';
46
+ }
47
+
48
+ syncMethod(): number {
49
+ return 42;
50
+ }
51
+ }
52
+
53
+ // ============================================================================
54
+ // Tests
55
+ // ============================================================================
56
+
57
+ describe('ViewModel async tracking', () => {
58
+ // ── Basic loading/error state ──────────────────────────────────
59
+
60
+ describe('basic loading/error tracking', () => {
61
+ it('starts with default TaskState { loading: false, error: null }', () => {
62
+ const vm = new AsyncVM({ count: 0, data: null });
63
+ vm.init();
64
+ expect(vm.async.fetchData).toEqual({ loading: false, error: null, errorCode: null });
65
+ });
66
+
67
+ it('sets loading: true when async method starts', async () => {
68
+ const d = defer<string>();
69
+
70
+ class VM extends ViewModel<TestState> {
71
+ async load(): Promise<string> {
72
+ return d.promise;
73
+ }
74
+ }
75
+
76
+ const vm = new VM({ count: 0, data: null });
77
+ vm.init();
78
+
79
+ const p = vm.load();
80
+ expect(vm.async.load).toEqual({ loading: true, error: null, errorCode: null });
81
+
82
+ d.resolve('ok');
83
+ await p;
84
+ expect(vm.async.load).toEqual({ loading: false, error: null, errorCode: null });
85
+ });
86
+
87
+ it('captures error message on rejection', async () => {
88
+ const vm = new AsyncVM({ count: 0, data: null });
89
+ vm.init();
90
+
91
+ await expect(vm.failingMethod()).rejects.toThrow('something went wrong');
92
+ expect(vm.async.failingMethod).toEqual({
93
+ loading: false,
94
+ error: 'something went wrong',
95
+ errorCode: 'unknown',
96
+ });
97
+ });
98
+
99
+ it('clears error on retry', async () => {
100
+ const d = defer<void>();
101
+ let shouldFail = true;
102
+
103
+ class VM extends ViewModel<TestState> {
104
+ async load(): Promise<void> {
105
+ if (shouldFail) throw new Error('fail');
106
+ return d.promise;
107
+ }
108
+ }
109
+
110
+ const vm = new VM({ count: 0, data: null });
111
+ vm.init();
112
+
113
+ // First call fails
114
+ await expect(vm.load()).rejects.toThrow('fail');
115
+ expect(vm.async.load.error).toBe('fail');
116
+
117
+ // Retry succeeds — error cleared
118
+ shouldFail = false;
119
+ const p = vm.load();
120
+ expect(vm.async.load).toEqual({ loading: true, error: null, errorCode: null });
121
+
122
+ d.resolve();
123
+ await p;
124
+ expect(vm.async.load).toEqual({ loading: false, error: null, errorCode: null });
125
+ });
126
+ });
127
+
128
+ // ── Return value preservation ──────────────────────────────────
129
+
130
+ describe('return value preservation', () => {
131
+ it('returns the resolved value on success', async () => {
132
+ const vm = new AsyncVM({ count: 0, data: null });
133
+ vm.init();
134
+
135
+ const result = await vm.fetchData();
136
+ expect(result).toBe('data');
137
+ });
138
+
139
+ it('re-throws the error on rejection', async () => {
140
+ const vm = new AsyncVM({ count: 0, data: null });
141
+ vm.init();
142
+
143
+ await expect(vm.failingMethod()).rejects.toThrow('something went wrong');
144
+ });
145
+
146
+ it('returns undefined for AbortError (swallowed)', async () => {
147
+ class VM extends ViewModel<TestState> {
148
+ async abortable(): Promise<string> {
149
+ throw abortError();
150
+ }
151
+ }
152
+
153
+ const vm = new VM({ count: 0, data: null });
154
+ vm.init();
155
+
156
+ const result = await vm.abortable();
157
+ expect(result).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ // ── Concurrent calls ──────────────────────────────────────────
162
+
163
+ describe('concurrent calls (counter-based loading)', () => {
164
+ it('stays loading until all concurrent calls resolve', async () => {
165
+ const d1 = defer<void>();
166
+ const d2 = defer<void>();
167
+ let callCount = 0;
168
+
169
+ class VM extends ViewModel<TestState> {
170
+ async load(): Promise<void> {
171
+ callCount++;
172
+ if (callCount === 1) return d1.promise;
173
+ return d2.promise;
174
+ }
175
+ }
176
+
177
+ const vm = new VM({ count: 0, data: null });
178
+ vm.init();
179
+
180
+ const p1 = vm.load();
181
+ const p2 = vm.load();
182
+
183
+ expect(vm.async.load.loading).toBe(true);
184
+
185
+ d1.resolve();
186
+ await p1;
187
+ // Still loading because p2 is pending
188
+ expect(vm.async.load.loading).toBe(true);
189
+
190
+ d2.resolve();
191
+ await p2;
192
+ expect(vm.async.load.loading).toBe(false);
193
+ });
194
+ });
195
+
196
+ // ── AbortError swallowing ─────────────────────────────────────
197
+
198
+ describe('AbortError swallowing', () => {
199
+ it('does not set error for AbortError', async () => {
200
+ class VM extends ViewModel<TestState> {
201
+ async load(): Promise<void> {
202
+ throw abortError();
203
+ }
204
+ }
205
+
206
+ const vm = new VM({ count: 0, data: null });
207
+ vm.init();
208
+
209
+ await vm.load();
210
+ expect(vm.async.load.error).toBeNull();
211
+ expect(vm.async.load.loading).toBe(false);
212
+ });
213
+
214
+ it('does not re-throw AbortError', async () => {
215
+ class VM extends ViewModel<TestState> {
216
+ async load(): Promise<void> {
217
+ throw abortError();
218
+ }
219
+ }
220
+
221
+ const vm = new VM({ count: 0, data: null });
222
+ vm.init();
223
+
224
+ // Should not reject
225
+ await expect(vm.load()).resolves.toBeUndefined();
226
+ });
227
+ });
228
+
229
+ // ── Sync method pruning ───────────────────────────────────────
230
+
231
+ describe('sync method pruning', () => {
232
+ it('prunes sync methods on first call', () => {
233
+ const vm = new AsyncVM({ count: 0, data: null });
234
+ vm.init();
235
+
236
+ const result = vm.syncMethod();
237
+ expect(result).toBe(42);
238
+
239
+ // After pruning, method is replaced with bound original
240
+ expect('syncMethod' in vm.async).toBe(false);
241
+ });
242
+
243
+ it('pruned method still works correctly on subsequent calls', () => {
244
+ const vm = new AsyncVM({ count: 0, data: null });
245
+ vm.init();
246
+
247
+ vm.syncMethod(); // first call prunes
248
+ expect(vm.syncMethod()).toBe(42); // subsequent call
249
+ expect(vm.syncMethod()).toBe(42); // another
250
+ });
251
+ });
252
+
253
+ // ── subscribeAsync ────────────────────────────────────────────
254
+
255
+ describe('subscribeAsync notifications', () => {
256
+ it('notifies on loading start and end', async () => {
257
+ const listener = vi.fn();
258
+ const vm = new AsyncVM({ count: 0, data: null });
259
+ vm.init();
260
+ vm.subscribeAsync(listener);
261
+
262
+ await vm.fetchData();
263
+ // Called at least twice: loading start + loading end
264
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
265
+ });
266
+
267
+ it('unsubscribe stops notifications', async () => {
268
+ const listener = vi.fn();
269
+ const vm = new AsyncVM({ count: 0, data: null });
270
+ vm.init();
271
+ const unsub = vm.subscribeAsync(listener);
272
+
273
+ unsub();
274
+ await vm.fetchData();
275
+ expect(listener).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it('returns no-op if disposed', () => {
279
+ const vm = new AsyncVM({ count: 0, data: null });
280
+ vm.init();
281
+ vm.dispose();
282
+ const unsub = vm.subscribeAsync(() => {});
283
+ expect(typeof unsub).toBe('function');
284
+ expect(() => unsub()).not.toThrow();
285
+ });
286
+ });
287
+
288
+ // ── async proxy ───────────────────────────────────────────────
289
+
290
+ describe('async proxy', () => {
291
+ it('returns frozen TaskState snapshots', async () => {
292
+ const vm = new AsyncVM({ count: 0, data: null });
293
+ vm.init();
294
+
295
+ await vm.fetchData();
296
+ const state = vm.async.fetchData;
297
+ expect(Object.isFrozen(state)).toBe(true);
298
+ });
299
+
300
+ it('returns DEFAULT_TASK_STATE for unknown keys', () => {
301
+ const vm = new AsyncVM({ count: 0, data: null });
302
+ vm.init();
303
+ expect((vm.async as any).nonExistent).toEqual({
304
+ loading: false,
305
+ error: null,
306
+ errorCode: null,
307
+ });
308
+ });
309
+
310
+ it('Object.keys returns tracked methods', async () => {
311
+ const d = defer<string>();
312
+
313
+ class VM extends ViewModel<TestState> {
314
+ async load(): Promise<string> { return d.promise; }
315
+ }
316
+
317
+ const vm = new VM({ count: 0, data: null });
318
+ vm.init();
319
+
320
+ const p = vm.load();
321
+ expect(Object.keys(vm.async)).toContain('load');
322
+
323
+ d.resolve('ok');
324
+ await p;
325
+ });
326
+
327
+ it('"in" operator works for tracked methods', async () => {
328
+ const d = defer<string>();
329
+
330
+ class VM extends ViewModel<TestState> {
331
+ async load(): Promise<string> { return d.promise; }
332
+ }
333
+
334
+ const vm = new VM({ count: 0, data: null });
335
+ vm.init();
336
+
337
+ const p = vm.load();
338
+ expect('load' in vm.async).toBe(true);
339
+
340
+ d.resolve('ok');
341
+ await p;
342
+ });
343
+
344
+ it('snapshot reference changes on each state update', async () => {
345
+ const d = defer<void>();
346
+
347
+ class VM extends ViewModel<TestState> {
348
+ async load(): Promise<void> { return d.promise; }
349
+ }
350
+
351
+ const vm = new VM({ count: 0, data: null });
352
+ vm.init();
353
+
354
+ const before = vm.async.load;
355
+ const p = vm.load();
356
+ const during = vm.async.load;
357
+ expect(during).not.toBe(before);
358
+
359
+ d.resolve();
360
+ await p;
361
+ const after = vm.async.load;
362
+ expect(after).not.toBe(during);
363
+ });
364
+ });
365
+
366
+ // ── Disposal ──────────────────────────────────────────────────
367
+
368
+ describe('disposal', () => {
369
+ it('methods become no-ops after dispose', () => {
370
+ const vm = new AsyncVM({ count: 0, data: null });
371
+ vm.init();
372
+ vm.dispose();
373
+
374
+ const result = vm.fetchData();
375
+ expect(result).toBeUndefined();
376
+ });
377
+
378
+ it('in-flight work fizzles — no state updates after dispose', async () => {
379
+ const d = defer<void>();
380
+
381
+ class VM extends ViewModel<TestState> {
382
+ async load(): Promise<void> {
383
+ await d.promise;
384
+ this.set({ data: 'loaded' });
385
+ }
386
+ }
387
+
388
+ const vm = new VM({ count: 0, data: null });
389
+ vm.init();
390
+
391
+ const p = vm.load();
392
+
393
+ // Dispose while in-flight
394
+ vm.dispose();
395
+
396
+ d.resolve();
397
+ await p.catch(() => {}); // may reject or resolve
398
+
399
+ expect(vm.state.data).toBeNull(); // no state change
400
+ });
401
+
402
+ it('subscribeAsync listeners cleared on dispose', async () => {
403
+ const listener = vi.fn();
404
+ const d = defer<void>();
405
+
406
+ class VM extends ViewModel<TestState> {
407
+ async load(): Promise<void> { return d.promise; }
408
+ }
409
+
410
+ const vm = new VM({ count: 0, data: null });
411
+ vm.init();
412
+ vm.subscribeAsync(listener);
413
+
414
+ const p = vm.load();
415
+ listener.mockClear();
416
+
417
+ vm.dispose();
418
+ d.resolve();
419
+ await p.catch(() => {});
420
+
421
+ expect(listener).not.toHaveBeenCalled();
422
+ });
423
+ });
424
+
425
+ // ── Lifecycle guards ──────────────────────────────────────────
426
+
427
+ describe('lifecycle guards (DEV)', () => {
428
+ it('warns when calling method after dispose', () => {
429
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
430
+
431
+ const vm = new AsyncVM({ count: 0, data: null });
432
+ vm.init();
433
+ vm.dispose();
434
+
435
+ vm.fetchData();
436
+
437
+ expect(warnSpy).toHaveBeenCalledWith(
438
+ expect.stringContaining('"fetchData" called after dispose')
439
+ );
440
+
441
+ warnSpy.mockRestore();
442
+ });
443
+
444
+ it('warns when calling method before init', () => {
445
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
446
+
447
+ // Need to manually wrap since _wrapMethods runs in init
448
+ // Pre-init warning only fires for methods on VMs that have been
449
+ // constructed but not yet init'd — since _wrapMethods() runs in init(),
450
+ // this path is only reachable if someone calls init(), then resets _initialized
451
+ // Actually, since wrapping happens in init(), pre-init calls go to unwrapped methods.
452
+ // The guard is for the scenario where init() runs but _initialized flag is somehow false.
453
+ // In practice, this is a safety net. Let's verify wrapping only happens after init.
454
+
455
+ const vm = new AsyncVM({ count: 0, data: null });
456
+ // Before init, methods are NOT wrapped — no warning
457
+ const result = vm.syncMethod();
458
+ expect(result).toBe(42);
459
+
460
+ warnSpy.mockRestore();
461
+ });
462
+ });
463
+
464
+ // ── Reserved key guard ────────────────────────────────────────
465
+
466
+ describe('reserved key guard', () => {
467
+ it('throws when subclass defines "async" method on prototype', () => {
468
+ expect(() => {
469
+ class BadVM extends ViewModel<TestState> {
470
+ // @ts-expect-error
471
+ async() { return 'bad'; }
472
+ }
473
+ new BadVM({ count: 0, data: null });
474
+ }).toThrow('"async" is a reserved property');
475
+ });
476
+
477
+ it('throws when subclass defines "subscribeAsync" method on prototype', () => {
478
+ expect(() => {
479
+ class BadVM extends ViewModel<TestState> {
480
+ subscribeAsync() { return () => {}; }
481
+ }
482
+ new BadVM({ count: 0, data: null });
483
+ }).toThrow('"subscribeAsync" is a reserved property');
484
+ });
485
+
486
+ it('throws when subclass defines "async" as class field', () => {
487
+ class BadVM extends ViewModel<TestState> {
488
+ // @ts-expect-error
489
+ async = 'bad';
490
+ }
491
+ const vm = new BadVM({ count: 0, data: null });
492
+ expect(() => vm.init()).toThrow('"async" is a reserved property');
493
+ });
494
+ });
495
+
496
+ // ── Inheritance ───────────────────────────────────────────────
497
+
498
+ describe('inheritance', () => {
499
+ it('tracks methods from parent and child classes', async () => {
500
+ class ParentVM extends ViewModel<TestState> {
501
+ async parentLoad(): Promise<string> {
502
+ return 'parent';
503
+ }
504
+ }
505
+
506
+ class ChildVM extends ParentVM {
507
+ async childLoad(): Promise<string> {
508
+ return 'child';
509
+ }
510
+ }
511
+
512
+ const vm = new ChildVM({ count: 0, data: null });
513
+ vm.init();
514
+
515
+ await vm.parentLoad();
516
+ await vm.childLoad();
517
+
518
+ // Both are tracked
519
+ expect(vm.async.parentLoad).toEqual({ loading: false, error: null, errorCode: null });
520
+ expect(vm.async.childLoad).toEqual({ loading: false, error: null, errorCode: null });
521
+ });
522
+
523
+ it('child override wins (most-derived)', async () => {
524
+ class ParentVM extends ViewModel<TestState> {
525
+ async load(): Promise<string> {
526
+ return 'parent';
527
+ }
528
+ }
529
+
530
+ class ChildVM extends ParentVM {
531
+ async load(): Promise<string> {
532
+ return 'child';
533
+ }
534
+ }
535
+
536
+ const vm = new ChildVM({ count: 0, data: null });
537
+ vm.init();
538
+
539
+ const result = await vm.load();
540
+ expect(result).toBe('child');
541
+ });
542
+
543
+ it('does not track base ViewModel methods', () => {
544
+ const vm = new AsyncVM({ count: 0, data: null });
545
+ vm.init();
546
+
547
+ // init, dispose, subscribe, etc. should not be in async proxy
548
+ expect('init' in vm.async).toBe(false);
549
+ expect('dispose' in vm.async).toBe(false);
550
+ expect('subscribe' in vm.async).toBe(false);
551
+ });
552
+ });
553
+
554
+ // ── Ghost detection (DEV, fake timers) ────────────────────────
555
+
556
+ describe('ghost detection', () => {
557
+ beforeEach(() => {
558
+ vi.useFakeTimers();
559
+ });
560
+
561
+ afterEach(() => {
562
+ vi.useRealTimers();
563
+ });
564
+
565
+ it('warns about pending operations after GHOST_TIMEOUT', async () => {
566
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
567
+ const d = defer<void>();
568
+
569
+ class VM extends ViewModel<TestState> {
570
+ async load(): Promise<void> {
571
+ return d.promise;
572
+ }
573
+ }
574
+
575
+ const vm = new VM({ count: 0, data: null });
576
+ vm.init();
577
+
578
+ vm.load(); // start but don't resolve
579
+
580
+ vm.dispose();
581
+
582
+ // Ghost check not yet fired
583
+ expect(warnSpy).not.toHaveBeenCalledWith(
584
+ expect.stringContaining('Ghost async operation')
585
+ );
586
+
587
+ vi.advanceTimersByTime(3000);
588
+
589
+ expect(warnSpy).toHaveBeenCalledWith(
590
+ expect.stringContaining('Ghost async operation detected: "load"')
591
+ );
592
+
593
+ warnSpy.mockRestore();
594
+ d.resolve(); // cleanup
595
+ });
596
+
597
+ it('respects custom GHOST_TIMEOUT', () => {
598
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
599
+ const d = defer<void>();
600
+
601
+ class VM extends ViewModel<TestState> {
602
+ static override GHOST_TIMEOUT = 5000;
603
+ async load(): Promise<void> { return d.promise; }
604
+ }
605
+
606
+ const vm = new VM({ count: 0, data: null });
607
+ vm.init();
608
+ vm.load();
609
+ vm.dispose();
610
+
611
+ vi.advanceTimersByTime(3000);
612
+ expect(warnSpy).not.toHaveBeenCalledWith(
613
+ expect.stringContaining('Ghost')
614
+ );
615
+
616
+ vi.advanceTimersByTime(2000);
617
+ expect(warnSpy).toHaveBeenCalledWith(
618
+ expect.stringContaining('Ghost async operation detected: "load"')
619
+ );
620
+
621
+ warnSpy.mockRestore();
622
+ d.resolve();
623
+ });
624
+ });
625
+
626
+ // ── Integration with reactive derived state management ─────────────────────────
627
+
628
+ describe('integration with reactive derived state management', () => {
629
+ it('async method calling set() triggers getter invalidation', async () => {
630
+ class VM extends ViewModel<TestState> {
631
+ get uppercaseData(): string | null {
632
+ return this.state.data?.toUpperCase() ?? null;
633
+ }
634
+
635
+ async loadData(): Promise<void> {
636
+ this.set({ data: 'hello' });
637
+ }
638
+ }
639
+
640
+ const vm = new VM({ count: 0, data: null });
641
+ vm.init();
642
+
643
+ expect(vm.uppercaseData).toBeNull();
644
+
645
+ await vm.loadData();
646
+ expect(vm.uppercaseData).toBe('HELLO');
647
+ });
648
+
649
+ it('_notifyAsync does NOT bump _revision (getters not invalidated by async state)', async () => {
650
+ let computeCount = 0;
651
+
652
+ class VM extends ViewModel<TestState> {
653
+ get derived(): number {
654
+ computeCount++;
655
+ return this.state.count * 2;
656
+ }
657
+
658
+ async load(): Promise<void> {
659
+ await Promise.resolve();
660
+ }
661
+ }
662
+
663
+ const vm = new VM({ count: 0, data: null });
664
+ vm.init();
665
+
666
+ // Prime the getter cache
667
+ expect(vm.derived).toBe(0);
668
+ computeCount = 0;
669
+
670
+ // Async operation — should NOT recompute getter
671
+ await vm.load();
672
+
673
+ expect(vm.derived).toBe(0);
674
+ expect(computeCount).toBe(0); // cached, not recomputed
675
+ });
676
+ });
677
+
678
+ // ── set() no-op after dispose ─────────────────────────────────
679
+
680
+ describe('set() after dispose', () => {
681
+ it('is a no-op (does not throw)', () => {
682
+ class VM extends ViewModel<TestState> {
683
+ update() {
684
+ this.set({ count: 99 });
685
+ }
686
+ }
687
+
688
+ const vm = new VM({ count: 0, data: null });
689
+ vm.dispose();
690
+ expect(() => vm.update()).not.toThrow();
691
+ expect(vm.state.count).toBe(0);
692
+ });
693
+ });
694
+
695
+ // ── Non-Error rejection ───────────────────────────────────────
696
+
697
+ describe('non-Error rejection', () => {
698
+ it('captures string error', async () => {
699
+ class VM extends ViewModel<TestState> {
700
+ async load(): Promise<void> {
701
+ throw 'string error';
702
+ }
703
+ }
704
+
705
+ const vm = new VM({ count: 0, data: null });
706
+ vm.init();
707
+
708
+ await vm.load().catch(() => {});
709
+ expect(vm.async.load.error).toBe('string error');
710
+ expect(vm.async.load.errorCode).toBe('unknown');
711
+ });
712
+ });
713
+
714
+ // ── Error classification ───────────────────────────────────────
715
+
716
+ describe('error classification (errorCode)', () => {
717
+ it('classifies HttpError(401) as unauthorized', async () => {
718
+ class VM extends ViewModel<TestState> {
719
+ async load(): Promise<void> {
720
+ throw new HttpError(401, 'Unauthorized');
721
+ }
722
+ }
723
+
724
+ const vm = new VM({ count: 0, data: null });
725
+ vm.init();
726
+
727
+ await vm.load().catch(() => {});
728
+ expect(vm.async.load.error).toBe('Unauthorized');
729
+ expect(vm.async.load.errorCode).toBe('unauthorized');
730
+ });
731
+
732
+ it('classifies HttpError(500) as server_error', async () => {
733
+ class VM extends ViewModel<TestState> {
734
+ async load(): Promise<void> {
735
+ throw new HttpError(500, 'Internal Server Error');
736
+ }
737
+ }
738
+
739
+ const vm = new VM({ count: 0, data: null });
740
+ vm.init();
741
+
742
+ await vm.load().catch(() => {});
743
+ expect(vm.async.load.error).toBe('Internal Server Error');
744
+ expect(vm.async.load.errorCode).toBe('server_error');
745
+ });
746
+
747
+ it('classifies TypeError("Failed to fetch") as network', async () => {
748
+ class VM extends ViewModel<TestState> {
749
+ async load(): Promise<void> {
750
+ throw new TypeError('Failed to fetch');
751
+ }
752
+ }
753
+
754
+ const vm = new VM({ count: 0, data: null });
755
+ vm.init();
756
+
757
+ await vm.load().catch(() => {});
758
+ expect(vm.async.load.error).toBe('Failed to fetch');
759
+ expect(vm.async.load.errorCode).toBe('network');
760
+ });
761
+
762
+ it('classifies generic Error as unknown', async () => {
763
+ class VM extends ViewModel<TestState> {
764
+ async load(): Promise<void> {
765
+ throw new Error('something broke');
766
+ }
767
+ }
768
+
769
+ const vm = new VM({ count: 0, data: null });
770
+ vm.init();
771
+
772
+ await vm.load().catch(() => {});
773
+ expect(vm.async.load.error).toBe('something broke');
774
+ expect(vm.async.load.errorCode).toBe('unknown');
775
+ });
776
+
777
+ it('classifies non-Error string as unknown', async () => {
778
+ class VM extends ViewModel<TestState> {
779
+ async load(): Promise<void> {
780
+ throw 'raw string';
781
+ }
782
+ }
783
+
784
+ const vm = new VM({ count: 0, data: null });
785
+ vm.init();
786
+
787
+ await vm.load().catch(() => {});
788
+ expect(vm.async.load.error).toBe('raw string');
789
+ expect(vm.async.load.errorCode).toBe('unknown');
790
+ });
791
+
792
+ it('clears errorCode on successful retry', async () => {
793
+ let shouldFail = true;
794
+
795
+ class VM extends ViewModel<TestState> {
796
+ async load(): Promise<void> {
797
+ if (shouldFail) throw new HttpError(500, 'Server Error');
798
+ }
799
+ }
800
+
801
+ const vm = new VM({ count: 0, data: null });
802
+ vm.init();
803
+
804
+ await vm.load().catch(() => {});
805
+ expect(vm.async.load.errorCode).toBe('server_error');
806
+
807
+ shouldFail = false;
808
+ await vm.load();
809
+ expect(vm.async.load.errorCode).toBeNull();
810
+ expect(vm.async.load.error).toBeNull();
811
+ });
812
+ });
813
+ });