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