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
package/src/Feed.test.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Feed } from './Feed';
|
|
3
|
+
import { ViewModel } from './ViewModel';
|
|
4
|
+
|
|
5
|
+
describe('Feed', () => {
|
|
6
|
+
describe('initial state', () => {
|
|
7
|
+
it('defaults to cursor null and hasMore true', () => {
|
|
8
|
+
const f = new Feed();
|
|
9
|
+
expect(f.cursor).toBe(null);
|
|
10
|
+
expect(f.hasMore).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('defaults to empty items', () => {
|
|
14
|
+
const f = new Feed<string>();
|
|
15
|
+
expect(f.items).toEqual([]);
|
|
16
|
+
expect(f.count).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('setResult', () => {
|
|
21
|
+
it('updates hasMore and cursor', () => {
|
|
22
|
+
const f = new Feed();
|
|
23
|
+
f.setResult({ hasMore: true, cursor: 'abc123' });
|
|
24
|
+
expect(f.cursor).toBe('abc123');
|
|
25
|
+
expect(f.hasMore).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('sets cursor to null when omitted', () => {
|
|
29
|
+
const f = new Feed();
|
|
30
|
+
f.setResult({ hasMore: false });
|
|
31
|
+
expect(f.cursor).toBe(null);
|
|
32
|
+
expect(f.hasMore).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('sets cursor to null when explicitly null', () => {
|
|
36
|
+
const f = new Feed();
|
|
37
|
+
f.setResult({ hasMore: false, cursor: null });
|
|
38
|
+
expect(f.cursor).toBe(null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('updates on subsequent calls', () => {
|
|
42
|
+
const f = new Feed();
|
|
43
|
+
f.setResult({ hasMore: true, cursor: 'page1' });
|
|
44
|
+
expect(f.cursor).toBe('page1');
|
|
45
|
+
|
|
46
|
+
f.setResult({ hasMore: true, cursor: 'page2' });
|
|
47
|
+
expect(f.cursor).toBe('page2');
|
|
48
|
+
|
|
49
|
+
f.setResult({ hasMore: false, cursor: 'page3' });
|
|
50
|
+
expect(f.cursor).toBe('page3');
|
|
51
|
+
expect(f.hasMore).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does NOT affect items (backward compat)', () => {
|
|
55
|
+
const f = new Feed<string>();
|
|
56
|
+
f.appendPage({ items: ['a'], hasMore: true, cursor: 'c1' });
|
|
57
|
+
f.setResult({ hasMore: false, cursor: 'c2' });
|
|
58
|
+
expect(f.items).toEqual(['a']);
|
|
59
|
+
expect(f.count).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('appendPage', () => {
|
|
64
|
+
it('accumulates items across calls', () => {
|
|
65
|
+
const f = new Feed<number>();
|
|
66
|
+
f.appendPage({ items: [1, 2], hasMore: true, cursor: 'c1' });
|
|
67
|
+
expect(f.items).toEqual([1, 2]);
|
|
68
|
+
expect(f.count).toBe(2);
|
|
69
|
+
|
|
70
|
+
f.appendPage({ items: [3, 4], hasMore: false, cursor: 'c2' });
|
|
71
|
+
expect(f.items).toEqual([1, 2, 3, 4]);
|
|
72
|
+
expect(f.count).toBe(4);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('updates cursor and hasMore', () => {
|
|
76
|
+
const f = new Feed<string>();
|
|
77
|
+
f.appendPage({ items: ['a'], hasMore: true, cursor: 'page1' });
|
|
78
|
+
expect(f.cursor).toBe('page1');
|
|
79
|
+
expect(f.hasMore).toBe(true);
|
|
80
|
+
|
|
81
|
+
f.appendPage({ items: ['b'], hasMore: false });
|
|
82
|
+
expect(f.cursor).toBe(null);
|
|
83
|
+
expect(f.hasMore).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns frozen array (new reference on change)', () => {
|
|
87
|
+
const f = new Feed<number>();
|
|
88
|
+
f.appendPage({ items: [1], hasMore: true });
|
|
89
|
+
const ref1 = f.items;
|
|
90
|
+
expect(Object.isFrozen(ref1)).toBe(true);
|
|
91
|
+
|
|
92
|
+
f.appendPage({ items: [2], hasMore: false });
|
|
93
|
+
const ref2 = f.items;
|
|
94
|
+
expect(ref2).not.toBe(ref1);
|
|
95
|
+
expect(Object.isFrozen(ref2)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('prependPage', () => {
|
|
100
|
+
it('adds items to front', () => {
|
|
101
|
+
const f = new Feed<number>();
|
|
102
|
+
f.appendPage({ items: [3, 4], hasMore: true });
|
|
103
|
+
f.prependPage({ items: [1, 2], hasMore: false, cursor: 'c1' });
|
|
104
|
+
expect(f.items).toEqual([1, 2, 3, 4]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('updates cursor and hasMore', () => {
|
|
108
|
+
const f = new Feed<string>();
|
|
109
|
+
f.prependPage({ items: ['a'], hasMore: false, cursor: 'end' });
|
|
110
|
+
expect(f.cursor).toBe('end');
|
|
111
|
+
expect(f.hasMore).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('push', () => {
|
|
116
|
+
it('adds items without affecting cursor/hasMore', () => {
|
|
117
|
+
const f = new Feed<number>();
|
|
118
|
+
f.appendPage({ items: [1, 2], hasMore: true, cursor: 'c1' });
|
|
119
|
+
f.push(3, 4);
|
|
120
|
+
expect(f.items).toEqual([1, 2, 3, 4]);
|
|
121
|
+
expect(f.cursor).toBe('c1');
|
|
122
|
+
expect(f.hasMore).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('no-op when called with zero items', () => {
|
|
126
|
+
const f = new Feed<number>();
|
|
127
|
+
const listener = vi.fn();
|
|
128
|
+
f.subscribe(listener);
|
|
129
|
+
f.push();
|
|
130
|
+
expect(listener).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns frozen array with new reference', () => {
|
|
134
|
+
const f = new Feed<string>();
|
|
135
|
+
f.push('a');
|
|
136
|
+
const ref1 = f.items;
|
|
137
|
+
expect(Object.isFrozen(ref1)).toBe(true);
|
|
138
|
+
f.push('b');
|
|
139
|
+
expect(f.items).not.toBe(ref1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('notifies subscribers', () => {
|
|
143
|
+
const f = new Feed<number>();
|
|
144
|
+
const listener = vi.fn();
|
|
145
|
+
f.subscribe(listener);
|
|
146
|
+
f.push(1);
|
|
147
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('filter', () => {
|
|
152
|
+
it('removes items that do not match the predicate', () => {
|
|
153
|
+
const f = new Feed<number>();
|
|
154
|
+
f.appendPage({ items: [1, 2, 3, 4, 5], hasMore: false });
|
|
155
|
+
f.filter(n => n > 2);
|
|
156
|
+
expect(f.items).toEqual([3, 4, 5]);
|
|
157
|
+
expect(f.count).toBe(3);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not affect cursor or hasMore', () => {
|
|
161
|
+
const f = new Feed<number>();
|
|
162
|
+
f.appendPage({ items: [1, 2, 3], hasMore: true, cursor: 'c1' });
|
|
163
|
+
f.filter(n => n !== 2);
|
|
164
|
+
expect(f.cursor).toBe('c1');
|
|
165
|
+
expect(f.hasMore).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('is a no-op when nothing is filtered out', () => {
|
|
169
|
+
const f = new Feed<number>();
|
|
170
|
+
f.appendPage({ items: [1, 2, 3], hasMore: true });
|
|
171
|
+
const listener = vi.fn();
|
|
172
|
+
f.subscribe(listener);
|
|
173
|
+
f.filter(() => true);
|
|
174
|
+
expect(listener).not.toHaveBeenCalled();
|
|
175
|
+
expect(f.count).toBe(3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('notifies subscribers when items are removed', () => {
|
|
179
|
+
const f = new Feed<number>();
|
|
180
|
+
f.appendPage({ items: [1, 2, 3], hasMore: true });
|
|
181
|
+
const listener = vi.fn();
|
|
182
|
+
f.subscribe(listener);
|
|
183
|
+
f.filter(n => n !== 2);
|
|
184
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns frozen array', () => {
|
|
188
|
+
const f = new Feed<number>();
|
|
189
|
+
f.appendPage({ items: [1, 2, 3], hasMore: false });
|
|
190
|
+
f.filter(n => n > 1);
|
|
191
|
+
expect(Object.isFrozen(f.items)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('replacePage', () => {
|
|
196
|
+
it('replaces all items atomically', () => {
|
|
197
|
+
const f = new Feed<number>();
|
|
198
|
+
f.appendPage({ items: [1, 2, 3], hasMore: true, cursor: 'c1' });
|
|
199
|
+
f.replacePage({ items: [10, 20], hasMore: false, cursor: 'c2' });
|
|
200
|
+
expect(f.items).toEqual([10, 20]);
|
|
201
|
+
expect(f.count).toBe(2);
|
|
202
|
+
expect(f.cursor).toBe('c2');
|
|
203
|
+
expect(f.hasMore).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('fires single notification', () => {
|
|
207
|
+
const f = new Feed<number>();
|
|
208
|
+
f.appendPage({ items: [1, 2], hasMore: true });
|
|
209
|
+
const listener = vi.fn();
|
|
210
|
+
f.subscribe(listener);
|
|
211
|
+
f.replacePage({ items: [3, 4], hasMore: false, cursor: 'end' });
|
|
212
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('can replace with empty items', () => {
|
|
216
|
+
const f = new Feed<number>();
|
|
217
|
+
f.appendPage({ items: [1, 2, 3], hasMore: true, cursor: 'c1' });
|
|
218
|
+
f.replacePage({ items: [], hasMore: false });
|
|
219
|
+
expect(f.items).toEqual([]);
|
|
220
|
+
expect(f.count).toBe(0);
|
|
221
|
+
expect(f.hasMore).toBe(false);
|
|
222
|
+
expect(f.cursor).toBe(null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns frozen array', () => {
|
|
226
|
+
const f = new Feed<number>();
|
|
227
|
+
f.replacePage({ items: [1, 2], hasMore: true });
|
|
228
|
+
expect(Object.isFrozen(f.items)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('produces new reference', () => {
|
|
232
|
+
const f = new Feed<number>();
|
|
233
|
+
f.appendPage({ items: [1], hasMore: true });
|
|
234
|
+
const ref1 = f.items;
|
|
235
|
+
f.replacePage({ items: [2], hasMore: false });
|
|
236
|
+
const ref2 = f.items;
|
|
237
|
+
expect(ref1).not.toBe(ref2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('ideal for pull-to-refresh pattern', () => {
|
|
241
|
+
const f = new Feed<string>();
|
|
242
|
+
// Initial load
|
|
243
|
+
f.appendPage({ items: ['old1', 'old2'], hasMore: true, cursor: 'page1' });
|
|
244
|
+
f.appendPage({ items: ['old3'], hasMore: false, cursor: 'page2' });
|
|
245
|
+
expect(f.count).toBe(3);
|
|
246
|
+
|
|
247
|
+
// Pull-to-refresh replaces everything
|
|
248
|
+
f.replacePage({ items: ['new1', 'new2'], hasMore: true, cursor: 'fresh' });
|
|
249
|
+
expect(f.items).toEqual(['new1', 'new2']);
|
|
250
|
+
expect(f.cursor).toBe('fresh');
|
|
251
|
+
expect(f.hasMore).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('reset', () => {
|
|
256
|
+
it('resets to initial state', () => {
|
|
257
|
+
const f = new Feed();
|
|
258
|
+
f.setResult({ hasMore: false, cursor: 'abc' });
|
|
259
|
+
f.reset();
|
|
260
|
+
expect(f.cursor).toBe(null);
|
|
261
|
+
expect(f.hasMore).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('clears items', () => {
|
|
265
|
+
const f = new Feed<number>();
|
|
266
|
+
f.appendPage({ items: [1, 2, 3], hasMore: true, cursor: 'c1' });
|
|
267
|
+
expect(f.count).toBe(3);
|
|
268
|
+
|
|
269
|
+
f.reset();
|
|
270
|
+
expect(f.items).toEqual([]);
|
|
271
|
+
expect(f.count).toBe(0);
|
|
272
|
+
expect(f.hasMore).toBe(true);
|
|
273
|
+
expect(f.cursor).toBe(null);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('subscribe notifications', () => {
|
|
278
|
+
it('notifies on setResult', () => {
|
|
279
|
+
const f = new Feed();
|
|
280
|
+
const listener = vi.fn();
|
|
281
|
+
f.subscribe(listener);
|
|
282
|
+
f.setResult({ hasMore: true, cursor: 'abc' });
|
|
283
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('notifies on reset', () => {
|
|
287
|
+
const f = new Feed();
|
|
288
|
+
const listener = vi.fn();
|
|
289
|
+
f.subscribe(listener);
|
|
290
|
+
f.reset();
|
|
291
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('notifies on appendPage', () => {
|
|
295
|
+
const f = new Feed<number>();
|
|
296
|
+
const listener = vi.fn();
|
|
297
|
+
f.subscribe(listener);
|
|
298
|
+
f.appendPage({ items: [1], hasMore: true });
|
|
299
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('notifies on prependPage', () => {
|
|
303
|
+
const f = new Feed<number>();
|
|
304
|
+
const listener = vi.fn();
|
|
305
|
+
f.subscribe(listener);
|
|
306
|
+
f.prependPage({ items: [1], hasMore: true });
|
|
307
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('notifies on replacePage', () => {
|
|
311
|
+
const f = new Feed<number>();
|
|
312
|
+
f.appendPage({ items: [1], hasMore: true });
|
|
313
|
+
const listener = vi.fn();
|
|
314
|
+
f.subscribe(listener);
|
|
315
|
+
f.replacePage({ items: [2, 3], hasMore: false });
|
|
316
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('unsubscribe stops notifications', () => {
|
|
320
|
+
const f = new Feed();
|
|
321
|
+
const listener = vi.fn();
|
|
322
|
+
const unsub = f.subscribe(listener);
|
|
323
|
+
unsub();
|
|
324
|
+
f.setResult({ hasMore: false });
|
|
325
|
+
expect(listener).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('auto-tracking integration', () => {
|
|
330
|
+
it('ViewModel getter invalidates when feed changes', () => {
|
|
331
|
+
let computeCount = 0;
|
|
332
|
+
|
|
333
|
+
class TestVM extends ViewModel {
|
|
334
|
+
readonly feed = new Feed();
|
|
335
|
+
|
|
336
|
+
get hasMore() {
|
|
337
|
+
computeCount++;
|
|
338
|
+
return this.feed.hasMore;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const vm = new TestVM();
|
|
343
|
+
vm.init();
|
|
344
|
+
|
|
345
|
+
expect(vm.hasMore).toBe(true);
|
|
346
|
+
expect(computeCount).toBe(1);
|
|
347
|
+
|
|
348
|
+
// Cached
|
|
349
|
+
vm.hasMore;
|
|
350
|
+
expect(computeCount).toBe(1);
|
|
351
|
+
|
|
352
|
+
// Change feed — getter should recompute
|
|
353
|
+
vm.feed.setResult({ hasMore: false });
|
|
354
|
+
expect(vm.hasMore).toBe(false);
|
|
355
|
+
expect(computeCount).toBe(2);
|
|
356
|
+
|
|
357
|
+
vm.dispose();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('ViewModel subscribe fires when feed changes', () => {
|
|
361
|
+
class TestVM extends ViewModel {
|
|
362
|
+
readonly feed = new Feed();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const vm = new TestVM();
|
|
366
|
+
vm.init();
|
|
367
|
+
|
|
368
|
+
const listener = vi.fn();
|
|
369
|
+
vm.subscribe(listener);
|
|
370
|
+
|
|
371
|
+
vm.feed.setResult({ hasMore: false, cursor: 'x' });
|
|
372
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
373
|
+
|
|
374
|
+
vm.dispose();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('ViewModel getter invalidates when items change via appendPage', () => {
|
|
378
|
+
let computeCount = 0;
|
|
379
|
+
|
|
380
|
+
class TestVM extends ViewModel {
|
|
381
|
+
readonly feed = new Feed<number>();
|
|
382
|
+
|
|
383
|
+
get itemCount() {
|
|
384
|
+
computeCount++;
|
|
385
|
+
return this.feed.items.length;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const vm = new TestVM();
|
|
390
|
+
vm.init();
|
|
391
|
+
|
|
392
|
+
expect(vm.itemCount).toBe(0);
|
|
393
|
+
expect(computeCount).toBe(1);
|
|
394
|
+
|
|
395
|
+
vm.feed.appendPage({ items: [1, 2], hasMore: true });
|
|
396
|
+
expect(vm.itemCount).toBe(2);
|
|
397
|
+
expect(computeCount).toBe(2);
|
|
398
|
+
|
|
399
|
+
vm.dispose();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('ViewModel getter invalidates when items change via replacePage', () => {
|
|
403
|
+
let computeCount = 0;
|
|
404
|
+
|
|
405
|
+
class TestVM extends ViewModel {
|
|
406
|
+
readonly feed = new Feed<number>();
|
|
407
|
+
|
|
408
|
+
get itemCount() {
|
|
409
|
+
computeCount++;
|
|
410
|
+
return this.feed.items.length;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const vm = new TestVM();
|
|
415
|
+
vm.init();
|
|
416
|
+
|
|
417
|
+
vm.feed.appendPage({ items: [1, 2], hasMore: true });
|
|
418
|
+
expect(vm.itemCount).toBe(2);
|
|
419
|
+
computeCount = 0;
|
|
420
|
+
|
|
421
|
+
// replacePage should invalidate the getter
|
|
422
|
+
vm.feed.replacePage({ items: [10, 20, 30], hasMore: false });
|
|
423
|
+
expect(vm.itemCount).toBe(3);
|
|
424
|
+
expect(computeCount).toBe(1);
|
|
425
|
+
|
|
426
|
+
vm.dispose();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('method binding', () => {
|
|
431
|
+
it('destructured methods work point-free', () => {
|
|
432
|
+
const feed = new Feed<{ id: string }>();
|
|
433
|
+
const { appendPage, reset, push } = feed;
|
|
434
|
+
appendPage({ items: [{ id: '1' }], hasMore: true, cursor: 'abc' });
|
|
435
|
+
expect(feed.count).toBe(1);
|
|
436
|
+
push({ id: '2' });
|
|
437
|
+
expect(feed.count).toBe(2);
|
|
438
|
+
reset();
|
|
439
|
+
expect(feed.count).toBe(0);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
});
|
package/src/Feed.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Trackable } from './Trackable';
|
|
2
|
+
|
|
3
|
+
/** Represents a page of items from a paginated API response. */
|
|
4
|
+
export interface FeedPage<T> {
|
|
5
|
+
items: T[];
|
|
6
|
+
hasMore: boolean;
|
|
7
|
+
cursor?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cursor-based pagination state for server-side paginated feeds.
|
|
12
|
+
* Accumulates items across pages, tracks cursor position and hasMore flag.
|
|
13
|
+
* Subscribable — auto-tracked when used as a ViewModel property.
|
|
14
|
+
*/
|
|
15
|
+
export class Feed<T = unknown> extends Trackable {
|
|
16
|
+
private _cursor: string | null = null;
|
|
17
|
+
private _hasMore: boolean = true;
|
|
18
|
+
private _items: readonly T[] = Object.freeze([] as T[]);
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
super();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Readable state ──
|
|
25
|
+
|
|
26
|
+
/** Current cursor position for the next page fetch, or null if at the beginning. */
|
|
27
|
+
get cursor(): string | null {
|
|
28
|
+
return this._cursor;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether more pages are available from the server. */
|
|
32
|
+
get hasMore(): boolean {
|
|
33
|
+
return this._hasMore;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Accumulated items across all loaded pages. */
|
|
37
|
+
get items(): readonly T[] {
|
|
38
|
+
return this._items;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Total number of accumulated items. */
|
|
42
|
+
get count(): number {
|
|
43
|
+
return this._items.length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Actions ──
|
|
47
|
+
|
|
48
|
+
/** Update cursor/hasMore only (backward-compatible, does NOT affect items). */
|
|
49
|
+
setResult(result: { hasMore: boolean; cursor?: string | null }): void {
|
|
50
|
+
this._hasMore = result.hasMore;
|
|
51
|
+
this._cursor = result.cursor ?? null;
|
|
52
|
+
this.notify();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Append page items and update cursor/hasMore. */
|
|
56
|
+
appendPage(page: FeedPage<T>): void {
|
|
57
|
+
this._items = Object.freeze([...this._items, ...page.items]);
|
|
58
|
+
this._hasMore = page.hasMore;
|
|
59
|
+
this._cursor = page.cursor ?? null;
|
|
60
|
+
this.notify();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Prepend page items and update cursor/hasMore. */
|
|
64
|
+
prependPage(page: FeedPage<T>): void {
|
|
65
|
+
this._items = Object.freeze([...page.items, ...this._items]);
|
|
66
|
+
this._hasMore = page.hasMore;
|
|
67
|
+
this._cursor = page.cursor ?? null;
|
|
68
|
+
this.notify();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Add items without affecting cursor/hasMore. */
|
|
72
|
+
push(...items: T[]): void {
|
|
73
|
+
if (items.length === 0) return;
|
|
74
|
+
this._items = Object.freeze([...this._items, ...items]);
|
|
75
|
+
this.notify();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Remove items that don't match the predicate. No-op if nothing is filtered out. */
|
|
79
|
+
filter(predicate: (item: T) => boolean): void {
|
|
80
|
+
const filtered = this._items.filter(predicate);
|
|
81
|
+
if (filtered.length === this._items.length) return;
|
|
82
|
+
this._items = Object.freeze(filtered);
|
|
83
|
+
this.notify();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Replace all items and update cursor/hasMore atomically. Ideal for pull-to-refresh. */
|
|
87
|
+
replacePage(page: FeedPage<T>): void {
|
|
88
|
+
this._items = Object.freeze([...page.items]);
|
|
89
|
+
this._hasMore = page.hasMore;
|
|
90
|
+
this._cursor = page.cursor ?? null;
|
|
91
|
+
this.notify();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reset to initial empty state with hasMore=true. */
|
|
95
|
+
reset(): void {
|
|
96
|
+
this._cursor = null;
|
|
97
|
+
this._hasMore = true;
|
|
98
|
+
this._items = Object.freeze([] as T[]);
|
|
99
|
+
this.notify();
|
|
100
|
+
}
|
|
101
|
+
}
|