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,1559 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Collection } from './Collection';
3
+ import { singleton, teardownAll } from './singleton';
4
+
5
+ interface Todo {
6
+ id: string;
7
+ text: string;
8
+ done: boolean;
9
+ }
10
+
11
+ interface User {
12
+ id: number;
13
+ name: string;
14
+ age: number;
15
+ }
16
+
17
+ describe('Collection', () => {
18
+ describe('initialization', () => {
19
+ it('initializes empty by default', () => {
20
+ const collection = new Collection<Todo>();
21
+ expect(collection.items).toEqual([]);
22
+ expect(collection.length).toBe(0);
23
+ });
24
+
25
+ it('initializes with provided items', () => {
26
+ const items = [
27
+ { id: '1', text: 'Buy milk', done: false },
28
+ { id: '2', text: 'Walk dog', done: true },
29
+ ];
30
+ const collection = new Collection<Todo>(items);
31
+ expect(collection.items).toHaveLength(2);
32
+ expect(collection.length).toBe(2);
33
+ });
34
+
35
+ it('items array is frozen', () => {
36
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
37
+ expect(Object.isFrozen(collection.items)).toBe(true);
38
+ });
39
+
40
+ it('state is alias for items', () => {
41
+ const items = [{ id: '1', text: 'Test', done: false }];
42
+ const collection = new Collection<Todo>(items);
43
+ expect(collection.state).toBe(collection.items);
44
+ });
45
+
46
+ it('starts not disposed', () => {
47
+ const collection = new Collection<Todo>();
48
+ expect(collection.disposed).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe('add()', () => {
53
+ it('adds single item', () => {
54
+ const collection = new Collection<Todo>();
55
+ collection.add({ id: '1', text: 'Test', done: false });
56
+ expect(collection.items).toHaveLength(1);
57
+ expect(collection.get('1')).toEqual({ id: '1', text: 'Test', done: false });
58
+ });
59
+
60
+ it('adds multiple items', () => {
61
+ const collection = new Collection<Todo>();
62
+ collection.add(
63
+ { id: '1', text: 'First', done: false },
64
+ { id: '2', text: 'Second', done: true }
65
+ );
66
+ expect(collection.length).toBe(2);
67
+ });
68
+
69
+ it('notifies listeners', () => {
70
+ const collection = new Collection<Todo>();
71
+ const listener = vi.fn();
72
+ collection.subscribe(listener);
73
+
74
+ collection.add({ id: '1', text: 'Test', done: false });
75
+
76
+ expect(listener).toHaveBeenCalledTimes(1);
77
+ expect(listener).toHaveBeenCalledWith(
78
+ [{ id: '1', text: 'Test', done: false }],
79
+ []
80
+ );
81
+ });
82
+
83
+ it('does not notify when adding empty', () => {
84
+ const collection = new Collection<Todo>();
85
+ const listener = vi.fn();
86
+ collection.subscribe(listener);
87
+
88
+ collection.add();
89
+
90
+ expect(listener).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('skips items with existing IDs', () => {
94
+ const collection = new Collection<Todo>([
95
+ { id: '1', text: 'First', done: false },
96
+ ]);
97
+
98
+ collection.add({ id: '1', text: 'Duplicate', done: true });
99
+
100
+ expect(collection.length).toBe(1);
101
+ expect(collection.get('1')!.text).toBe('First');
102
+ });
103
+
104
+ it('mixed new + existing in batch — only new appended', () => {
105
+ const collection = new Collection<Todo>([
106
+ { id: '1', text: 'First', done: false },
107
+ ]);
108
+
109
+ collection.add(
110
+ { id: '1', text: 'Dup', done: true },
111
+ { id: '2', text: 'Second', done: false },
112
+ );
113
+
114
+ expect(collection.length).toBe(2);
115
+ expect(collection.get('1')!.text).toBe('First');
116
+ expect(collection.get('2')!.text).toBe('Second');
117
+ });
118
+
119
+ it('no notification when all items already exist', () => {
120
+ const collection = new Collection<Todo>([
121
+ { id: '1', text: 'First', done: false },
122
+ ]);
123
+ const listener = vi.fn();
124
+ collection.subscribe(listener);
125
+
126
+ collection.add({ id: '1', text: 'Dup', done: true });
127
+
128
+ expect(listener).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('deduplicates within batch', () => {
132
+ const collection = new Collection<Todo>();
133
+
134
+ collection.add(
135
+ { id: '1', text: 'First', done: false },
136
+ { id: '1', text: 'Second', done: true },
137
+ );
138
+
139
+ expect(collection.length).toBe(1);
140
+ expect(collection.get('1')!.text).toBe('First');
141
+ });
142
+ });
143
+
144
+ describe('upsert()', () => {
145
+ it('adds new item (appended)', () => {
146
+ const collection = new Collection<Todo>();
147
+ collection.upsert({ id: '1', text: 'Test', done: false });
148
+ expect(collection.length).toBe(1);
149
+ expect(collection.get('1')).toEqual({ id: '1', text: 'Test', done: false });
150
+ });
151
+
152
+ it('replaces existing item in-place (position preserved)', () => {
153
+ const collection = new Collection<Todo>([
154
+ { id: '1', text: 'First', done: false },
155
+ { id: '2', text: 'Second', done: false },
156
+ { id: '3', text: 'Third', done: false },
157
+ ]);
158
+
159
+ collection.upsert({ id: '2', text: 'Updated', done: true });
160
+
161
+ expect(collection.length).toBe(3);
162
+ expect(collection.items[1]).toEqual({ id: '2', text: 'Updated', done: true });
163
+ });
164
+
165
+ it('mixed new + existing in one call', () => {
166
+ const collection = new Collection<Todo>([
167
+ { id: '1', text: 'First', done: false },
168
+ ]);
169
+
170
+ collection.upsert(
171
+ { id: '1', text: 'Updated', done: true },
172
+ { id: '2', text: 'New', done: false },
173
+ );
174
+
175
+ expect(collection.length).toBe(2);
176
+ expect(collection.items[0]).toEqual({ id: '1', text: 'Updated', done: true });
177
+ expect(collection.items[1]).toEqual({ id: '2', text: 'New', done: false });
178
+ });
179
+
180
+ it('full replacement (not partial merge)', () => {
181
+ const collection = new Collection<Todo>([
182
+ { id: '1', text: 'Original', done: false },
183
+ ]);
184
+
185
+ collection.upsert({ id: '1', text: 'Replaced', done: true });
186
+
187
+ const item = collection.get('1')!;
188
+ expect(item.text).toBe('Replaced');
189
+ expect(item.done).toBe(true);
190
+ });
191
+
192
+ it('notifies listeners once for mixed batch', () => {
193
+ const collection = new Collection<Todo>([
194
+ { id: '1', text: 'First', done: false },
195
+ ]);
196
+ const listener = vi.fn();
197
+ collection.subscribe(listener);
198
+
199
+ collection.upsert(
200
+ { id: '1', text: 'Updated', done: true },
201
+ { id: '2', text: 'New', done: false },
202
+ );
203
+
204
+ expect(listener).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it('no notification on empty args', () => {
208
+ const collection = new Collection<Todo>();
209
+ const listener = vi.fn();
210
+ collection.subscribe(listener);
211
+
212
+ collection.upsert();
213
+
214
+ expect(listener).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it('no notification when all items reference-identical', () => {
218
+ const item = { id: '1', text: 'Test', done: false };
219
+ const collection = new Collection<Todo>([item]);
220
+ const listener = vi.fn();
221
+ collection.subscribe(listener);
222
+
223
+ collection.upsert(item);
224
+
225
+ expect(listener).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('duplicate IDs in input — last wins', () => {
229
+ const collection = new Collection<Todo>();
230
+
231
+ collection.upsert(
232
+ { id: '1', text: 'First', done: false },
233
+ { id: '1', text: 'Last', done: true },
234
+ );
235
+
236
+ expect(collection.length).toBe(1);
237
+ expect(collection.get('1')!.text).toBe('Last');
238
+ });
239
+
240
+ it('index consistency after upsert', () => {
241
+ const collection = new Collection<Todo>([
242
+ { id: '1', text: 'First', done: false },
243
+ ]);
244
+
245
+ collection.upsert(
246
+ { id: '1', text: 'Updated', done: true },
247
+ { id: '2', text: 'New', done: false },
248
+ );
249
+
250
+ expect(collection.has('1')).toBe(true);
251
+ expect(collection.has('2')).toBe(true);
252
+ expect(collection.get('1')!.text).toBe('Updated');
253
+ expect(collection.get('2')!.text).toBe('New');
254
+ });
255
+
256
+ it('throws on disposed', () => {
257
+ const collection = new Collection<Todo>();
258
+ collection.dispose();
259
+ expect(() => collection.upsert({ id: '1', text: 'Test', done: false })).toThrow(
260
+ 'Cannot upsert on disposed Collection'
261
+ );
262
+ });
263
+
264
+ it('items array frozen after upsert', () => {
265
+ const collection = new Collection<Todo>();
266
+ collection.upsert({ id: '1', text: 'Test', done: false });
267
+ expect(Object.isFrozen(collection.items)).toBe(true);
268
+ });
269
+
270
+ it('works inside optimistic() callback (rollback restores)', () => {
271
+ const collection = new Collection<Todo>([
272
+ { id: '1', text: 'First', done: false },
273
+ ]);
274
+
275
+ const rollback = collection.optimistic(() => {
276
+ collection.upsert(
277
+ { id: '1', text: 'Updated', done: true },
278
+ { id: '2', text: 'New', done: false },
279
+ );
280
+ });
281
+
282
+ expect(collection.length).toBe(2);
283
+ expect(collection.get('1')!.text).toBe('Updated');
284
+
285
+ rollback();
286
+
287
+ expect(collection.length).toBe(1);
288
+ expect(collection.get('1')!.text).toBe('First');
289
+ expect(collection.has('2')).toBe(false);
290
+ });
291
+
292
+ it('works with numeric IDs', () => {
293
+ const collection = new Collection<User>();
294
+ collection.upsert(
295
+ { id: 1, name: 'Alice', age: 30 },
296
+ { id: 2, name: 'Bob', age: 25 },
297
+ );
298
+
299
+ expect(collection.length).toBe(2);
300
+
301
+ collection.upsert({ id: 1, name: 'Alice Updated', age: 31 });
302
+
303
+ expect(collection.length).toBe(2);
304
+ expect(collection.get(1)!.name).toBe('Alice Updated');
305
+ expect(collection.items[0].id).toBe(1); // position preserved
306
+ });
307
+ });
308
+
309
+ describe('remove()', () => {
310
+ it('removes single item by id', () => {
311
+ const collection = new Collection<Todo>([
312
+ { id: '1', text: 'First', done: false },
313
+ { id: '2', text: 'Second', done: true },
314
+ ]);
315
+
316
+ collection.remove('1');
317
+
318
+ expect(collection.length).toBe(1);
319
+ expect(collection.get('1')).toBeUndefined();
320
+ expect(collection.get('2')).toBeDefined();
321
+ });
322
+
323
+ it('removes multiple items', () => {
324
+ const collection = new Collection<Todo>([
325
+ { id: '1', text: 'First', done: false },
326
+ { id: '2', text: 'Second', done: true },
327
+ { id: '3', text: 'Third', done: false },
328
+ ]);
329
+
330
+ collection.remove('1', '3');
331
+
332
+ expect(collection.length).toBe(1);
333
+ expect(collection.items[0].id).toBe('2');
334
+ });
335
+
336
+ it('notifies listeners', () => {
337
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
338
+ const listener = vi.fn();
339
+ collection.subscribe(listener);
340
+
341
+ collection.remove('1');
342
+
343
+ expect(listener).toHaveBeenCalledTimes(1);
344
+ });
345
+
346
+ it('does not notify when id not found', () => {
347
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
348
+ const listener = vi.fn();
349
+ collection.subscribe(listener);
350
+
351
+ collection.remove('999');
352
+
353
+ expect(listener).not.toHaveBeenCalled();
354
+ });
355
+
356
+ it('does not notify when removing empty', () => {
357
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
358
+ const listener = vi.fn();
359
+ collection.subscribe(listener);
360
+
361
+ collection.remove();
362
+
363
+ expect(listener).not.toHaveBeenCalled();
364
+ });
365
+ });
366
+
367
+ describe('update()', () => {
368
+ it('updates item by id', () => {
369
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
370
+
371
+ collection.update('1', { done: true });
372
+
373
+ expect(collection.get('1')).toEqual({ id: '1', text: 'Test', done: true });
374
+ });
375
+
376
+ it('preserves id even if included in changes', () => {
377
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
378
+
379
+ collection.update('1', { id: '999' as string, text: 'Updated' });
380
+
381
+ expect(collection.get('1')).toBeDefined();
382
+ expect(collection.get('1')!.text).toBe('Updated');
383
+ });
384
+
385
+ it('notifies listeners', () => {
386
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
387
+ const listener = vi.fn();
388
+ collection.subscribe(listener);
389
+
390
+ collection.update('1', { done: true });
391
+
392
+ expect(listener).toHaveBeenCalledTimes(1);
393
+ });
394
+
395
+ it('does not notify when id not found', () => {
396
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
397
+ const listener = vi.fn();
398
+ collection.subscribe(listener);
399
+
400
+ collection.update('999', { done: true });
401
+
402
+ expect(listener).not.toHaveBeenCalled();
403
+ });
404
+
405
+ it('does not notify when values unchanged', () => {
406
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
407
+ const listener = vi.fn();
408
+ collection.subscribe(listener);
409
+
410
+ collection.update('1', { done: false });
411
+
412
+ expect(listener).not.toHaveBeenCalled();
413
+ });
414
+ });
415
+
416
+ describe('reset()', () => {
417
+ it('replaces all items', () => {
418
+ const collection = new Collection<Todo>([
419
+ { id: '1', text: 'First', done: false },
420
+ ]);
421
+
422
+ collection.reset([
423
+ { id: '2', text: 'Second', done: true },
424
+ { id: '3', text: 'Third', done: false },
425
+ ]);
426
+
427
+ expect(collection.length).toBe(2);
428
+ expect(collection.get('1')).toBeUndefined();
429
+ expect(collection.get('2')).toBeDefined();
430
+ });
431
+
432
+ it('notifies listeners', () => {
433
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
434
+ const listener = vi.fn();
435
+ collection.subscribe(listener);
436
+
437
+ collection.reset([{ id: '2', text: 'New', done: true }]);
438
+
439
+ expect(listener).toHaveBeenCalledTimes(1);
440
+ });
441
+ });
442
+
443
+ describe('clear()', () => {
444
+ it('removes all items', () => {
445
+ const collection = new Collection<Todo>([
446
+ { id: '1', text: 'First', done: false },
447
+ { id: '2', text: 'Second', done: true },
448
+ ]);
449
+
450
+ collection.clear();
451
+
452
+ expect(collection.length).toBe(0);
453
+ expect(collection.items).toEqual([]);
454
+ });
455
+
456
+ it('notifies listeners', () => {
457
+ const collection = new Collection<Todo>([{ id: '1', text: 'Test', done: false }]);
458
+ const listener = vi.fn();
459
+ collection.subscribe(listener);
460
+
461
+ collection.clear();
462
+
463
+ expect(listener).toHaveBeenCalledTimes(1);
464
+ });
465
+
466
+ it('does not notify when already empty', () => {
467
+ const collection = new Collection<Todo>();
468
+ const listener = vi.fn();
469
+ collection.subscribe(listener);
470
+
471
+ collection.clear();
472
+
473
+ expect(listener).not.toHaveBeenCalled();
474
+ });
475
+ });
476
+
477
+ describe('optimistic()', () => {
478
+ it('returns a rollback function', () => {
479
+ const collection = new Collection<Todo>();
480
+ const rollback = collection.optimistic(() => {});
481
+ expect(typeof rollback).toBe('function');
482
+ });
483
+
484
+ it('callback mutations apply immediately', () => {
485
+ const collection = new Collection<Todo>([
486
+ { id: '1', text: 'First', done: false },
487
+ ]);
488
+
489
+ collection.optimistic(() => {
490
+ collection.update('1', { done: true });
491
+ });
492
+
493
+ expect(collection.get('1')!.done).toBe(true);
494
+ });
495
+
496
+ it('callback mutations notify listeners normally', () => {
497
+ const collection = new Collection<Todo>([
498
+ { id: '1', text: 'First', done: false },
499
+ ]);
500
+ const listener = vi.fn();
501
+ collection.subscribe(listener);
502
+
503
+ collection.optimistic(() => {
504
+ collection.update('1', { done: true });
505
+ });
506
+
507
+ expect(listener).toHaveBeenCalledTimes(1);
508
+ });
509
+
510
+ it('rollback restores items to pre-callback state', () => {
511
+ const collection = new Collection<Todo>([
512
+ { id: '1', text: 'First', done: false },
513
+ { id: '2', text: 'Second', done: false },
514
+ ]);
515
+
516
+ const rollback = collection.optimistic(() => {
517
+ collection.update('1', { done: true });
518
+ collection.remove('2');
519
+ });
520
+
521
+ expect(collection.length).toBe(1);
522
+ expect(collection.get('1')!.done).toBe(true);
523
+
524
+ rollback();
525
+
526
+ expect(collection.length).toBe(2);
527
+ expect(collection.get('1')!.done).toBe(false);
528
+ expect(collection.get('2')).toBeDefined();
529
+ });
530
+
531
+ it('rollback restores index correctly (has/get consistency)', () => {
532
+ const collection = new Collection<Todo>([
533
+ { id: '1', text: 'First', done: false },
534
+ { id: '2', text: 'Second', done: false },
535
+ ]);
536
+
537
+ const rollback = collection.optimistic(() => {
538
+ collection.remove('1');
539
+ collection.add({ id: '3', text: 'Third', done: true });
540
+ });
541
+
542
+ expect(collection.has('1')).toBe(false);
543
+ expect(collection.has('3')).toBe(true);
544
+
545
+ rollback();
546
+
547
+ expect(collection.has('1')).toBe(true);
548
+ expect(collection.has('3')).toBe(false);
549
+ expect(collection.get('1')).toEqual({ id: '1', text: 'First', done: false });
550
+ });
551
+
552
+ it('rollback notifies listeners', () => {
553
+ const collection = new Collection<Todo>([
554
+ { id: '1', text: 'First', done: false },
555
+ ]);
556
+ const listener = vi.fn();
557
+ collection.subscribe(listener);
558
+
559
+ const rollback = collection.optimistic(() => {
560
+ collection.update('1', { done: true });
561
+ });
562
+
563
+ listener.mockClear();
564
+ rollback();
565
+
566
+ expect(listener).toHaveBeenCalledTimes(1);
567
+ // Called with (restored items, pre-rollback items)
568
+ expect(listener).toHaveBeenCalledWith(
569
+ [{ id: '1', text: 'First', done: false }],
570
+ [{ id: '1', text: 'First', done: true }]
571
+ );
572
+ });
573
+
574
+ it('rollback is idempotent', () => {
575
+ const collection = new Collection<Todo>([
576
+ { id: '1', text: 'First', done: false },
577
+ ]);
578
+ const listener = vi.fn();
579
+ collection.subscribe(listener);
580
+
581
+ const rollback = collection.optimistic(() => {
582
+ collection.update('1', { done: true });
583
+ });
584
+
585
+ listener.mockClear();
586
+ rollback();
587
+ rollback();
588
+ rollback();
589
+
590
+ expect(listener).toHaveBeenCalledTimes(1);
591
+ });
592
+
593
+ it('rollback is no-op when disposed', () => {
594
+ const collection = new Collection<Todo>([
595
+ { id: '1', text: 'First', done: false },
596
+ ]);
597
+
598
+ const rollback = collection.optimistic(() => {
599
+ collection.update('1', { done: true });
600
+ });
601
+
602
+ collection.dispose();
603
+ expect(() => rollback()).not.toThrow();
604
+ });
605
+
606
+ it('throws when called on disposed collection', () => {
607
+ const collection = new Collection<Todo>();
608
+ collection.dispose();
609
+
610
+ expect(() => collection.optimistic(() => {})).toThrow(
611
+ 'Cannot perform optimistic update on disposed Collection'
612
+ );
613
+ });
614
+
615
+ it('multiple CRUD operations in callback all rollback together', () => {
616
+ const collection = new Collection<Todo>([
617
+ { id: '1', text: 'First', done: false },
618
+ { id: '2', text: 'Second', done: false },
619
+ ]);
620
+
621
+ const rollback = collection.optimistic(() => {
622
+ collection.update('1', { done: true });
623
+ collection.update('2', { text: 'Modified' });
624
+ collection.add({ id: '3', text: 'Third', done: true });
625
+ });
626
+
627
+ expect(collection.length).toBe(3);
628
+
629
+ rollback();
630
+
631
+ expect(collection.length).toBe(2);
632
+ expect(collection.get('1')!.done).toBe(false);
633
+ expect(collection.get('2')!.text).toBe('Second');
634
+ expect(collection.has('3')).toBe(false);
635
+ });
636
+
637
+ it('rollback after additional mutations restores pre-optimistic state', () => {
638
+ const collection = new Collection<Todo>([
639
+ { id: '1', text: 'First', done: false },
640
+ ]);
641
+
642
+ const rollback = collection.optimistic(() => {
643
+ collection.update('1', { done: true });
644
+ });
645
+
646
+ // Additional mutation after optimistic
647
+ collection.add({ id: '2', text: 'Extra', done: false });
648
+ expect(collection.length).toBe(2);
649
+
650
+ rollback();
651
+
652
+ // Restores to pre-optimistic snapshot, discarding the extra add
653
+ expect(collection.length).toBe(1);
654
+ expect(collection.get('1')!.done).toBe(false);
655
+ expect(collection.has('2')).toBe(false);
656
+ });
657
+
658
+ it('nested optimistic calls capture independent snapshots', () => {
659
+ const collection = new Collection<Todo>([
660
+ { id: '1', text: 'First', done: false },
661
+ ]);
662
+
663
+ const rollback1 = collection.optimistic(() => {
664
+ collection.update('1', { text: 'Outer' });
665
+ });
666
+
667
+ const rollback2 = collection.optimistic(() => {
668
+ collection.update('1', { text: 'Inner' });
669
+ });
670
+
671
+ expect(collection.get('1')!.text).toBe('Inner');
672
+
673
+ // Rolling back inner restores to after outer
674
+ rollback2();
675
+ expect(collection.get('1')!.text).toBe('Outer');
676
+
677
+ // Rolling back outer restores original
678
+ rollback1();
679
+ expect(collection.get('1')!.text).toBe('First');
680
+ });
681
+
682
+ it('works with empty collection', () => {
683
+ const collection = new Collection<Todo>();
684
+
685
+ const rollback = collection.optimistic(() => {
686
+ collection.add({ id: '1', text: 'Test', done: false });
687
+ });
688
+
689
+ expect(collection.length).toBe(1);
690
+
691
+ rollback();
692
+
693
+ expect(collection.length).toBe(0);
694
+ });
695
+ });
696
+
697
+ describe('query methods', () => {
698
+ const users: User[] = [
699
+ { id: 1, name: 'Alice', age: 30 },
700
+ { id: 2, name: 'Bob', age: 25 },
701
+ { id: 3, name: 'Charlie', age: 35 },
702
+ ];
703
+
704
+ describe('get()', () => {
705
+ it('returns item by id', () => {
706
+ const collection = new Collection<User>(users);
707
+ expect(collection.get(2)).toEqual({ id: 2, name: 'Bob', age: 25 });
708
+ });
709
+
710
+ it('returns undefined for unknown id', () => {
711
+ const collection = new Collection<User>(users);
712
+ expect(collection.get(999)).toBeUndefined();
713
+ });
714
+ });
715
+
716
+ describe('has()', () => {
717
+ it('returns true when item exists', () => {
718
+ const collection = new Collection<User>(users);
719
+ expect(collection.has(1)).toBe(true);
720
+ });
721
+
722
+ it('returns false when item does not exist', () => {
723
+ const collection = new Collection<User>(users);
724
+ expect(collection.has(999)).toBe(false);
725
+ });
726
+ });
727
+
728
+ describe('find()', () => {
729
+ it('returns first matching item', () => {
730
+ const collection = new Collection<User>(users);
731
+ const result = collection.find(u => u.age > 28);
732
+ expect(result).toEqual({ id: 1, name: 'Alice', age: 30 });
733
+ });
734
+
735
+ it('returns undefined when no match', () => {
736
+ const collection = new Collection<User>(users);
737
+ const result = collection.find(u => u.age > 100);
738
+ expect(result).toBeUndefined();
739
+ });
740
+ });
741
+
742
+ describe('filter()', () => {
743
+ it('returns matching items', () => {
744
+ const collection = new Collection<User>(users);
745
+ const result = collection.filter(u => u.age >= 30);
746
+ expect(result).toHaveLength(2);
747
+ expect(result.map(u => u.name)).toEqual(['Alice', 'Charlie']);
748
+ });
749
+
750
+ it('returns empty array when no match', () => {
751
+ const collection = new Collection<User>(users);
752
+ const result = collection.filter(u => u.age > 100);
753
+ expect(result).toEqual([]);
754
+ });
755
+ });
756
+
757
+ describe('sorted()', () => {
758
+ it('returns sorted copy', () => {
759
+ const collection = new Collection<User>(users);
760
+ const result = collection.sorted((a, b) => a.age - b.age);
761
+ expect(result.map(u => u.name)).toEqual(['Bob', 'Alice', 'Charlie']);
762
+ });
763
+
764
+ it('does not modify original', () => {
765
+ const collection = new Collection<User>(users);
766
+ collection.sorted((a, b) => a.age - b.age);
767
+ expect(collection.items[0].name).toBe('Alice');
768
+ });
769
+ });
770
+
771
+ describe('map()', () => {
772
+ it('transforms items', () => {
773
+ const collection = new Collection<User>(users);
774
+ const names = collection.map(u => u.name);
775
+ expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
776
+ });
777
+ });
778
+ });
779
+
780
+ describe('subscriptions', () => {
781
+ it('unsubscribe function works', () => {
782
+ const collection = new Collection<Todo>();
783
+ const listener = vi.fn();
784
+ const unsubscribe = collection.subscribe(listener);
785
+
786
+ collection.add({ id: '1', text: 'Test', done: false });
787
+ expect(listener).toHaveBeenCalledTimes(1);
788
+
789
+ unsubscribe();
790
+ collection.add({ id: '2', text: 'Test 2', done: true });
791
+ expect(listener).toHaveBeenCalledTimes(1);
792
+ });
793
+ });
794
+
795
+ describe('dispose', () => {
796
+ it('sets disposed to true', () => {
797
+ const collection = new Collection<Todo>();
798
+ collection.dispose();
799
+ expect(collection.disposed).toBe(true);
800
+ });
801
+
802
+ it('throws on add after dispose', () => {
803
+ const collection = new Collection<Todo>();
804
+ collection.dispose();
805
+ expect(() => collection.add({ id: '1', text: 'Test', done: false })).toThrow(
806
+ 'Cannot add to disposed Collection'
807
+ );
808
+ });
809
+
810
+ it('throws on remove after dispose', () => {
811
+ const collection = new Collection<Todo>();
812
+ collection.dispose();
813
+ expect(() => collection.remove('1')).toThrow(
814
+ 'Cannot remove from disposed Collection'
815
+ );
816
+ });
817
+
818
+ it('returns no-op on subscribe after dispose', () => {
819
+ const collection = new Collection<Todo>();
820
+ collection.dispose();
821
+ const unsub = collection.subscribe(() => {});
822
+ expect(typeof unsub).toBe('function');
823
+ expect(() => unsub()).not.toThrow();
824
+ });
825
+
826
+ it('is idempotent', () => {
827
+ const collection = new Collection<Todo>();
828
+ collection.dispose();
829
+ collection.dispose();
830
+ expect(collection.disposed).toBe(true);
831
+ });
832
+ });
833
+
834
+ describe('singleton integration', () => {
835
+ beforeEach(() => {
836
+ teardownAll();
837
+ });
838
+
839
+ it('can be used with singleton registry', () => {
840
+ class TodoCollection extends Collection<Todo> {}
841
+
842
+ const c1 = singleton(TodoCollection);
843
+ const c2 = singleton(TodoCollection);
844
+ expect(c1).toBe(c2);
845
+ });
846
+ });
847
+
848
+ describe('signal and addCleanup', () => {
849
+ it('signal returns an AbortSignal', () => {
850
+ const collection = new Collection<Todo>();
851
+ expect(collection.disposeSignal).toBeInstanceOf(AbortSignal);
852
+ });
853
+
854
+ it('returns the same signal on multiple accesses', () => {
855
+ const collection = new Collection<Todo>();
856
+ const s1 = collection.disposeSignal;
857
+ const s2 = collection.disposeSignal;
858
+ expect(s1).toBe(s2);
859
+ });
860
+
861
+ it('signal is not aborted before dispose', () => {
862
+ const collection = new Collection<Todo>();
863
+ expect(collection.disposeSignal.aborted).toBe(false);
864
+ });
865
+
866
+ it('signal is aborted after dispose', () => {
867
+ const collection = new Collection<Todo>();
868
+ const signal = collection.disposeSignal;
869
+ collection.dispose();
870
+ expect(signal.aborted).toBe(true);
871
+ });
872
+
873
+ it('signal is aborted before onDispose runs', () => {
874
+ let wasAbortedDuringDispose = false;
875
+ class CheckCollection extends Collection<Todo> {
876
+ protected onDispose(): void {
877
+ wasAbortedDuringDispose = this.disposeSignal.aborted;
878
+ }
879
+ }
880
+ const collection = new CheckCollection();
881
+ collection.disposeSignal; // force lazy creation
882
+ collection.dispose();
883
+ expect(wasAbortedDuringDispose).toBe(true);
884
+ });
885
+
886
+ it('addCleanup fires on dispose', () => {
887
+ let cleaned = false;
888
+ class CleanupCollection extends Collection<Todo> {
889
+ setup() {
890
+ this.addCleanup(() => { cleaned = true; });
891
+ }
892
+ }
893
+ const collection = new CleanupCollection();
894
+ collection.setup();
895
+ expect(cleaned).toBe(false);
896
+ collection.dispose();
897
+ expect(cleaned).toBe(true);
898
+ });
899
+
900
+ it('dispose works without accessing signal (lazy, zero cost)', () => {
901
+ const collection = new Collection<Todo>();
902
+ collection.dispose();
903
+ expect(collection.disposed).toBe(true);
904
+ });
905
+ });
906
+
907
+ describe('onDispose hook', () => {
908
+ it('calls onDispose on dispose (bug fix)', () => {
909
+ let called = false;
910
+ class DisposeCollection extends Collection<Todo> {
911
+ protected onDispose(): void {
912
+ called = true;
913
+ }
914
+ }
915
+ const collection = new DisposeCollection();
916
+ collection.dispose();
917
+ expect(called).toBe(true);
918
+ });
919
+
920
+ it('onDispose called only once even with multiple dispose calls', () => {
921
+ let callCount = 0;
922
+ class CountingCollection extends Collection<Todo> {
923
+ protected onDispose(): void {
924
+ callCount++;
925
+ }
926
+ }
927
+ const collection = new CountingCollection();
928
+ collection.dispose();
929
+ collection.dispose();
930
+ collection.dispose();
931
+ expect(callCount).toBe(1);
932
+ });
933
+ });
934
+
935
+ describe('MAX_SIZE (capacity eviction)', () => {
936
+ class CappedCollection extends Collection<Todo> {
937
+ static MAX_SIZE = 3;
938
+ }
939
+
940
+ it('evicts oldest (FIFO) when add exceeds capacity', () => {
941
+ const collection = new CappedCollection([
942
+ { id: '1', text: 'First', done: false },
943
+ { id: '2', text: 'Second', done: false },
944
+ { id: '3', text: 'Third', done: false },
945
+ ]);
946
+
947
+ collection.add({ id: '4', text: 'Fourth', done: false });
948
+
949
+ expect(collection.length).toBe(3);
950
+ expect(collection.has('1')).toBe(false);
951
+ expect(collection.has('4')).toBe(true);
952
+ expect(collection.items[0].id).toBe('2');
953
+ expect(collection.items[2].id).toBe('4');
954
+ });
955
+
956
+ it('evicts on upsert when new items push over limit', () => {
957
+ const collection = new CappedCollection([
958
+ { id: '1', text: 'First', done: false },
959
+ { id: '2', text: 'Second', done: false },
960
+ { id: '3', text: 'Third', done: false },
961
+ ]);
962
+
963
+ collection.upsert(
964
+ { id: '4', text: 'Fourth', done: false },
965
+ { id: '5', text: 'Fifth', done: false },
966
+ );
967
+
968
+ expect(collection.length).toBe(3);
969
+ expect(collection.has('1')).toBe(false);
970
+ expect(collection.has('2')).toBe(false);
971
+ expect(collection.has('3')).toBe(true);
972
+ expect(collection.has('4')).toBe(true);
973
+ expect(collection.has('5')).toBe(true);
974
+ });
975
+
976
+ it('upsert replacing existing items does not trigger eviction', () => {
977
+ const collection = new CappedCollection([
978
+ { id: '1', text: 'First', done: false },
979
+ { id: '2', text: 'Second', done: false },
980
+ { id: '3', text: 'Third', done: false },
981
+ ]);
982
+
983
+ collection.upsert({ id: '2', text: 'Updated', done: true });
984
+
985
+ expect(collection.length).toBe(3);
986
+ expect(collection.has('1')).toBe(true);
987
+ expect(collection.get('2')!.text).toBe('Updated');
988
+ });
989
+
990
+ it('reset() truncates to MAX_SIZE', () => {
991
+ const collection = new CappedCollection();
992
+
993
+ collection.reset([
994
+ { id: '1', text: 'A', done: false },
995
+ { id: '2', text: 'B', done: false },
996
+ { id: '3', text: 'C', done: false },
997
+ { id: '4', text: 'D', done: false },
998
+ { id: '5', text: 'E', done: false },
999
+ ]);
1000
+
1001
+ expect(collection.length).toBe(3);
1002
+ expect(collection.has('1')).toBe(false);
1003
+ expect(collection.has('2')).toBe(false);
1004
+ expect(collection.items[0].id).toBe('3');
1005
+ });
1006
+
1007
+ it('no eviction when under capacity', () => {
1008
+ const collection = new CappedCollection();
1009
+
1010
+ collection.add(
1011
+ { id: '1', text: 'First', done: false },
1012
+ { id: '2', text: 'Second', done: false },
1013
+ );
1014
+
1015
+ expect(collection.length).toBe(2);
1016
+ expect(collection.has('1')).toBe(true);
1017
+ expect(collection.has('2')).toBe(true);
1018
+ });
1019
+
1020
+ it('MAX_SIZE = 0 is unlimited (default behavior)', () => {
1021
+ const collection = new Collection<Todo>();
1022
+
1023
+ for (let i = 0; i < 100; i++) {
1024
+ collection.add({ id: String(i), text: `Item ${i}`, done: false });
1025
+ }
1026
+
1027
+ expect(collection.length).toBe(100);
1028
+ });
1029
+
1030
+ it('single notification for add + eviction', () => {
1031
+ const collection = new CappedCollection([
1032
+ { id: '1', text: 'First', done: false },
1033
+ { id: '2', text: 'Second', done: false },
1034
+ { id: '3', text: 'Third', done: false },
1035
+ ]);
1036
+ const listener = vi.fn();
1037
+ collection.subscribe(listener);
1038
+
1039
+ collection.add({ id: '4', text: 'Fourth', done: false });
1040
+
1041
+ expect(listener).toHaveBeenCalledTimes(1);
1042
+ });
1043
+
1044
+ it('index stays consistent after eviction', () => {
1045
+ const collection = new CappedCollection([
1046
+ { id: '1', text: 'First', done: false },
1047
+ { id: '2', text: 'Second', done: false },
1048
+ { id: '3', text: 'Third', done: false },
1049
+ ]);
1050
+
1051
+ collection.add({ id: '4', text: 'Fourth', done: false });
1052
+
1053
+ expect(collection.get('1')).toBeUndefined();
1054
+ expect(collection.get('4')).toEqual({ id: '4', text: 'Fourth', done: false });
1055
+ expect(collection.has('1')).toBe(false);
1056
+ expect(collection.has('4')).toBe(true);
1057
+ });
1058
+
1059
+ it('constructor truncates initial items to MAX_SIZE', () => {
1060
+ const collection = new CappedCollection([
1061
+ { id: '1', text: 'A', done: false },
1062
+ { id: '2', text: 'B', done: false },
1063
+ { id: '3', text: 'C', done: false },
1064
+ { id: '4', text: 'D', done: false },
1065
+ { id: '5', text: 'E', done: false },
1066
+ ]);
1067
+
1068
+ expect(collection.length).toBe(3);
1069
+ expect(collection.has('1')).toBe(false);
1070
+ expect(collection.has('2')).toBe(false);
1071
+ expect(collection.items[0].id).toBe('3');
1072
+ });
1073
+
1074
+ it('evicts multiple items at once when adding batch over limit', () => {
1075
+ const collection = new CappedCollection([
1076
+ { id: '1', text: 'First', done: false },
1077
+ ]);
1078
+
1079
+ collection.add(
1080
+ { id: '2', text: 'Second', done: false },
1081
+ { id: '3', text: 'Third', done: false },
1082
+ { id: '4', text: 'Fourth', done: false },
1083
+ { id: '5', text: 'Fifth', done: false },
1084
+ );
1085
+
1086
+ expect(collection.length).toBe(3);
1087
+ expect(collection.items[0].id).toBe('3');
1088
+ expect(collection.items[2].id).toBe('5');
1089
+ });
1090
+ });
1091
+
1092
+ describe('TTL (time-to-live eviction)', () => {
1093
+ beforeEach(() => {
1094
+ vi.useFakeTimers();
1095
+ });
1096
+
1097
+ afterEach(() => {
1098
+ vi.useRealTimers();
1099
+ });
1100
+
1101
+ class TTLCollection extends Collection<Todo> {
1102
+ static TTL = 1000; // 1 second
1103
+ }
1104
+
1105
+ it('items auto-removed after TTL expires', () => {
1106
+ const collection = new TTLCollection();
1107
+ collection.add({ id: '1', text: 'Test', done: false });
1108
+
1109
+ expect(collection.length).toBe(1);
1110
+
1111
+ vi.advanceTimersByTime(1000);
1112
+
1113
+ expect(collection.length).toBe(0);
1114
+ });
1115
+
1116
+ it('subscribers notified on TTL sweep', () => {
1117
+ const collection = new TTLCollection();
1118
+ collection.add({ id: '1', text: 'Test', done: false });
1119
+
1120
+ const listener = vi.fn();
1121
+ collection.subscribe(listener);
1122
+
1123
+ vi.advanceTimersByTime(1000);
1124
+
1125
+ expect(listener).toHaveBeenCalledTimes(1);
1126
+ expect(listener).toHaveBeenCalledWith([], [{ id: '1', text: 'Test', done: false }]);
1127
+ });
1128
+
1129
+ it('timer rescheduled after partial sweep', () => {
1130
+ const collection = new TTLCollection();
1131
+ collection.add({ id: '1', text: 'First', done: false });
1132
+
1133
+ vi.advanceTimersByTime(500);
1134
+ collection.add({ id: '2', text: 'Second', done: false });
1135
+
1136
+ // First item expires at 1000ms
1137
+ vi.advanceTimersByTime(500);
1138
+ expect(collection.length).toBe(1);
1139
+ expect(collection.has('1')).toBe(false);
1140
+ expect(collection.has('2')).toBe(true);
1141
+
1142
+ // Second item expires at 1500ms
1143
+ vi.advanceTimersByTime(500);
1144
+ expect(collection.length).toBe(0);
1145
+ });
1146
+
1147
+ it('no timer/timestamps when TTL = 0 (default)', () => {
1148
+ const collection = new Collection<Todo>();
1149
+ collection.add({ id: '1', text: 'Test', done: false });
1150
+
1151
+ // Access private field for zero-overhead verification
1152
+ expect((collection as any)._timestamps).toBeNull();
1153
+ expect((collection as any)._evictionTimer).toBeNull();
1154
+ });
1155
+
1156
+ it('upsert() refreshes timestamp', () => {
1157
+ const collection = new TTLCollection();
1158
+ collection.add({ id: '1', text: 'Test', done: false });
1159
+
1160
+ // Wait 800ms, then upsert to refresh
1161
+ vi.advanceTimersByTime(800);
1162
+ collection.upsert({ id: '1', text: 'Refreshed', done: false });
1163
+
1164
+ // Original would have expired at 1000ms, but refresh extends it
1165
+ vi.advanceTimersByTime(200);
1166
+ expect(collection.length).toBe(1);
1167
+ expect(collection.get('1')!.text).toBe('Refreshed');
1168
+
1169
+ // Expires at 800 + 1000 = 1800ms total
1170
+ vi.advanceTimersByTime(800);
1171
+ expect(collection.length).toBe(0);
1172
+ });
1173
+
1174
+ it('update() does NOT refresh timestamp', () => {
1175
+ const collection = new TTLCollection();
1176
+ collection.add({ id: '1', text: 'Test', done: false });
1177
+
1178
+ vi.advanceTimersByTime(800);
1179
+ collection.update('1', { done: true });
1180
+
1181
+ // Still expires at original 1000ms
1182
+ vi.advanceTimersByTime(200);
1183
+ expect(collection.length).toBe(0);
1184
+ });
1185
+
1186
+ it('clear() cancels timer + clears timestamps', () => {
1187
+ const collection = new TTLCollection();
1188
+ collection.add({ id: '1', text: 'Test', done: false });
1189
+
1190
+ collection.clear();
1191
+
1192
+ expect((collection as any)._evictionTimer).toBeNull();
1193
+ expect((collection as any)._timestamps.size).toBe(0);
1194
+
1195
+ // Timer fire is harmless (no items to sweep)
1196
+ vi.advanceTimersByTime(1000);
1197
+ expect(collection.length).toBe(0);
1198
+ });
1199
+
1200
+ it('reset() resets all timestamps', () => {
1201
+ const collection = new TTLCollection();
1202
+ collection.add({ id: '1', text: 'Old', done: false });
1203
+
1204
+ vi.advanceTimersByTime(800);
1205
+ collection.reset([
1206
+ { id: '1', text: 'Reset', done: false },
1207
+ { id: '2', text: 'New', done: false },
1208
+ ]);
1209
+
1210
+ // Old timestamp was at 0ms, would expire at 1000ms
1211
+ // Reset refreshes to 800ms, so expires at 1800ms
1212
+ vi.advanceTimersByTime(200);
1213
+ expect(collection.length).toBe(2);
1214
+
1215
+ vi.advanceTimersByTime(800);
1216
+ expect(collection.length).toBe(0);
1217
+ });
1218
+
1219
+ it('dispose() cancels timer', () => {
1220
+ const collection = new TTLCollection();
1221
+ collection.add({ id: '1', text: 'Test', done: false });
1222
+
1223
+ collection.dispose();
1224
+
1225
+ expect((collection as any)._evictionTimer).toBeNull();
1226
+ });
1227
+
1228
+ it('timer fire after dispose is no-op', () => {
1229
+ const collection = new TTLCollection();
1230
+ collection.add({ id: '1', text: 'Test', done: false });
1231
+
1232
+ // Manually store timer reference, then dispose
1233
+ collection.dispose();
1234
+
1235
+ // Even if a timer somehow fires, no crash
1236
+ expect(() => vi.advanceTimersByTime(2000)).not.toThrow();
1237
+ });
1238
+
1239
+ it('constructor sets up timestamps for initial items', () => {
1240
+ const collection = new TTLCollection([
1241
+ { id: '1', text: 'A', done: false },
1242
+ { id: '2', text: 'B', done: false },
1243
+ ]);
1244
+
1245
+ expect((collection as any)._timestamps).toBeInstanceOf(Map);
1246
+ expect((collection as any)._timestamps.size).toBe(2);
1247
+
1248
+ vi.advanceTimersByTime(1000);
1249
+ expect(collection.length).toBe(0);
1250
+ });
1251
+
1252
+ it('remove() cleans up timestamps', () => {
1253
+ const collection = new TTLCollection();
1254
+ collection.add({ id: '1', text: 'A', done: false });
1255
+ collection.add({ id: '2', text: 'B', done: false });
1256
+
1257
+ collection.remove('1');
1258
+
1259
+ expect((collection as any)._timestamps.has('1')).toBe(false);
1260
+ expect((collection as any)._timestamps.has('2')).toBe(true);
1261
+ });
1262
+ });
1263
+
1264
+ describe('onEvict hook', () => {
1265
+ it('called with candidates before capacity eviction', () => {
1266
+ const evictCalls: { items: Todo[]; reason: string }[] = [];
1267
+
1268
+ class HookCollection extends Collection<Todo> {
1269
+ static MAX_SIZE = 2;
1270
+ protected onEvict(items: Todo[], reason: 'capacity' | 'ttl') {
1271
+ evictCalls.push({ items: [...items], reason });
1272
+ }
1273
+ }
1274
+
1275
+ const collection = new HookCollection([
1276
+ { id: '1', text: 'First', done: false },
1277
+ { id: '2', text: 'Second', done: false },
1278
+ ]);
1279
+
1280
+ collection.add({ id: '3', text: 'Third', done: false });
1281
+
1282
+ expect(evictCalls).toHaveLength(1);
1283
+ expect(evictCalls[0].reason).toBe('capacity');
1284
+ expect(evictCalls[0].items).toHaveLength(1);
1285
+ expect(evictCalls[0].items[0].id).toBe('1');
1286
+ });
1287
+
1288
+ it('called with expired items before TTL eviction', () => {
1289
+ vi.useFakeTimers();
1290
+ const evictCalls: { items: Todo[]; reason: string }[] = [];
1291
+
1292
+ class HookTTLCollection extends Collection<Todo> {
1293
+ static TTL = 1000;
1294
+ protected onEvict(items: Todo[], reason: 'capacity' | 'ttl') {
1295
+ evictCalls.push({ items: [...items], reason });
1296
+ }
1297
+ }
1298
+
1299
+ const collection = new HookTTLCollection();
1300
+ collection.add({ id: '1', text: 'Test', done: false });
1301
+
1302
+ vi.advanceTimersByTime(1000);
1303
+
1304
+ expect(evictCalls).toHaveLength(1);
1305
+ expect(evictCalls[0].reason).toBe('ttl');
1306
+ expect(evictCalls[0].items[0].id).toBe('1');
1307
+
1308
+ vi.useRealTimers();
1309
+ });
1310
+
1311
+ it('returning false vetoes eviction', () => {
1312
+ class VetoCollection extends Collection<Todo> {
1313
+ static MAX_SIZE = 2;
1314
+ protected onEvict() {
1315
+ return false as const;
1316
+ }
1317
+ }
1318
+
1319
+ const collection = new VetoCollection([
1320
+ { id: '1', text: 'First', done: false },
1321
+ { id: '2', text: 'Second', done: false },
1322
+ ]);
1323
+
1324
+ collection.add({ id: '3', text: 'Third', done: false });
1325
+
1326
+ // All items kept despite MAX_SIZE = 2
1327
+ expect(collection.length).toBe(3);
1328
+ expect(collection.has('1')).toBe(true);
1329
+ expect(collection.has('3')).toBe(true);
1330
+ });
1331
+
1332
+ it('returning T[] subset evicts only those', () => {
1333
+ class FilterCollection extends Collection<Todo> {
1334
+ static MAX_SIZE = 2;
1335
+ protected onEvict(items: Todo[]) {
1336
+ // Only evict done items
1337
+ return items.filter(i => i.done);
1338
+ }
1339
+ }
1340
+
1341
+ const collection = new FilterCollection([
1342
+ { id: '1', text: 'First', done: true },
1343
+ { id: '2', text: 'Second', done: false },
1344
+ ]);
1345
+
1346
+ collection.add({ id: '3', text: 'Third', done: false });
1347
+
1348
+ // Only id=1 (done) was evicted
1349
+ expect(collection.length).toBe(2);
1350
+ expect(collection.has('1')).toBe(false);
1351
+ expect(collection.has('2')).toBe(true);
1352
+ expect(collection.has('3')).toBe(true);
1353
+ });
1354
+
1355
+ it('returning void proceeds with all candidates', () => {
1356
+ class VoidCollection extends Collection<Todo> {
1357
+ static MAX_SIZE = 2;
1358
+ protected onEvict() {
1359
+ // void return
1360
+ }
1361
+ }
1362
+
1363
+ const collection = new VoidCollection([
1364
+ { id: '1', text: 'First', done: false },
1365
+ { id: '2', text: 'Second', done: false },
1366
+ ]);
1367
+
1368
+ collection.add({ id: '3', text: 'Third', done: false });
1369
+
1370
+ expect(collection.length).toBe(2);
1371
+ expect(collection.has('1')).toBe(false);
1372
+ });
1373
+
1374
+ it('DEV warning when veto causes >2x MAX_SIZE', () => {
1375
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1376
+
1377
+ class BigVetoCollection extends Collection<Todo> {
1378
+ static MAX_SIZE = 2;
1379
+ protected onEvict() {
1380
+ return false as const;
1381
+ }
1382
+ }
1383
+
1384
+ const collection = new BigVetoCollection([
1385
+ { id: '1', text: 'A', done: false },
1386
+ { id: '2', text: 'B', done: false },
1387
+ ]);
1388
+
1389
+ // Add items to exceed 2x MAX_SIZE (2*2=4, need >4)
1390
+ collection.add({ id: '3', text: 'C', done: false });
1391
+ collection.add({ id: '4', text: 'D', done: false });
1392
+ collection.add({ id: '5', text: 'E', done: false });
1393
+
1394
+ expect(warnSpy).toHaveBeenCalledWith(
1395
+ expect.stringContaining('Collection exceeded 2x MAX_SIZE')
1396
+ );
1397
+
1398
+ warnSpy.mockRestore();
1399
+ });
1400
+
1401
+ it('TTL veto keeps items alive', () => {
1402
+ vi.useFakeTimers();
1403
+
1404
+ class TTLVetoCollection extends Collection<Todo> {
1405
+ static TTL = 1000;
1406
+ protected onEvict() {
1407
+ return false as const;
1408
+ }
1409
+ }
1410
+
1411
+ const collection = new TTLVetoCollection();
1412
+ collection.add({ id: '1', text: 'Test', done: false });
1413
+
1414
+ vi.advanceTimersByTime(1000);
1415
+
1416
+ expect(collection.length).toBe(1);
1417
+
1418
+ vi.useRealTimers();
1419
+ });
1420
+ });
1421
+
1422
+ describe('MAX_SIZE + TTL interactions', () => {
1423
+ beforeEach(() => {
1424
+ vi.useFakeTimers();
1425
+ });
1426
+
1427
+ afterEach(() => {
1428
+ vi.useRealTimers();
1429
+ });
1430
+
1431
+ it('both enforced independently', () => {
1432
+ class BothCollection extends Collection<Todo> {
1433
+ static MAX_SIZE = 3;
1434
+ static TTL = 1000;
1435
+ }
1436
+
1437
+ const collection = new BothCollection();
1438
+ collection.add(
1439
+ { id: '1', text: 'A', done: false },
1440
+ { id: '2', text: 'B', done: false },
1441
+ { id: '3', text: 'C', done: false },
1442
+ );
1443
+
1444
+ // Capacity eviction
1445
+ collection.add({ id: '4', text: 'D', done: false });
1446
+ expect(collection.length).toBe(3);
1447
+ expect(collection.has('1')).toBe(false);
1448
+
1449
+ // TTL eviction
1450
+ vi.advanceTimersByTime(1000);
1451
+ expect(collection.length).toBe(0);
1452
+ });
1453
+
1454
+ it('optimistic rollback restores evicted items + timestamps', () => {
1455
+ class CappedTTL extends Collection<Todo> {
1456
+ static MAX_SIZE = 2;
1457
+ static TTL = 1000;
1458
+ }
1459
+
1460
+ const collection = new CappedTTL([
1461
+ { id: '1', text: 'A', done: false },
1462
+ { id: '2', text: 'B', done: false },
1463
+ ]);
1464
+
1465
+ const rollback = collection.optimistic(() => {
1466
+ collection.add({ id: '3', text: 'C', done: false });
1467
+ });
1468
+
1469
+ // Capacity evicted id=1
1470
+ expect(collection.length).toBe(2);
1471
+ expect(collection.has('1')).toBe(false);
1472
+
1473
+ rollback();
1474
+
1475
+ // Restored
1476
+ expect(collection.length).toBe(2);
1477
+ expect(collection.has('1')).toBe(true);
1478
+ expect(collection.has('2')).toBe(true);
1479
+ expect(collection.has('3')).toBe(false);
1480
+
1481
+ // TTL still works after rollback
1482
+ vi.advanceTimersByTime(1000);
1483
+ expect(collection.length).toBe(0);
1484
+ });
1485
+ });
1486
+
1487
+ describe('zero-overhead verification', () => {
1488
+ it('no _timestamps Map for default Collection', () => {
1489
+ const collection = new Collection<Todo>();
1490
+ collection.add({ id: '1', text: 'Test', done: false });
1491
+ expect((collection as any)._timestamps).toBeNull();
1492
+ });
1493
+
1494
+ it('no timer for default Collection', () => {
1495
+ const collection = new Collection<Todo>();
1496
+ collection.add({ id: '1', text: 'Test', done: false });
1497
+ expect((collection as any)._evictionTimer).toBeNull();
1498
+ });
1499
+ });
1500
+
1501
+ describe('method binding', () => {
1502
+ it('destructured methods work point-free', () => {
1503
+ const collection = new Collection<Todo>();
1504
+ const { add, remove, get, has } = collection;
1505
+ add({ id: '1', text: 'Test', done: false });
1506
+ expect(collection.length).toBe(1);
1507
+ expect(has('1')).toBe(true);
1508
+ expect(get('1')).toEqual({ id: '1', text: 'Test', done: false });
1509
+ remove('1');
1510
+ expect(collection.length).toBe(0);
1511
+ });
1512
+
1513
+ it('methods work as React-style callbacks', () => {
1514
+ const collection = new Collection<Todo>();
1515
+ const callback: (...items: Todo[]) => void = collection.add;
1516
+ callback({ id: '1', text: 'Test', done: false });
1517
+ expect(collection.length).toBe(1);
1518
+ });
1519
+
1520
+ it('subclass methods are also bound', () => {
1521
+ class TodoCollection extends Collection<Todo> {
1522
+ completed(): Todo[] { return this.filter(t => t.done); }
1523
+ }
1524
+ const col = new TodoCollection();
1525
+ const { completed, add } = col;
1526
+ add({ id: '1', text: 'Test', done: true });
1527
+ expect(completed()).toEqual([{ id: '1', text: 'Test', done: true }]);
1528
+ });
1529
+
1530
+ it('multi-level inheritance chain binds all levels', () => {
1531
+ class BaseCollection extends Collection<Todo> {
1532
+ baseMethod(): number { return this.length; }
1533
+ }
1534
+ class LeafCollection extends BaseCollection {
1535
+ leafMethod(): string { return `count: ${this.length}`; }
1536
+ }
1537
+ const col = new LeafCollection();
1538
+ const { add, baseMethod, leafMethod } = col;
1539
+ add({ id: '1', text: 'Test', done: false });
1540
+ expect(baseMethod()).toBe(1);
1541
+ expect(leafMethod()).toBe('count: 1');
1542
+ });
1543
+
1544
+ it('most-derived override wins in subclass chain', () => {
1545
+ class TrackingCollection extends Collection<Todo> {
1546
+ addCount = 0;
1547
+ override add(...items: Todo[]): void {
1548
+ this.addCount += items.length;
1549
+ super.add(...items);
1550
+ }
1551
+ }
1552
+ const col = new TrackingCollection();
1553
+ const { add } = col;
1554
+ add({ id: '1', text: 'Test', done: false });
1555
+ expect(col.addCount).toBe(1);
1556
+ expect(col.length).toBe(1);
1557
+ });
1558
+ });
1559
+ });