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.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- 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
|
+
});
|