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,786 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Resource } from './Resource';
3
+ import { Collection } from './Collection';
4
+ import { HttpError } from './errors';
5
+ import { singleton, teardownAll } from './singleton';
6
+
7
+ type Item = { id: number; name: string };
8
+
9
+ const alice: Item = { id: 1, name: 'Alice' };
10
+ const bob: Item = { id: 2, name: 'Bob' };
11
+ const charlie: Item = { id: 3, name: 'Charlie' };
12
+
13
+ beforeEach(() => {
14
+ teardownAll();
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ // ── Category A: Basic Collection Behavior (no injection) ──────
19
+
20
+ describe('Resource — basic Collection behavior', () => {
21
+ class TestResource extends Resource<Item> {}
22
+
23
+ it('starts empty by default', () => {
24
+ const r = new TestResource();
25
+ expect(r.items).toEqual([]);
26
+ expect(r.length).toBe(0);
27
+ });
28
+
29
+ it('accepts initial items array', () => {
30
+ const r = new TestResource([alice, bob]);
31
+ expect(r.items).toEqual([alice, bob]);
32
+ expect(r.length).toBe(2);
33
+ });
34
+
35
+ it('add works', () => {
36
+ const r = new TestResource();
37
+ r.add(alice);
38
+ expect(r.items).toEqual([alice]);
39
+ r.add(bob, charlie);
40
+ expect(r.items).toEqual([alice, bob, charlie]);
41
+ });
42
+
43
+ it('upsert works', () => {
44
+ const r = new TestResource([alice]);
45
+ const updated = { id: 1, name: 'Alice Updated' };
46
+ r.upsert(updated, bob);
47
+ expect(r.items).toEqual([updated, bob]);
48
+ });
49
+
50
+ it('update works', () => {
51
+ const r = new TestResource([alice]);
52
+ r.update(1, { name: 'Updated' });
53
+ expect(r.get(1)?.name).toBe('Updated');
54
+ });
55
+
56
+ it('remove works', () => {
57
+ const r = new TestResource([alice, bob]);
58
+ r.remove(1);
59
+ expect(r.items).toEqual([bob]);
60
+ });
61
+
62
+ it('reset works', () => {
63
+ const r = new TestResource([alice]);
64
+ r.reset([bob, charlie]);
65
+ expect(r.items).toEqual([bob, charlie]);
66
+ });
67
+
68
+ it('clear works', () => {
69
+ const r = new TestResource([alice, bob]);
70
+ r.clear();
71
+ expect(r.items).toEqual([]);
72
+ });
73
+
74
+ it('get/has work', () => {
75
+ const r = new TestResource([alice]);
76
+ expect(r.get(1)).toBe(alice);
77
+ expect(r.has(1)).toBe(true);
78
+ expect(r.has(99)).toBe(false);
79
+ });
80
+
81
+ it('find/filter/sorted/map work', () => {
82
+ const r = new TestResource([bob, alice]);
83
+ expect(r.find(i => i.id === 1)).toBe(alice);
84
+ expect(r.filter(i => i.id > 1)).toEqual([bob]);
85
+ expect(r.sorted((a, b) => a.id - b.id)).toEqual([alice, bob]);
86
+ expect(r.map(i => i.name)).toEqual(['Bob', 'Alice']);
87
+ });
88
+
89
+ it('subscribe fires on mutations', () => {
90
+ const r = new TestResource();
91
+ const listener = vi.fn();
92
+ r.subscribe(listener);
93
+ r.add(alice);
94
+ expect(listener).toHaveBeenCalledOnce();
95
+ });
96
+
97
+ it('state is alias for items', () => {
98
+ const r = new TestResource([alice]);
99
+ expect(r.state).toBe(r.items);
100
+ });
101
+
102
+ it('dispose cleans up', () => {
103
+ const r = new TestResource();
104
+ r.dispose();
105
+ expect(r.disposed).toBe(true);
106
+ expect(() => r.add(alice)).toThrow();
107
+ });
108
+
109
+ it('optimistic works', () => {
110
+ const r = new TestResource([alice]);
111
+ const rollback = r.optimistic(() => {
112
+ r.remove(1);
113
+ });
114
+ expect(r.items).toEqual([]);
115
+ rollback();
116
+ expect(r.items).toEqual([alice]);
117
+ });
118
+
119
+ it('MAX_SIZE inherited from Collection works', () => {
120
+ class LimitedResource extends Resource<Item> {
121
+ static MAX_SIZE = 2;
122
+ }
123
+ const r = new LimitedResource([alice, bob, charlie]);
124
+ // FIFO eviction: alice evicted
125
+ expect(r.length).toBe(2);
126
+ expect(r.has(1)).toBe(false);
127
+ });
128
+
129
+ it('TTL inherited from Collection works', async () => {
130
+ class ExpiringResource extends Resource<Item> {
131
+ static TTL = 50;
132
+ }
133
+ const r = new ExpiringResource([alice]);
134
+ expect(r.length).toBe(1);
135
+ await new Promise(resolve => setTimeout(resolve, 100));
136
+ expect(r.length).toBe(0);
137
+ r.dispose();
138
+ });
139
+ });
140
+
141
+ // ── Category B: External Collection Injection ─────────────────
142
+
143
+ describe('Resource — external Collection injection', () => {
144
+ it('constructor accepts Collection instance', () => {
145
+ const col = new Collection<Item>([alice, bob]);
146
+ class TestResource extends Resource<Item> {}
147
+ const r = new TestResource(col);
148
+ expect(r.items).toEqual([alice, bob]);
149
+ });
150
+
151
+ it('items/state/length read from external', () => {
152
+ const col = new Collection<Item>([alice]);
153
+ class TestResource extends Resource<Item> {}
154
+ const r = new TestResource(col);
155
+ expect(r.items).toBe(col.items);
156
+ expect(r.state).toBe(col.state);
157
+ expect(r.length).toBe(col.length);
158
+ });
159
+
160
+ it('mutation methods mutate the external collection', () => {
161
+ const col = new Collection<Item>();
162
+ class TestResource extends Resource<Item> {}
163
+ const r = new TestResource(col);
164
+
165
+ r.add(alice);
166
+ expect(col.items).toEqual([alice]);
167
+
168
+ r.upsert({ id: 1, name: 'Updated' });
169
+ expect(col.get(1)?.name).toBe('Updated');
170
+
171
+ r.add(bob);
172
+ r.update(2, { name: 'Bob Updated' });
173
+ expect(col.get(2)?.name).toBe('Bob Updated');
174
+
175
+ r.remove(1);
176
+ expect(col.has(1)).toBe(false);
177
+
178
+ r.reset([charlie]);
179
+ expect(col.items).toEqual([charlie]);
180
+
181
+ r.clear();
182
+ expect(col.items).toEqual([]);
183
+ });
184
+
185
+ it('query methods read from external', () => {
186
+ const col = new Collection<Item>([alice, bob]);
187
+ class TestResource extends Resource<Item> {}
188
+ const r = new TestResource(col);
189
+
190
+ expect(r.get(1)).toBe(col.get(1));
191
+ expect(r.has(1)).toBe(true);
192
+ expect(r.find(i => i.id === 2)).toBe(bob);
193
+ expect(r.filter(i => i.id > 0)).toEqual(col.filter(i => i.id > 0));
194
+ expect(r.sorted((a, b) => a.id - b.id)).toEqual(col.sorted((a, b) => a.id - b.id));
195
+ expect(r.map(i => i.name)).toEqual(col.map(i => i.name));
196
+ });
197
+
198
+ it('subscribe delegates to external', () => {
199
+ const col = new Collection<Item>();
200
+ class TestResource extends Resource<Item> {}
201
+ const r = new TestResource(col);
202
+ const listener = vi.fn();
203
+ r.subscribe(listener);
204
+ col.add(alice);
205
+ expect(listener).toHaveBeenCalledOnce();
206
+ });
207
+
208
+ it('dispose does NOT dispose external collection', () => {
209
+ const col = new Collection<Item>([alice]);
210
+ class TestResource extends Resource<Item> {}
211
+ const r = new TestResource(col);
212
+ r.dispose();
213
+ expect(r.disposed).toBe(true);
214
+ expect(col.disposed).toBe(false);
215
+ expect(col.items).toEqual([alice]);
216
+ // External collection still works after resource disposal
217
+ col.add(bob);
218
+ expect(col.length).toBe(2);
219
+ });
220
+
221
+ it('external collection changes are visible through Resource getters', () => {
222
+ const col = new Collection<Item>();
223
+ class TestResource extends Resource<Item> {}
224
+ const r = new TestResource(col);
225
+ col.add(alice);
226
+ expect(r.items).toEqual([alice]);
227
+ expect(r.length).toBe(1);
228
+ });
229
+
230
+ it('optimistic delegates to external', () => {
231
+ const col = new Collection<Item>([alice]);
232
+ class TestResource extends Resource<Item> {}
233
+ const r = new TestResource(col);
234
+ const rollback = r.optimistic(() => {
235
+ r.remove(1);
236
+ });
237
+ expect(col.items).toEqual([]);
238
+ rollback();
239
+ expect(col.items).toEqual([alice]);
240
+ });
241
+
242
+ it('DEV warning when MAX_SIZE/TTL set with external collection', () => {
243
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
244
+ const col = new Collection<Item>();
245
+ class BadResource extends Resource<Item> {
246
+ static MAX_SIZE = 10;
247
+ }
248
+ new BadResource(col);
249
+ expect(warn).toHaveBeenCalledWith(
250
+ expect.stringContaining('MAX_SIZE or TTL set but uses an injected Collection')
251
+ );
252
+ });
253
+
254
+ it('subscribe returns no-op after resource disposal', () => {
255
+ const col = new Collection<Item>();
256
+ class TestResource extends Resource<Item> {}
257
+ const r = new TestResource(col);
258
+ r.dispose();
259
+ const unsub = r.subscribe(vi.fn());
260
+ expect(typeof unsub).toBe('function');
261
+ // Should be no-op, no error
262
+ unsub();
263
+ });
264
+ });
265
+
266
+ // ── Category C: Lifecycle ─────────────────────────────────────
267
+
268
+ describe('Resource — lifecycle', () => {
269
+ it('init() calls onInit()', async () => {
270
+ const initSpy = vi.fn();
271
+ class TestResource extends Resource<Item> {
272
+ onInit() { initSpy(); }
273
+ }
274
+ const r = new TestResource();
275
+ r.init();
276
+ expect(initSpy).toHaveBeenCalledOnce();
277
+ });
278
+
279
+ it('init() supports async onInit()', async () => {
280
+ let loaded = false;
281
+ class TestResource extends Resource<Item> {
282
+ async onInit() {
283
+ await Promise.resolve();
284
+ loaded = true;
285
+ }
286
+ }
287
+ const r = new TestResource();
288
+ await r.init();
289
+ expect(loaded).toBe(true);
290
+ });
291
+
292
+ it('init() is idempotent', () => {
293
+ const initSpy = vi.fn();
294
+ class TestResource extends Resource<Item> {
295
+ onInit() { initSpy(); }
296
+ }
297
+ const r = new TestResource();
298
+ r.init();
299
+ r.init();
300
+ expect(initSpy).toHaveBeenCalledOnce();
301
+ });
302
+
303
+ it('dispose() calls onDispose()', () => {
304
+ const disposeSpy = vi.fn();
305
+ class TestResource extends Resource<Item> {
306
+ onDispose() { disposeSpy(); }
307
+ }
308
+ const r = new TestResource();
309
+ r.dispose();
310
+ expect(disposeSpy).toHaveBeenCalledOnce();
311
+ });
312
+
313
+ it('dispose() is idempotent', () => {
314
+ const disposeSpy = vi.fn();
315
+ class TestResource extends Resource<Item> {
316
+ onDispose() { disposeSpy(); }
317
+ }
318
+ const r = new TestResource();
319
+ r.dispose();
320
+ r.dispose();
321
+ expect(disposeSpy).toHaveBeenCalledOnce();
322
+ });
323
+
324
+ it('disposeSignal fires on dispose', () => {
325
+ const r = new (class extends Resource<Item> {})();
326
+ const abortListener = vi.fn();
327
+ r.disposeSignal.addEventListener('abort', abortListener);
328
+ r.dispose();
329
+ expect(abortListener).toHaveBeenCalledOnce();
330
+ });
331
+
332
+ it('addCleanup functions run on dispose', () => {
333
+ const cleanup = vi.fn();
334
+ class TestResource extends Resource<Item> {
335
+ onInit() {
336
+ this.addCleanup(cleanup);
337
+ }
338
+ }
339
+ const r = new TestResource();
340
+ r.init();
341
+ r.dispose();
342
+ expect(cleanup).toHaveBeenCalledOnce();
343
+ });
344
+
345
+ it('initialized returns false before init, true after', () => {
346
+ const r = new (class extends Resource<Item> {})();
347
+ expect(r.initialized).toBe(false);
348
+ r.init();
349
+ expect(r.initialized).toBe(true);
350
+ });
351
+
352
+ it('init() no-ops after dispose', () => {
353
+ const initSpy = vi.fn();
354
+ class TestResource extends Resource<Item> {
355
+ onInit() { initSpy(); }
356
+ }
357
+ const r = new TestResource();
358
+ r.dispose();
359
+ r.init();
360
+ expect(initSpy).not.toHaveBeenCalled();
361
+ });
362
+ });
363
+
364
+ // ── Category D: Async Tracking ────────────────────────────────
365
+
366
+ describe('Resource — async tracking', () => {
367
+ it('methods get loading/error tracking after init()', async () => {
368
+ let resolve!: () => void;
369
+ class TestResource extends Resource<Item> {
370
+ async load() {
371
+ await new Promise<void>(r => { resolve = r; });
372
+ this.reset([alice]);
373
+ }
374
+ }
375
+ const r = new TestResource();
376
+ r.init();
377
+
378
+ expect(r.async.load.loading).toBe(false);
379
+ const p = r.load();
380
+ expect(r.async.load.loading).toBe(true);
381
+ resolve();
382
+ await p;
383
+ expect(r.async.load.loading).toBe(false);
384
+ expect(r.items).toEqual([alice]);
385
+ });
386
+
387
+ it('error captures message and errorCode on failure', async () => {
388
+ class TestResource extends Resource<Item> {
389
+ async load() {
390
+ throw new HttpError(404, 'Not found');
391
+ }
392
+ }
393
+ const r = new TestResource();
394
+ r.init();
395
+
396
+ await r.load().catch(() => {});
397
+ expect(r.async.load.error).toBe('Not found');
398
+ expect(r.async.load.errorCode).toBe('not_found');
399
+ });
400
+
401
+ it('concurrent calls: loading stays true until all resolve', async () => {
402
+ const resolvers: (() => void)[] = [];
403
+ class TestResource extends Resource<Item> {
404
+ async load() {
405
+ await new Promise<void>(r => { resolvers.push(r); });
406
+ }
407
+ }
408
+ const r = new TestResource();
409
+ r.init();
410
+
411
+ const pA = r.load();
412
+ const pB = r.load();
413
+
414
+ expect(r.async.load.loading).toBe(true);
415
+
416
+ resolvers[0]();
417
+ await pA;
418
+ // Still loading because second call is pending
419
+ expect(r.async.load.loading).toBe(true);
420
+
421
+ resolvers[1]();
422
+ await pB;
423
+ expect(r.async.load.loading).toBe(false);
424
+ });
425
+
426
+ it('AbortError silently swallowed', async () => {
427
+ class TestResource extends Resource<Item> {
428
+ async load() {
429
+ const err = new Error('Aborted');
430
+ err.name = 'AbortError';
431
+ throw err;
432
+ }
433
+ }
434
+ const r = new TestResource();
435
+ r.init();
436
+
437
+ // Should not throw
438
+ const result = await r.load();
439
+ expect(result).toBeUndefined();
440
+ expect(r.async.load.loading).toBe(false);
441
+ expect(r.async.load.error).toBeNull();
442
+ });
443
+
444
+ it('non-abort errors re-thrown from wrapper', async () => {
445
+ class TestResource extends Resource<Item> {
446
+ async load() {
447
+ throw new Error('Boom');
448
+ }
449
+ }
450
+ const r = new TestResource();
451
+ r.init();
452
+
453
+ await expect(r.load()).rejects.toThrow('Boom');
454
+ expect(r.async.load.error).toBe('Boom');
455
+ });
456
+
457
+ it('sync methods auto-pruned on first call', () => {
458
+ class TestResource extends Resource<Item> {
459
+ doSync() {
460
+ return 42;
461
+ }
462
+ }
463
+ const r = new TestResource();
464
+ r.init();
465
+
466
+ // First call — triggers pruning
467
+ expect(r.doSync()).toBe(42);
468
+ // Second call — bound original, no wrapper overhead
469
+ expect(r.doSync()).toBe(42);
470
+ // Verify it's no longer tracked
471
+ expect('doSync' in r.async).toBe(false);
472
+ });
473
+
474
+ it('subscribeAsync fires on async state changes', async () => {
475
+ let resolve!: () => void;
476
+ class TestResource extends Resource<Item> {
477
+ async load() {
478
+ await new Promise<void>(r => { resolve = r; });
479
+ }
480
+ }
481
+ const r = new TestResource();
482
+ r.init();
483
+
484
+ const listener = vi.fn();
485
+ r.subscribeAsync(listener);
486
+
487
+ const p = r.load();
488
+ expect(listener).toHaveBeenCalledTimes(1); // loading: true
489
+
490
+ resolve();
491
+ await p;
492
+ expect(listener).toHaveBeenCalledTimes(2); // loading: false
493
+ });
494
+
495
+ it('async proxy returns DEFAULT_TASK_STATE for unknown keys', () => {
496
+ class TestResource extends Resource<Item> {}
497
+ const r = new TestResource();
498
+ r.init();
499
+
500
+ const state = (r.async as any).nonExistent;
501
+ expect(state.loading).toBe(false);
502
+ expect(state.error).toBeNull();
503
+ expect(state.errorCode).toBeNull();
504
+ });
505
+
506
+ it('disposed: method calls return undefined (DEV warning)', async () => {
507
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
508
+ class TestResource extends Resource<Item> {
509
+ async load() {
510
+ this.reset([alice]);
511
+ }
512
+ }
513
+ const r = new TestResource();
514
+ r.init();
515
+ r.dispose();
516
+
517
+ const result = await r.load();
518
+ expect(result).toBeUndefined();
519
+ expect(warn).toHaveBeenCalledWith(
520
+ expect.stringContaining('"load" called after dispose')
521
+ );
522
+ });
523
+
524
+ it('ghost detection warns about pending ops after dispose (DEV)', async () => {
525
+ vi.useFakeTimers();
526
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
527
+
528
+ class TestResource extends Resource<Item> {
529
+ static GHOST_TIMEOUT = 100;
530
+ async load() {
531
+ await new Promise(() => {}); // Never resolves
532
+ }
533
+ }
534
+ const r = new TestResource();
535
+ r.init();
536
+
537
+ r.load();
538
+ r.dispose();
539
+
540
+ vi.advanceTimersByTime(200);
541
+ expect(warn).toHaveBeenCalledWith(
542
+ expect.stringContaining('Ghost async operation detected: "load"')
543
+ );
544
+
545
+ vi.useRealTimers();
546
+ });
547
+
548
+ it('reserved keys: cannot override async', () => {
549
+ expect(() => {
550
+ class BadResource extends Resource<Item> {
551
+ get async(): any { return {}; }
552
+ }
553
+ new BadResource();
554
+ }).toThrow('"async" is a reserved property');
555
+ });
556
+
557
+ it('reserved keys: cannot override subscribeAsync', () => {
558
+ expect(() => {
559
+ class BadResource extends Resource<Item> {
560
+ subscribeAsync(): any { return () => {}; }
561
+ }
562
+ new BadResource();
563
+ }).toThrow('"subscribeAsync" is a reserved property');
564
+ });
565
+
566
+ it('methods called before init() work but have no wrapping', () => {
567
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
568
+ class TestResource extends Resource<Item> {
569
+ doSync() { return 42; }
570
+ }
571
+ const r = new TestResource();
572
+
573
+ // Method works fine before init (no wrapping)
574
+ expect(r.doSync()).toBe(42);
575
+ // No DEV warning before init because method isn't wrapped
576
+ expect(warn).not.toHaveBeenCalled();
577
+ });
578
+ });
579
+
580
+ // ── Category E: Async Tracking + External Collection ──────────
581
+
582
+ describe('Resource — async tracking with external Collection', () => {
583
+ it('async methods that mutate external collection get proper tracking', async () => {
584
+ const col = new Collection<Item>();
585
+ let resolve!: () => void;
586
+
587
+ class TestResource extends Resource<Item> {
588
+ async loadAll() {
589
+ await new Promise<void>(r => { resolve = r; });
590
+ this.reset([alice, bob]);
591
+ }
592
+ }
593
+ const r = new TestResource(col);
594
+ r.init();
595
+
596
+ const p = r.loadAll();
597
+ expect(r.async.loadAll.loading).toBe(true);
598
+ expect(col.items).toEqual([]); // Not loaded yet
599
+
600
+ resolve();
601
+ await p;
602
+ expect(r.async.loadAll.loading).toBe(false);
603
+ expect(col.items).toEqual([alice, bob]); // Loaded into external
604
+ expect(r.items).toEqual([alice, bob]); // Visible through resource
605
+ });
606
+
607
+ it('multiple async methods have independent tracking', async () => {
608
+ const col = new Collection<Item>();
609
+ let resolveA!: () => void;
610
+ let resolveB!: () => void;
611
+
612
+ class TestResource extends Resource<Item> {
613
+ async loadAll() {
614
+ await new Promise<void>(r => { resolveA = r; });
615
+ this.reset([alice, bob]);
616
+ }
617
+ async loadOne() {
618
+ await new Promise<void>(r => { resolveB = r; });
619
+ this.add(charlie);
620
+ }
621
+ }
622
+ const r = new TestResource(col);
623
+ r.init();
624
+
625
+ const pA = r.loadAll();
626
+ const pB = r.loadOne();
627
+
628
+ expect(r.async.loadAll.loading).toBe(true);
629
+ expect(r.async.loadOne.loading).toBe(true);
630
+
631
+ resolveA();
632
+ await pA;
633
+ expect(r.async.loadAll.loading).toBe(false);
634
+ expect(r.async.loadOne.loading).toBe(true);
635
+
636
+ resolveB();
637
+ await pB;
638
+ expect(r.async.loadOne.loading).toBe(false);
639
+ });
640
+ });
641
+
642
+ // ── Category F: Integration ───────────────────────────────────
643
+
644
+ describe('Resource — integration', () => {
645
+ it('subscribeAsync duck-typing matches useInstance expectations', () => {
646
+ const r = new (class extends Resource<Item> {})();
647
+ expect(typeof r.subscribeAsync).toBe('function');
648
+ // useInstance duck-types: typeof obj.subscribeAsync === 'function'
649
+ });
650
+
651
+ it('singleton integration: teardownAll disposes resource', () => {
652
+ class TestResource extends Resource<Item> {}
653
+ const r = singleton(TestResource);
654
+ expect(r.disposed).toBe(false);
655
+ teardownAll();
656
+ expect(r.disposed).toBe(true);
657
+ });
658
+
659
+ it('data persists in resource across singleton() calls', () => {
660
+ class TestResource extends Resource<Item> {}
661
+ const r1 = singleton(TestResource);
662
+ r1.add(alice);
663
+ const r2 = singleton(TestResource);
664
+ expect(r1).toBe(r2);
665
+ expect(r2.items).toEqual([alice]);
666
+ });
667
+
668
+ it('isInitializable guard detects Resource', () => {
669
+ const r = new (class extends Resource<Item> {})();
670
+ // Duck-type check: has init() method
671
+ expect(typeof (r as any).init).toBe('function');
672
+ expect('init' in r).toBe(true);
673
+ });
674
+
675
+ it('resource implements Subscribable interface', () => {
676
+ const r = new (class extends Resource<Item> {})();
677
+ expect(typeof r.subscribe).toBe('function');
678
+ expect('state' in r).toBe(true);
679
+ expect('disposed' in r).toBe(true);
680
+ expect('disposeSignal' in r).toBe(true);
681
+ expect(typeof r.dispose).toBe('function');
682
+ });
683
+
684
+ it('ViewModel auto-tracking via subscribe works', () => {
685
+ const r = new (class extends Resource<Item> {})();
686
+ const listener = vi.fn();
687
+ const unsub = r.subscribe(listener);
688
+ r.add(alice);
689
+ expect(listener).toHaveBeenCalledOnce();
690
+ unsub();
691
+ r.add(bob);
692
+ expect(listener).toHaveBeenCalledOnce(); // Not called again
693
+ });
694
+
695
+ it('external collection subscription survives resource disposal', () => {
696
+ const col = new Collection<Item>();
697
+ class TestResource extends Resource<Item> {}
698
+ const r = new TestResource(col);
699
+
700
+ const listener = vi.fn();
701
+ const unsub = r.subscribe(listener);
702
+
703
+ // Mutation through resource
704
+ r.add(alice);
705
+ expect(listener).toHaveBeenCalledTimes(1);
706
+
707
+ // Dispose resource
708
+ r.dispose();
709
+
710
+ // Direct mutation on external — listener was registered on external
711
+ // so it still fires
712
+ col.add(bob);
713
+ expect(listener).toHaveBeenCalledTimes(2);
714
+
715
+ // Clean up
716
+ unsub();
717
+ });
718
+ });
719
+
720
+ describe('Resource — method binding', () => {
721
+ class TestResource extends Resource<Item> {}
722
+
723
+ it('destructured methods work point-free before init()', () => {
724
+ const resource = new TestResource();
725
+ const { add, remove } = resource;
726
+ add(alice);
727
+ expect(resource.length).toBe(1);
728
+ remove(1);
729
+ expect(resource.length).toBe(0);
730
+ });
731
+
732
+ it('methods work after init() replaces them with async wrappers', async () => {
733
+ const resource = new TestResource();
734
+ await resource.init();
735
+ const { add } = resource;
736
+ add(alice);
737
+ expect(resource.length).toBe(1);
738
+ });
739
+
740
+ it('method reference captured before init() still works after init()', async () => {
741
+ const resource = new TestResource();
742
+ const addRef = resource.add;
743
+ await resource.init();
744
+ addRef(alice);
745
+ expect(resource.length).toBe(1);
746
+ });
747
+
748
+ it('subclass methods are bound through the inheritance chain', () => {
749
+ class ItemResource extends Resource<Item> {
750
+ findByName(name: string): Item | undefined {
751
+ return this.find(i => i.name === name);
752
+ }
753
+ }
754
+ const r = new ItemResource([alice, bob]);
755
+ const { findByName } = r;
756
+ expect(findByName('Alice')).toEqual(alice);
757
+ });
758
+
759
+ it('most-derived override wins when subclass overrides Collection method', () => {
760
+ class TrackingResource extends Resource<Item> {
761
+ addCount = 0;
762
+ override add(...items: Item[]): void {
763
+ this.addCount += items.length;
764
+ super.add(...items);
765
+ }
766
+ }
767
+ const r = new TrackingResource();
768
+ const { add } = r;
769
+ add(alice, bob);
770
+ expect(r.addCount).toBe(2);
771
+ expect(r.length).toBe(2);
772
+ });
773
+
774
+ it('subclass async methods work point-free after init()', async () => {
775
+ class AsyncResource extends Resource<Item> {
776
+ async loadAll() {
777
+ this.reset([alice, bob, charlie]);
778
+ }
779
+ }
780
+ const r = new AsyncResource();
781
+ await r.init();
782
+ const { loadAll } = r;
783
+ await loadAll();
784
+ expect(r.length).toBe(3);
785
+ });
786
+ });