mvc-kit 2.12.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +10 -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,1583 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ViewModel } from './ViewModel';
|
|
3
|
+
import { Collection } from './Collection';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Test Setup Classes
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
interface Item {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
role?: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class TestCollection extends Collection<Item> {}
|
|
17
|
+
|
|
18
|
+
interface FilterState {
|
|
19
|
+
search: string;
|
|
20
|
+
statusFilter: string;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
error: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class FilterViewModel extends ViewModel<FilterState> {
|
|
26
|
+
collection = new TestCollection();
|
|
27
|
+
|
|
28
|
+
get filtered(): Item[] {
|
|
29
|
+
const { search, statusFilter } = this.state;
|
|
30
|
+
let items = [...this.collection.items];
|
|
31
|
+
if (search) {
|
|
32
|
+
const q = search.toLowerCase();
|
|
33
|
+
items = items.filter((i) => i.name.toLowerCase().includes(q));
|
|
34
|
+
}
|
|
35
|
+
if (statusFilter !== 'all') {
|
|
36
|
+
items = items.filter((i) => i.status === statusFilter);
|
|
37
|
+
}
|
|
38
|
+
return items;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get total(): number {
|
|
42
|
+
return this.collection.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get hasResults(): boolean {
|
|
46
|
+
return this.filtered.length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get activeFilterCount(): number {
|
|
50
|
+
let count = 0;
|
|
51
|
+
if (this.state.search) count++;
|
|
52
|
+
if (this.state.statusFilter !== 'all') count++;
|
|
53
|
+
return count;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setSearch(search: string) {
|
|
57
|
+
this.set({ search });
|
|
58
|
+
}
|
|
59
|
+
setStatusFilter(statusFilter: string) {
|
|
60
|
+
this.set({ statusFilter });
|
|
61
|
+
}
|
|
62
|
+
setLoading(loading: boolean) {
|
|
63
|
+
this.set({ loading });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// VM with counter to track getter executions
|
|
68
|
+
class CountedFilterViewModel extends FilterViewModel {
|
|
69
|
+
filteredCallCount = 0;
|
|
70
|
+
totalCallCount = 0;
|
|
71
|
+
hasResultsCallCount = 0;
|
|
72
|
+
activeFilterCountCallCount = 0;
|
|
73
|
+
|
|
74
|
+
override get filtered(): Item[] {
|
|
75
|
+
this.filteredCallCount++;
|
|
76
|
+
return super.filtered;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override get total(): number {
|
|
80
|
+
this.totalCallCount++;
|
|
81
|
+
return super.total;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override get hasResults(): boolean {
|
|
85
|
+
this.hasResultsCallCount++;
|
|
86
|
+
return super.hasResults;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override get activeFilterCount(): number {
|
|
90
|
+
this.activeFilterCountCallCount++;
|
|
91
|
+
return super.activeFilterCount;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// State Proxy Tracking
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
describe('State Proxy Tracking', () => {
|
|
100
|
+
it('state proxy records accessed keys during tracking', () => {
|
|
101
|
+
const vm = new CountedFilterViewModel({
|
|
102
|
+
search: '',
|
|
103
|
+
statusFilter: 'all',
|
|
104
|
+
loading: false,
|
|
105
|
+
error: null,
|
|
106
|
+
});
|
|
107
|
+
vm.collection.reset([
|
|
108
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
109
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
110
|
+
]);
|
|
111
|
+
vm.init();
|
|
112
|
+
|
|
113
|
+
// First access - tracking records search and statusFilter
|
|
114
|
+
const result1 = vm.filtered;
|
|
115
|
+
expect(result1).toHaveLength(2);
|
|
116
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
117
|
+
|
|
118
|
+
// Change search - should invalidate
|
|
119
|
+
vm.setSearch('ali');
|
|
120
|
+
const result2 = vm.filtered;
|
|
121
|
+
expect(result2).toHaveLength(1);
|
|
122
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
123
|
+
|
|
124
|
+
// Change loading (not accessed in getter) - should NOT invalidate
|
|
125
|
+
vm.setLoading(true);
|
|
126
|
+
const result3 = vm.filtered;
|
|
127
|
+
expect(result3).toBe(result2); // Same reference
|
|
128
|
+
expect(vm.filteredCallCount).toBe(2); // No recompute
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('state proxy does not record when tracking is inactive', () => {
|
|
132
|
+
const vm = new FilterViewModel({
|
|
133
|
+
search: 'test',
|
|
134
|
+
statusFilter: 'all',
|
|
135
|
+
loading: false,
|
|
136
|
+
error: null,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Before init, state proxy is just a plain accessor
|
|
140
|
+
const searchValue = vm.state.search;
|
|
141
|
+
expect(searchValue).toBe('test');
|
|
142
|
+
|
|
143
|
+
// No tracking should have occurred
|
|
144
|
+
// (This is implicit - we can't directly check tracking, but we verify
|
|
145
|
+
// that getters work as plain accessors before init)
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('state proxy prevents direct mutation', () => {
|
|
149
|
+
const vm = new FilterViewModel({
|
|
150
|
+
search: '',
|
|
151
|
+
statusFilter: 'all',
|
|
152
|
+
loading: false,
|
|
153
|
+
error: null,
|
|
154
|
+
});
|
|
155
|
+
vm.init();
|
|
156
|
+
|
|
157
|
+
expect(() => {
|
|
158
|
+
(vm.state as any).search = 'test';
|
|
159
|
+
}).toThrow(); // Frozen state prevents direct mutation
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Auto-Memoized Getters - Basic
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
describe('Auto-Memoized Getters - Basic', () => {
|
|
168
|
+
it('getter computes correct value on first access', () => {
|
|
169
|
+
const vm = new FilterViewModel({
|
|
170
|
+
search: 'ali',
|
|
171
|
+
statusFilter: 'active',
|
|
172
|
+
loading: false,
|
|
173
|
+
error: null,
|
|
174
|
+
});
|
|
175
|
+
vm.collection.reset([
|
|
176
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
177
|
+
{ id: '2', name: 'Bob', status: 'active' },
|
|
178
|
+
{ id: '3', name: 'Charlie', status: 'inactive' },
|
|
179
|
+
]);
|
|
180
|
+
vm.init();
|
|
181
|
+
|
|
182
|
+
const result = vm.filtered;
|
|
183
|
+
expect(result).toEqual([{ id: '1', name: 'Alice', status: 'active' }]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('getter returns cached reference on repeated access', () => {
|
|
187
|
+
const vm = new CountedFilterViewModel({
|
|
188
|
+
search: '',
|
|
189
|
+
statusFilter: 'all',
|
|
190
|
+
loading: false,
|
|
191
|
+
error: null,
|
|
192
|
+
});
|
|
193
|
+
vm.collection.reset([
|
|
194
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
195
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
196
|
+
]);
|
|
197
|
+
vm.init();
|
|
198
|
+
|
|
199
|
+
const result1 = vm.filtered;
|
|
200
|
+
const result2 = vm.filtered;
|
|
201
|
+
const result3 = vm.filtered;
|
|
202
|
+
|
|
203
|
+
expect(result1).toBe(result2);
|
|
204
|
+
expect(result2).toBe(result3);
|
|
205
|
+
expect(vm.filteredCallCount).toBe(1); // Computed only once
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('getter recomputes when a dependency changes', () => {
|
|
209
|
+
const vm = new CountedFilterViewModel({
|
|
210
|
+
search: '',
|
|
211
|
+
statusFilter: 'all',
|
|
212
|
+
loading: false,
|
|
213
|
+
error: null,
|
|
214
|
+
});
|
|
215
|
+
vm.collection.reset([
|
|
216
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
217
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
218
|
+
]);
|
|
219
|
+
vm.init();
|
|
220
|
+
|
|
221
|
+
const result1 = vm.filtered;
|
|
222
|
+
expect(result1).toHaveLength(2);
|
|
223
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
224
|
+
|
|
225
|
+
vm.setSearch('ali');
|
|
226
|
+
const result2 = vm.filtered;
|
|
227
|
+
expect(result2).toHaveLength(1);
|
|
228
|
+
expect(result2).not.toBe(result1); // Different reference
|
|
229
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('getter does NOT recompute when unrelated state changes', () => {
|
|
233
|
+
const vm = new CountedFilterViewModel({
|
|
234
|
+
search: '',
|
|
235
|
+
statusFilter: 'all',
|
|
236
|
+
loading: false,
|
|
237
|
+
error: null,
|
|
238
|
+
});
|
|
239
|
+
vm.collection.reset([
|
|
240
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
241
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
242
|
+
]);
|
|
243
|
+
vm.init();
|
|
244
|
+
|
|
245
|
+
const result1 = vm.filtered;
|
|
246
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
247
|
+
|
|
248
|
+
vm.setLoading(true);
|
|
249
|
+
const result2 = vm.filtered;
|
|
250
|
+
expect(result2).toBe(result1); // Same reference
|
|
251
|
+
expect(vm.filteredCallCount).toBe(1); // No recompute
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('getter handles empty initial state', () => {
|
|
255
|
+
const vm = new FilterViewModel({
|
|
256
|
+
search: '',
|
|
257
|
+
statusFilter: 'all',
|
|
258
|
+
loading: false,
|
|
259
|
+
error: null,
|
|
260
|
+
});
|
|
261
|
+
vm.init();
|
|
262
|
+
|
|
263
|
+
const result = vm.filtered;
|
|
264
|
+
expect(result).toEqual([]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// Conditional Dependencies
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
describe('Conditional Dependencies', () => {
|
|
273
|
+
interface ModeState {
|
|
274
|
+
mode: string;
|
|
275
|
+
title: string;
|
|
276
|
+
subtitle: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
class ModeVM extends ViewModel<ModeState> {
|
|
280
|
+
displayCallCount = 0;
|
|
281
|
+
|
|
282
|
+
get display(): string {
|
|
283
|
+
this.displayCallCount++;
|
|
284
|
+
if (this.state.mode === 'compact') return this.state.title;
|
|
285
|
+
return `${this.state.title} — ${this.state.subtitle}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setMode(mode: string) {
|
|
289
|
+
this.set({ mode });
|
|
290
|
+
}
|
|
291
|
+
setTitle(title: string) {
|
|
292
|
+
this.set({ title });
|
|
293
|
+
}
|
|
294
|
+
setSubtitle(subtitle: string) {
|
|
295
|
+
this.set({ subtitle });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
it('getter re-tracks deps when conditional branches change', () => {
|
|
300
|
+
const vm = new ModeVM({
|
|
301
|
+
mode: 'compact',
|
|
302
|
+
title: 'Hello',
|
|
303
|
+
subtitle: 'World',
|
|
304
|
+
});
|
|
305
|
+
vm.init();
|
|
306
|
+
|
|
307
|
+
// In compact mode, only title is accessed
|
|
308
|
+
const result1 = vm.display;
|
|
309
|
+
expect(result1).toBe('Hello');
|
|
310
|
+
expect(vm.displayCallCount).toBe(1);
|
|
311
|
+
|
|
312
|
+
// Change subtitle - should NOT recompute (not a dependency in compact mode)
|
|
313
|
+
vm.setSubtitle('Universe');
|
|
314
|
+
const result2 = vm.display;
|
|
315
|
+
expect(result2).toBe(result1); // Same reference
|
|
316
|
+
expect(vm.displayCallCount).toBe(1);
|
|
317
|
+
|
|
318
|
+
// Switch to full mode - recompute and re-track
|
|
319
|
+
vm.setMode('full');
|
|
320
|
+
const result3 = vm.display;
|
|
321
|
+
expect(result3).toBe('Hello — Universe');
|
|
322
|
+
expect(vm.displayCallCount).toBe(2);
|
|
323
|
+
|
|
324
|
+
// Now subtitle IS a dependency - should recompute
|
|
325
|
+
vm.setSubtitle('Galaxy');
|
|
326
|
+
const result4 = vm.display;
|
|
327
|
+
expect(result4).toBe('Hello — Galaxy');
|
|
328
|
+
expect(vm.displayCallCount).toBe(3);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Getter Composition (Nested)
|
|
334
|
+
// ============================================================================
|
|
335
|
+
|
|
336
|
+
describe('Getter Composition (Nested)', () => {
|
|
337
|
+
it('getter reads another getter and composes correctly', () => {
|
|
338
|
+
const vm = new FilterViewModel({
|
|
339
|
+
search: 'ali',
|
|
340
|
+
statusFilter: 'all',
|
|
341
|
+
loading: false,
|
|
342
|
+
error: null,
|
|
343
|
+
});
|
|
344
|
+
vm.collection.reset([
|
|
345
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
346
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
347
|
+
]);
|
|
348
|
+
vm.init();
|
|
349
|
+
|
|
350
|
+
const hasResults = vm.hasResults;
|
|
351
|
+
expect(hasResults).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('nested getter invalidates on transitive dependency', () => {
|
|
355
|
+
const vm = new CountedFilterViewModel({
|
|
356
|
+
search: '',
|
|
357
|
+
statusFilter: 'all',
|
|
358
|
+
loading: false,
|
|
359
|
+
error: null,
|
|
360
|
+
});
|
|
361
|
+
vm.collection.reset([
|
|
362
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
363
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
364
|
+
]);
|
|
365
|
+
vm.init();
|
|
366
|
+
|
|
367
|
+
const hasResults1 = vm.hasResults;
|
|
368
|
+
expect(hasResults1).toBe(true);
|
|
369
|
+
expect(vm.hasResultsCallCount).toBe(1);
|
|
370
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
371
|
+
|
|
372
|
+
// Set search to something that matches nothing
|
|
373
|
+
vm.setSearch('xyz');
|
|
374
|
+
const hasResults2 = vm.hasResults;
|
|
375
|
+
expect(hasResults2).toBe(false);
|
|
376
|
+
expect(vm.hasResultsCallCount).toBe(2); // hasResults recomputed
|
|
377
|
+
expect(vm.filteredCallCount).toBe(2); // filtered recomputed (dependency of hasResults)
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('nested getter caches when non-dependency changes', () => {
|
|
381
|
+
const vm = new CountedFilterViewModel({
|
|
382
|
+
search: '',
|
|
383
|
+
statusFilter: 'all',
|
|
384
|
+
loading: false,
|
|
385
|
+
error: null,
|
|
386
|
+
});
|
|
387
|
+
vm.collection.reset([
|
|
388
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
389
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
390
|
+
]);
|
|
391
|
+
vm.init();
|
|
392
|
+
|
|
393
|
+
const hasResults1 = vm.hasResults;
|
|
394
|
+
expect(hasResults1).toBe(true);
|
|
395
|
+
expect(vm.hasResultsCallCount).toBe(1);
|
|
396
|
+
|
|
397
|
+
vm.setLoading(true);
|
|
398
|
+
const hasResults2 = vm.hasResults;
|
|
399
|
+
expect(hasResults2).toBe(hasResults1);
|
|
400
|
+
expect(vm.hasResultsCallCount).toBe(1); // No recompute
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('two sibling getters sharing a nested getter both update on collection change', () => {
|
|
404
|
+
// This is the exact bug scenario: filteredCount accesses `filtered` first,
|
|
405
|
+
// then paged accesses `filtered` second via Tier 1 cache hit.
|
|
406
|
+
// Without dep bubbling, paged misses the collection dependency.
|
|
407
|
+
class PaginatedVM extends ViewModel<FilterState> {
|
|
408
|
+
collection = new TestCollection();
|
|
409
|
+
filteredCallCount = 0;
|
|
410
|
+
filteredCountCallCount = 0;
|
|
411
|
+
pagedCallCount = 0;
|
|
412
|
+
|
|
413
|
+
get filtered(): Item[] {
|
|
414
|
+
this.filteredCallCount++;
|
|
415
|
+
const { search } = this.state;
|
|
416
|
+
let items = [...this.collection.items];
|
|
417
|
+
if (search) {
|
|
418
|
+
const q = search.toLowerCase();
|
|
419
|
+
items = items.filter((i) => i.name.toLowerCase().includes(q));
|
|
420
|
+
}
|
|
421
|
+
return items;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
get filteredCount(): number {
|
|
425
|
+
this.filteredCountCallCount++;
|
|
426
|
+
return this.filtered.length;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
get paged(): Item[] {
|
|
430
|
+
this.pagedCallCount++;
|
|
431
|
+
return this.filtered.slice(0, 2);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
setSearch(search: string) { this.set({ search }); }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const vm = new PaginatedVM({ search: '', statusFilter: 'all', loading: false, error: null });
|
|
438
|
+
vm.init();
|
|
439
|
+
|
|
440
|
+
// Access filteredCount FIRST, then paged (same order as the component)
|
|
441
|
+
expect(vm.filteredCount).toBe(0);
|
|
442
|
+
expect(vm.paged).toEqual([]);
|
|
443
|
+
|
|
444
|
+
// Populate collection
|
|
445
|
+
vm.collection.reset([
|
|
446
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
447
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
448
|
+
{ id: '3', name: 'Charlie', status: 'active' },
|
|
449
|
+
]);
|
|
450
|
+
|
|
451
|
+
// BOTH getters must update — this was the bug
|
|
452
|
+
expect(vm.filteredCount).toBe(3);
|
|
453
|
+
expect(vm.paged).toHaveLength(2);
|
|
454
|
+
expect(vm.paged[0].name).toBe('Alice');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('sibling getters update regardless of access order (reversed)', () => {
|
|
458
|
+
class PaginatedVM extends ViewModel<FilterState> {
|
|
459
|
+
collection = new TestCollection();
|
|
460
|
+
|
|
461
|
+
get filtered(): Item[] {
|
|
462
|
+
const { search } = this.state;
|
|
463
|
+
let items = [...this.collection.items];
|
|
464
|
+
if (search) {
|
|
465
|
+
const q = search.toLowerCase();
|
|
466
|
+
items = items.filter((i) => i.name.toLowerCase().includes(q));
|
|
467
|
+
}
|
|
468
|
+
return items;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
get filteredCount(): number {
|
|
472
|
+
return this.filtered.length;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
get paged(): Item[] {
|
|
476
|
+
return this.filtered.slice(0, 2);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
setSearch(search: string) { this.set({ search }); }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const vm = new PaginatedVM({ search: '', statusFilter: 'all', loading: false, error: null });
|
|
483
|
+
vm.init();
|
|
484
|
+
|
|
485
|
+
// Access paged FIRST, then filteredCount (reversed order)
|
|
486
|
+
expect(vm.paged).toEqual([]);
|
|
487
|
+
expect(vm.filteredCount).toBe(0);
|
|
488
|
+
|
|
489
|
+
vm.collection.reset([
|
|
490
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
491
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
// Both must update regardless of access order
|
|
495
|
+
expect(vm.paged).toHaveLength(2);
|
|
496
|
+
expect(vm.filteredCount).toBe(2);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('sibling getters both recompute after state dep change (counter verification)', () => {
|
|
500
|
+
class PaginatedVM extends ViewModel<FilterState> {
|
|
501
|
+
collection = new TestCollection();
|
|
502
|
+
filteredCallCount = 0;
|
|
503
|
+
filteredCountCallCount = 0;
|
|
504
|
+
pagedCallCount = 0;
|
|
505
|
+
|
|
506
|
+
get filtered(): Item[] {
|
|
507
|
+
this.filteredCallCount++;
|
|
508
|
+
const { search } = this.state;
|
|
509
|
+
let items = [...this.collection.items];
|
|
510
|
+
if (search) {
|
|
511
|
+
const q = search.toLowerCase();
|
|
512
|
+
items = items.filter((i) => i.name.toLowerCase().includes(q));
|
|
513
|
+
}
|
|
514
|
+
return items;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
get filteredCount(): number {
|
|
518
|
+
this.filteredCountCallCount++;
|
|
519
|
+
return this.filtered.length;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
get paged(): Item[] {
|
|
523
|
+
this.pagedCallCount++;
|
|
524
|
+
return this.filtered.slice(0, 2);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
setSearch(search: string) { this.set({ search }); }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const vm = new PaginatedVM({ search: '', statusFilter: 'all', loading: false, error: null });
|
|
531
|
+
vm.collection.reset([
|
|
532
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
533
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
534
|
+
{ id: '3', name: 'Charlie', status: 'active' },
|
|
535
|
+
]);
|
|
536
|
+
vm.init();
|
|
537
|
+
|
|
538
|
+
// Initial access: filteredCount first, paged second
|
|
539
|
+
expect(vm.filteredCount).toBe(3);
|
|
540
|
+
expect(vm.paged).toHaveLength(2);
|
|
541
|
+
// filtered called once by filteredCount (Tier 3), then Tier 1 cache hit for paged
|
|
542
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
543
|
+
expect(vm.filteredCountCallCount).toBe(1);
|
|
544
|
+
expect(vm.pagedCallCount).toBe(1);
|
|
545
|
+
|
|
546
|
+
// Change a state dep (search) — both siblings must recompute
|
|
547
|
+
vm.setSearch('ali');
|
|
548
|
+
expect(vm.filteredCount).toBe(1);
|
|
549
|
+
expect(vm.paged).toHaveLength(1);
|
|
550
|
+
expect(vm.filteredCountCallCount).toBe(2);
|
|
551
|
+
expect(vm.pagedCallCount).toBe(2);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('three-level nesting: grandchild deps bubble through all levels', () => {
|
|
555
|
+
// Chain: summary → paged → sorted → filtered → collection
|
|
556
|
+
class DeepChainVM extends ViewModel<FilterState> {
|
|
557
|
+
collection = new TestCollection();
|
|
558
|
+
filteredCallCount = 0;
|
|
559
|
+
sortedCallCount = 0;
|
|
560
|
+
pagedCallCount = 0;
|
|
561
|
+
summaryCallCount = 0;
|
|
562
|
+
|
|
563
|
+
get filtered(): Item[] {
|
|
564
|
+
this.filteredCallCount++;
|
|
565
|
+
const { search } = this.state;
|
|
566
|
+
let items = [...this.collection.items];
|
|
567
|
+
if (search) {
|
|
568
|
+
const q = search.toLowerCase();
|
|
569
|
+
items = items.filter((i) => i.name.toLowerCase().includes(q));
|
|
570
|
+
}
|
|
571
|
+
return items;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
get sorted(): Item[] {
|
|
575
|
+
this.sortedCallCount++;
|
|
576
|
+
return [...this.filtered].sort((a, b) => a.name.localeCompare(b.name));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
get paged(): Item[] {
|
|
580
|
+
this.pagedCallCount++;
|
|
581
|
+
return this.sorted.slice(0, 2);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
get summary(): string {
|
|
585
|
+
this.summaryCallCount++;
|
|
586
|
+
return `Showing ${this.paged.length} of ${this.filtered.length}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
setSearch(search: string) { this.set({ search }); }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const vm = new DeepChainVM({ search: '', statusFilter: 'all', loading: false, error: null });
|
|
593
|
+
vm.init();
|
|
594
|
+
|
|
595
|
+
expect(vm.summary).toBe('Showing 0 of 0');
|
|
596
|
+
|
|
597
|
+
vm.collection.reset([
|
|
598
|
+
{ id: '1', name: 'Charlie', status: 'active' },
|
|
599
|
+
{ id: '2', name: 'Alice', status: 'active' },
|
|
600
|
+
{ id: '3', name: 'Bob', status: 'active' },
|
|
601
|
+
]);
|
|
602
|
+
|
|
603
|
+
expect(vm.summary).toBe('Showing 2 of 3');
|
|
604
|
+
expect(vm.paged[0].name).toBe('Alice'); // sorted
|
|
605
|
+
|
|
606
|
+
vm.setSearch('ali');
|
|
607
|
+
expect(vm.summary).toBe('Showing 1 of 1');
|
|
608
|
+
expect(vm.paged[0].name).toBe('Alice');
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Getter Inheritance
|
|
614
|
+
// ============================================================================
|
|
615
|
+
|
|
616
|
+
describe('Getter Inheritance', () => {
|
|
617
|
+
interface BaseState {
|
|
618
|
+
items: Item[];
|
|
619
|
+
search: string;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
class BaseListVM extends ViewModel<BaseState> {
|
|
623
|
+
isEmptyCallCount = 0;
|
|
624
|
+
|
|
625
|
+
get isEmpty(): boolean {
|
|
626
|
+
this.isEmptyCallCount++;
|
|
627
|
+
return this.state.items.length === 0;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
class DerivedVM extends BaseListVM {
|
|
632
|
+
filteredCallCount = 0;
|
|
633
|
+
|
|
634
|
+
get filtered(): Item[] {
|
|
635
|
+
this.filteredCallCount++;
|
|
636
|
+
return this.state.items.filter((i) => i.name.includes(this.state.search));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
setSearch(s: string) {
|
|
640
|
+
this.set({ search: s });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
setItems(items: Item[]) {
|
|
644
|
+
this.set({ items });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
it('getters from parent classes are memoized', () => {
|
|
649
|
+
const vm = new DerivedVM({
|
|
650
|
+
items: [],
|
|
651
|
+
search: '',
|
|
652
|
+
});
|
|
653
|
+
vm.init();
|
|
654
|
+
|
|
655
|
+
const isEmpty1 = vm.isEmpty;
|
|
656
|
+
expect(isEmpty1).toBe(true);
|
|
657
|
+
expect(vm.isEmptyCallCount).toBe(1);
|
|
658
|
+
|
|
659
|
+
const filtered1 = vm.filtered;
|
|
660
|
+
expect(filtered1).toEqual([]);
|
|
661
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('subclass getter override replaces parent', () => {
|
|
665
|
+
interface LabelState {
|
|
666
|
+
name: string;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
class ParentVM extends ViewModel<LabelState> {
|
|
670
|
+
get label(): string {
|
|
671
|
+
return 'parent';
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
class ChildVM extends ParentVM {
|
|
676
|
+
override get label(): string {
|
|
677
|
+
return this.state.name;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
setName(name: string) {
|
|
681
|
+
this.set({ name });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const vm = new ChildVM({ name: 'child' });
|
|
686
|
+
vm.init();
|
|
687
|
+
|
|
688
|
+
expect(vm.label).toBe('child');
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// ============================================================================
|
|
693
|
+
// Subscribable Auto-Detection
|
|
694
|
+
// ============================================================================
|
|
695
|
+
|
|
696
|
+
describe('Subscribable Auto-Detection', () => {
|
|
697
|
+
it('subscribable members are detected and subscribed', () => {
|
|
698
|
+
const vm = new FilterViewModel({
|
|
699
|
+
search: '',
|
|
700
|
+
statusFilter: 'all',
|
|
701
|
+
loading: false,
|
|
702
|
+
error: null,
|
|
703
|
+
});
|
|
704
|
+
vm.collection.reset([
|
|
705
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
706
|
+
]);
|
|
707
|
+
vm.init();
|
|
708
|
+
|
|
709
|
+
const listener = vi.fn();
|
|
710
|
+
vm.subscribe(listener);
|
|
711
|
+
|
|
712
|
+
vm.collection.reset([
|
|
713
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
714
|
+
]);
|
|
715
|
+
|
|
716
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('subscribable notification does not create new state reference', () => {
|
|
720
|
+
const vm = new FilterViewModel({
|
|
721
|
+
search: '',
|
|
722
|
+
statusFilter: 'all',
|
|
723
|
+
loading: false,
|
|
724
|
+
error: null,
|
|
725
|
+
});
|
|
726
|
+
vm.collection.reset([
|
|
727
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
728
|
+
]);
|
|
729
|
+
vm.init();
|
|
730
|
+
|
|
731
|
+
const stateBefore = vm.state;
|
|
732
|
+
|
|
733
|
+
vm.collection.reset([
|
|
734
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
735
|
+
]);
|
|
736
|
+
|
|
737
|
+
// State reference is unchanged — subscribable notifications no longer
|
|
738
|
+
// spread a new object, since React detection uses a version counter
|
|
739
|
+
expect(vm.state).toBe(stateBefore);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('non-subscribable members are ignored', () => {
|
|
743
|
+
interface VMState {
|
|
744
|
+
count: number;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
class VMWithPlainObject extends ViewModel<VMState> {
|
|
748
|
+
plainObject = { value: 42 };
|
|
749
|
+
|
|
750
|
+
get doubled(): number {
|
|
751
|
+
return this.state.count * 2;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const vm = new VMWithPlainObject({ count: 5 });
|
|
756
|
+
vm.init();
|
|
757
|
+
|
|
758
|
+
// Should not throw, plainObject is ignored
|
|
759
|
+
expect(vm.doubled).toBe(10);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('subscribable notification bumps revision and triggers notify', () => {
|
|
763
|
+
const vm = new FilterViewModel({
|
|
764
|
+
search: '',
|
|
765
|
+
statusFilter: 'all',
|
|
766
|
+
loading: false,
|
|
767
|
+
error: null,
|
|
768
|
+
});
|
|
769
|
+
vm.collection.reset([
|
|
770
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
771
|
+
]);
|
|
772
|
+
vm.init();
|
|
773
|
+
|
|
774
|
+
const listener = vi.fn();
|
|
775
|
+
vm.subscribe(listener);
|
|
776
|
+
|
|
777
|
+
vm.collection.reset([
|
|
778
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
779
|
+
]);
|
|
780
|
+
|
|
781
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
782
|
+
// Listener called with (state, state) — same object reference
|
|
783
|
+
expect(listener).toHaveBeenCalledWith(vm.state, vm.state);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('multiple subscribable members tracked independently', () => {
|
|
787
|
+
interface TwoCollectionsState {
|
|
788
|
+
label: string;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
class TwoCollectionsVM extends ViewModel<TwoCollectionsState> {
|
|
792
|
+
collection1 = new TestCollection();
|
|
793
|
+
collection2 = new TestCollection();
|
|
794
|
+
|
|
795
|
+
collection1Count = 0;
|
|
796
|
+
collection2Count = 0;
|
|
797
|
+
|
|
798
|
+
get fromCollection1() {
|
|
799
|
+
this.collection1Count++;
|
|
800
|
+
return this.collection1.items;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
get fromCollection2() {
|
|
804
|
+
this.collection2Count++;
|
|
805
|
+
return this.collection2.items;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const vm = new TwoCollectionsVM({ label: 'test' });
|
|
810
|
+
vm.collection1.reset([{ id: '1', name: 'Alice' }]);
|
|
811
|
+
vm.collection2.reset([{ id: '2', name: 'Bob' }]);
|
|
812
|
+
vm.init();
|
|
813
|
+
|
|
814
|
+
const result1 = vm.fromCollection1;
|
|
815
|
+
expect(result1).toHaveLength(1);
|
|
816
|
+
expect(vm.collection1Count).toBe(1);
|
|
817
|
+
|
|
818
|
+
// Change collection2 - should NOT invalidate fromCollection1
|
|
819
|
+
vm.collection2.reset([{ id: '3', name: 'Charlie' }]);
|
|
820
|
+
const result1Again = vm.fromCollection1;
|
|
821
|
+
expect(result1Again).toBe(result1); // Same reference
|
|
822
|
+
expect(vm.collection1Count).toBe(1); // No recompute
|
|
823
|
+
|
|
824
|
+
// Change collection1 - SHOULD invalidate fromCollection1
|
|
825
|
+
vm.collection1.reset([{ id: '4', name: 'Diana' }]);
|
|
826
|
+
const result1Updated = vm.fromCollection1;
|
|
827
|
+
expect(result1Updated).not.toBe(result1);
|
|
828
|
+
expect(vm.collection1Count).toBe(2);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('subscribable member async state changes invalidate parent getters', async () => {
|
|
832
|
+
let resolveLoad!: () => void;
|
|
833
|
+
|
|
834
|
+
class ChildVM extends ViewModel {
|
|
835
|
+
async loadData() {
|
|
836
|
+
await new Promise<void>((r) => { resolveLoad = r; });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
class ParentVM extends ViewModel {
|
|
841
|
+
childVM = new ChildVM();
|
|
842
|
+
|
|
843
|
+
get isChildLoading() {
|
|
844
|
+
return this.childVM.async.loadData.loading;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
onInit() {
|
|
848
|
+
this.childVM.init();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
onDispose() {
|
|
852
|
+
this.childVM.dispose();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const parent = new ParentVM();
|
|
857
|
+
parent.init();
|
|
858
|
+
|
|
859
|
+
// Initially not loading
|
|
860
|
+
expect(parent.isChildLoading).toBe(false);
|
|
861
|
+
|
|
862
|
+
// Start the async operation on the child
|
|
863
|
+
const loadPromise = parent.childVM.loadData();
|
|
864
|
+
// Allow microtask to propagate async tracking
|
|
865
|
+
await Promise.resolve();
|
|
866
|
+
|
|
867
|
+
// Parent getter should now reflect loading = true
|
|
868
|
+
expect(parent.isChildLoading).toBe(true);
|
|
869
|
+
|
|
870
|
+
// Verify parent.subscribe fires on child async changes
|
|
871
|
+
const listener = vi.fn();
|
|
872
|
+
parent.subscribe(listener);
|
|
873
|
+
|
|
874
|
+
// Resolve the async operation
|
|
875
|
+
resolveLoad();
|
|
876
|
+
await loadPromise;
|
|
877
|
+
|
|
878
|
+
// Parent getter should return to false
|
|
879
|
+
expect(parent.isChildLoading).toBe(false);
|
|
880
|
+
// Listener should have been called (child async → parent notification)
|
|
881
|
+
expect(listener).toHaveBeenCalled();
|
|
882
|
+
|
|
883
|
+
parent.dispose();
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// ============================================================================
|
|
888
|
+
// Subscribable Dependencies in Getters
|
|
889
|
+
// ============================================================================
|
|
890
|
+
|
|
891
|
+
describe('Subscribable Dependencies in Getters', () => {
|
|
892
|
+
it('getter that reads collection depends on collection', () => {
|
|
893
|
+
const vm = new CountedFilterViewModel({
|
|
894
|
+
search: '',
|
|
895
|
+
statusFilter: 'all',
|
|
896
|
+
loading: false,
|
|
897
|
+
error: null,
|
|
898
|
+
});
|
|
899
|
+
vm.collection.reset([
|
|
900
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
901
|
+
]);
|
|
902
|
+
vm.init();
|
|
903
|
+
|
|
904
|
+
const result1 = vm.filtered;
|
|
905
|
+
expect(result1).toHaveLength(1);
|
|
906
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
907
|
+
|
|
908
|
+
vm.collection.reset([
|
|
909
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
910
|
+
{ id: '3', name: 'Charlie', status: 'active' },
|
|
911
|
+
]);
|
|
912
|
+
const result2 = vm.filtered;
|
|
913
|
+
expect(result2).toHaveLength(2);
|
|
914
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('getter does not recompute when unrelated subscribable changes', () => {
|
|
918
|
+
interface TwoCollectionsState {
|
|
919
|
+
label: string;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
class TwoCollectionsVM extends ViewModel<TwoCollectionsState> {
|
|
923
|
+
collection1 = new TestCollection();
|
|
924
|
+
collection2 = new TestCollection();
|
|
925
|
+
callCount = 0;
|
|
926
|
+
|
|
927
|
+
get fromCollection1() {
|
|
928
|
+
this.callCount++;
|
|
929
|
+
return this.collection1.items;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const vm = new TwoCollectionsVM({ label: 'test' });
|
|
934
|
+
vm.collection1.reset([{ id: '1', name: 'Alice' }]);
|
|
935
|
+
vm.collection2.reset([{ id: '2', name: 'Bob' }]);
|
|
936
|
+
vm.init();
|
|
937
|
+
|
|
938
|
+
const result1 = vm.fromCollection1;
|
|
939
|
+
expect(result1).toHaveLength(1);
|
|
940
|
+
expect(vm.callCount).toBe(1);
|
|
941
|
+
|
|
942
|
+
// Change unrelated collection2
|
|
943
|
+
vm.collection2.reset([{ id: '3', name: 'Charlie' }]);
|
|
944
|
+
const result2 = vm.fromCollection1;
|
|
945
|
+
expect(result2).toBe(result1); // Same reference
|
|
946
|
+
expect(vm.callCount).toBe(1); // No recompute
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('getter with mixed state + subscribable deps', () => {
|
|
950
|
+
const vm = new CountedFilterViewModel({
|
|
951
|
+
search: '',
|
|
952
|
+
statusFilter: 'all',
|
|
953
|
+
loading: false,
|
|
954
|
+
error: null,
|
|
955
|
+
});
|
|
956
|
+
vm.collection.reset([
|
|
957
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
958
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
959
|
+
]);
|
|
960
|
+
vm.init();
|
|
961
|
+
|
|
962
|
+
// Initial access
|
|
963
|
+
const result1 = vm.filtered;
|
|
964
|
+
expect(result1).toHaveLength(2);
|
|
965
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
966
|
+
|
|
967
|
+
// 1) Change state dep -> recompute
|
|
968
|
+
vm.setSearch('ali');
|
|
969
|
+
const result2 = vm.filtered;
|
|
970
|
+
expect(result2).toHaveLength(1);
|
|
971
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
972
|
+
|
|
973
|
+
// 2) Change unrelated state -> no recompute
|
|
974
|
+
vm.setLoading(true);
|
|
975
|
+
const result3 = vm.filtered;
|
|
976
|
+
expect(result3).toBe(result2);
|
|
977
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
978
|
+
|
|
979
|
+
// 3) Change collection -> recompute
|
|
980
|
+
vm.collection.reset([
|
|
981
|
+
{ id: '3', name: 'Alice Cooper', status: 'active' },
|
|
982
|
+
]);
|
|
983
|
+
const result4 = vm.filtered;
|
|
984
|
+
expect(result4).toHaveLength(1);
|
|
985
|
+
expect(result4[0].name).toBe('Alice Cooper');
|
|
986
|
+
expect(vm.filteredCallCount).toBe(3);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// ============================================================================
|
|
991
|
+
// Cleanup and Dispose
|
|
992
|
+
// ============================================================================
|
|
993
|
+
|
|
994
|
+
describe('Cleanup and Dispose', () => {
|
|
995
|
+
it('dispose unsubscribes from all tracked sources', () => {
|
|
996
|
+
const vm = new FilterViewModel({
|
|
997
|
+
search: '',
|
|
998
|
+
statusFilter: 'all',
|
|
999
|
+
loading: false,
|
|
1000
|
+
error: null,
|
|
1001
|
+
});
|
|
1002
|
+
vm.collection.reset([
|
|
1003
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
1004
|
+
]);
|
|
1005
|
+
vm.init();
|
|
1006
|
+
|
|
1007
|
+
const listener = vi.fn();
|
|
1008
|
+
vm.subscribe(listener);
|
|
1009
|
+
|
|
1010
|
+
// Trigger notification before dispose
|
|
1011
|
+
vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
|
|
1012
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1013
|
+
|
|
1014
|
+
vm.dispose();
|
|
1015
|
+
|
|
1016
|
+
// After dispose, collection changes should not trigger VM notifications
|
|
1017
|
+
vm.collection.reset([{ id: '3', name: 'Charlie', status: 'active' }]);
|
|
1018
|
+
expect(listener).toHaveBeenCalledTimes(1); // Still 1, no new calls
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it('collection changes after dispose do not trigger notify', () => {
|
|
1022
|
+
const vm = new FilterViewModel({
|
|
1023
|
+
search: '',
|
|
1024
|
+
statusFilter: 'all',
|
|
1025
|
+
loading: false,
|
|
1026
|
+
error: null,
|
|
1027
|
+
});
|
|
1028
|
+
vm.collection.reset([
|
|
1029
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
1030
|
+
]);
|
|
1031
|
+
vm.init();
|
|
1032
|
+
|
|
1033
|
+
const listener = vi.fn();
|
|
1034
|
+
vm.subscribe(listener);
|
|
1035
|
+
|
|
1036
|
+
vm.dispose();
|
|
1037
|
+
|
|
1038
|
+
vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
|
|
1039
|
+
expect(listener).not.toHaveBeenCalled();
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('getter access after dispose returns last cached value', () => {
|
|
1043
|
+
const vm = new CountedFilterViewModel({
|
|
1044
|
+
search: '',
|
|
1045
|
+
statusFilter: 'all',
|
|
1046
|
+
loading: false,
|
|
1047
|
+
error: null,
|
|
1048
|
+
});
|
|
1049
|
+
vm.collection.reset([
|
|
1050
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
1051
|
+
]);
|
|
1052
|
+
vm.init();
|
|
1053
|
+
|
|
1054
|
+
const result1 = vm.filtered;
|
|
1055
|
+
expect(result1).toHaveLength(1);
|
|
1056
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1057
|
+
|
|
1058
|
+
vm.dispose();
|
|
1059
|
+
|
|
1060
|
+
const result2 = vm.filtered;
|
|
1061
|
+
expect(result2).toBe(result1); // Same cached value
|
|
1062
|
+
expect(vm.filteredCallCount).toBe(1); // No recompute
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// ============================================================================
|
|
1067
|
+
// Revision Counter
|
|
1068
|
+
// ============================================================================
|
|
1069
|
+
|
|
1070
|
+
describe('Revision Counter', () => {
|
|
1071
|
+
it('revision starts at 0 (implicit via Tier 3 behavior)', () => {
|
|
1072
|
+
const vm = new CountedFilterViewModel({
|
|
1073
|
+
search: '',
|
|
1074
|
+
statusFilter: 'all',
|
|
1075
|
+
loading: false,
|
|
1076
|
+
error: null,
|
|
1077
|
+
});
|
|
1078
|
+
vm.init();
|
|
1079
|
+
|
|
1080
|
+
// First getter access always computes (revision 0 -> Tier 3)
|
|
1081
|
+
// @ts-expect-error
|
|
1082
|
+
const result = vm.filtered;
|
|
1083
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('set() with actual changes increments revision', () => {
|
|
1087
|
+
const vm = new CountedFilterViewModel({
|
|
1088
|
+
search: '',
|
|
1089
|
+
statusFilter: 'all',
|
|
1090
|
+
loading: false,
|
|
1091
|
+
error: null,
|
|
1092
|
+
});
|
|
1093
|
+
vm.init();
|
|
1094
|
+
|
|
1095
|
+
vm.filtered; // Initial compute
|
|
1096
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1097
|
+
|
|
1098
|
+
vm.setSearch('ali'); // Changes state -> increments revision
|
|
1099
|
+
vm.filtered; // Should recompute
|
|
1100
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it('set() with no changes does NOT increment revision', () => {
|
|
1104
|
+
const vm = new CountedFilterViewModel({
|
|
1105
|
+
search: 'ali',
|
|
1106
|
+
statusFilter: 'all',
|
|
1107
|
+
loading: false,
|
|
1108
|
+
error: null,
|
|
1109
|
+
});
|
|
1110
|
+
vm.init();
|
|
1111
|
+
|
|
1112
|
+
vm.filtered; // Initial compute
|
|
1113
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1114
|
+
|
|
1115
|
+
vm.setSearch('ali'); // Same value -> no change, no revision increment
|
|
1116
|
+
vm.filtered; // Should NOT recompute
|
|
1117
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('subscribable notification increments revision', () => {
|
|
1121
|
+
const vm = new CountedFilterViewModel({
|
|
1122
|
+
search: '',
|
|
1123
|
+
statusFilter: 'all',
|
|
1124
|
+
loading: false,
|
|
1125
|
+
error: null,
|
|
1126
|
+
});
|
|
1127
|
+
vm.collection.reset([{ id: '1', name: 'Alice', status: 'active' }]);
|
|
1128
|
+
vm.init();
|
|
1129
|
+
|
|
1130
|
+
vm.filtered; // Initial compute
|
|
1131
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1132
|
+
|
|
1133
|
+
vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
|
|
1134
|
+
vm.filtered; // Should recompute due to revision increment
|
|
1135
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('multiple rapid set() calls each increment revision', () => {
|
|
1139
|
+
const vm = new CountedFilterViewModel({
|
|
1140
|
+
search: '',
|
|
1141
|
+
statusFilter: 'all',
|
|
1142
|
+
loading: false,
|
|
1143
|
+
error: null,
|
|
1144
|
+
});
|
|
1145
|
+
vm.init();
|
|
1146
|
+
|
|
1147
|
+
vm.filtered;
|
|
1148
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
1149
|
+
|
|
1150
|
+
vm.setSearch('a');
|
|
1151
|
+
vm.setSearch('ab');
|
|
1152
|
+
vm.setSearch('abc');
|
|
1153
|
+
|
|
1154
|
+
vm.filtered;
|
|
1155
|
+
expect(vm.filteredCallCount).toBe(2); // Only one recompute after all changes
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
// ============================================================================
|
|
1160
|
+
// Dev-Mode Safety
|
|
1161
|
+
// ============================================================================
|
|
1162
|
+
|
|
1163
|
+
describe(' Dev-Mode Safety', () => {
|
|
1164
|
+
it('set() inside a getter logs error when __MVC_KIT_DEV__ is enabled', () => {
|
|
1165
|
+
interface BadState {
|
|
1166
|
+
count: number;
|
|
1167
|
+
computed: string;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
class BadVM extends ViewModel<BadState> {
|
|
1171
|
+
get broken(): string {
|
|
1172
|
+
this.publicSet({ count: 999 }); // BAD!
|
|
1173
|
+
return 'bad';
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
publicSet(p: Partial<BadState>) {
|
|
1177
|
+
this.set(p);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const vm = new BadVM({ count: 0, computed: '' });
|
|
1182
|
+
vm.init();
|
|
1183
|
+
|
|
1184
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1185
|
+
|
|
1186
|
+
vm.broken;
|
|
1187
|
+
|
|
1188
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
1189
|
+
expect(consoleErrorSpy.mock.calls[0][0]).toMatch(/set\(\) called inside a getter/i);
|
|
1190
|
+
expect(vm.state.count).toBe(0); // State NOT mutated
|
|
1191
|
+
|
|
1192
|
+
consoleErrorSpy.mockRestore();
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// ============================================================================
|
|
1197
|
+
// Multiple Instances
|
|
1198
|
+
// ============================================================================
|
|
1199
|
+
|
|
1200
|
+
describe(' Multiple Instances', () => {
|
|
1201
|
+
it('two instances of same ViewModel class have independent caches', () => {
|
|
1202
|
+
const vm1 = new CountedFilterViewModel({
|
|
1203
|
+
search: '',
|
|
1204
|
+
statusFilter: 'all',
|
|
1205
|
+
loading: false,
|
|
1206
|
+
error: null,
|
|
1207
|
+
});
|
|
1208
|
+
vm1.collection.reset([{ id: '1', name: 'Alice', status: 'active' }]);
|
|
1209
|
+
vm1.init();
|
|
1210
|
+
|
|
1211
|
+
const vm2 = new CountedFilterViewModel({
|
|
1212
|
+
search: 'bob',
|
|
1213
|
+
statusFilter: 'all',
|
|
1214
|
+
loading: false,
|
|
1215
|
+
error: null,
|
|
1216
|
+
});
|
|
1217
|
+
vm2.collection.reset([
|
|
1218
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
1219
|
+
{ id: '3', name: 'Alice', status: 'active' },
|
|
1220
|
+
]);
|
|
1221
|
+
vm2.init();
|
|
1222
|
+
|
|
1223
|
+
const result1 = vm1.filtered;
|
|
1224
|
+
const result2 = vm2.filtered;
|
|
1225
|
+
|
|
1226
|
+
expect(result1).toHaveLength(1);
|
|
1227
|
+
expect(result1[0].name).toBe('Alice');
|
|
1228
|
+
|
|
1229
|
+
expect(result2).toHaveLength(1);
|
|
1230
|
+
expect(result2[0].name).toBe('Bob');
|
|
1231
|
+
|
|
1232
|
+
// Changes to vm1 should not affect vm2
|
|
1233
|
+
vm1.setSearch('xyz');
|
|
1234
|
+
const result1Updated = vm1.filtered;
|
|
1235
|
+
const result2Again = vm2.filtered;
|
|
1236
|
+
|
|
1237
|
+
expect(result1Updated).toHaveLength(0);
|
|
1238
|
+
expect(result2Again).toBe(result2); // vm2 unchanged
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
it('two instances sharing a singleton collection both react', () => {
|
|
1242
|
+
const sharedCollection = new TestCollection();
|
|
1243
|
+
sharedCollection.reset([
|
|
1244
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
1245
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
1246
|
+
]);
|
|
1247
|
+
|
|
1248
|
+
class SharedCollectionVM extends ViewModel<FilterState> {
|
|
1249
|
+
constructor(initialState: FilterState, public collection: TestCollection) {
|
|
1250
|
+
super(initialState);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
get filtered() {
|
|
1254
|
+
const { search } = this.state;
|
|
1255
|
+
if (!search) return this.collection.items;
|
|
1256
|
+
return this.collection.items.filter((i) =>
|
|
1257
|
+
i.name.toLowerCase().includes(search.toLowerCase())
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const vm1 = new SharedCollectionVM(
|
|
1263
|
+
{ search: '', statusFilter: 'all', loading: false, error: null },
|
|
1264
|
+
sharedCollection
|
|
1265
|
+
);
|
|
1266
|
+
const vm2 = new SharedCollectionVM(
|
|
1267
|
+
{ search: '', statusFilter: 'all', loading: false, error: null },
|
|
1268
|
+
sharedCollection
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
vm1.init();
|
|
1272
|
+
vm2.init();
|
|
1273
|
+
|
|
1274
|
+
const listener1 = vi.fn();
|
|
1275
|
+
const listener2 = vi.fn();
|
|
1276
|
+
vm1.subscribe(listener1);
|
|
1277
|
+
vm2.subscribe(listener2);
|
|
1278
|
+
|
|
1279
|
+
// Load data into shared collection
|
|
1280
|
+
sharedCollection.reset([
|
|
1281
|
+
{ id: '3', name: 'Charlie', status: 'active' },
|
|
1282
|
+
]);
|
|
1283
|
+
|
|
1284
|
+
expect(listener1).toHaveBeenCalledTimes(1);
|
|
1285
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
1286
|
+
|
|
1287
|
+
expect(vm1.filtered).toHaveLength(1);
|
|
1288
|
+
expect(vm2.filtered).toHaveLength(1);
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// ============================================================================
|
|
1293
|
+
// Edge Cases
|
|
1294
|
+
// ============================================================================
|
|
1295
|
+
|
|
1296
|
+
describe(' Edge Cases', () => {
|
|
1297
|
+
it('getter with zero dependencies', () => {
|
|
1298
|
+
interface ZeroDepState {
|
|
1299
|
+
count: number;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
class ZeroDepVM extends ViewModel<ZeroDepState> {
|
|
1303
|
+
callCount = 0;
|
|
1304
|
+
|
|
1305
|
+
get constant(): string {
|
|
1306
|
+
this.callCount++;
|
|
1307
|
+
return 'always the same';
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
setCount(count: number) {
|
|
1311
|
+
this.set({ count });
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const vm = new ZeroDepVM({ count: 0 });
|
|
1316
|
+
vm.init();
|
|
1317
|
+
|
|
1318
|
+
const result1 = vm.constant;
|
|
1319
|
+
expect(result1).toBe('always the same');
|
|
1320
|
+
expect(vm.callCount).toBe(1);
|
|
1321
|
+
|
|
1322
|
+
// Change state - getter should NOT recompute (no dependencies)
|
|
1323
|
+
vm.setCount(10);
|
|
1324
|
+
const result2 = vm.constant;
|
|
1325
|
+
expect(result2).toBe(result1);
|
|
1326
|
+
expect(vm.callCount).toBe(1);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('getter that throws an error', () => {
|
|
1330
|
+
interface ErrorState {
|
|
1331
|
+
shouldThrow: boolean;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
class ErrorVM extends ViewModel<ErrorState> {
|
|
1335
|
+
callCount = 0;
|
|
1336
|
+
|
|
1337
|
+
get maybeThrow(): string {
|
|
1338
|
+
this.callCount++;
|
|
1339
|
+
if (this.state.shouldThrow) {
|
|
1340
|
+
throw new Error('getter error');
|
|
1341
|
+
}
|
|
1342
|
+
return 'ok';
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
setThrow(shouldThrow: boolean) {
|
|
1346
|
+
this.set({ shouldThrow });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const vm = new ErrorVM({ shouldThrow: true });
|
|
1351
|
+
vm.init();
|
|
1352
|
+
|
|
1353
|
+
expect(() => vm.maybeThrow).toThrow('getter error');
|
|
1354
|
+
expect(vm.callCount).toBe(1);
|
|
1355
|
+
|
|
1356
|
+
// Cache NOT populated, next access re-throws
|
|
1357
|
+
expect(() => vm.maybeThrow).toThrow('getter error');
|
|
1358
|
+
expect(vm.callCount).toBe(2);
|
|
1359
|
+
|
|
1360
|
+
// Fix the state
|
|
1361
|
+
vm.setThrow(false);
|
|
1362
|
+
const result = vm.maybeThrow;
|
|
1363
|
+
expect(result).toBe('ok');
|
|
1364
|
+
expect(vm.callCount).toBe(3);
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
it('getter accessing deeply nested collection data', () => {
|
|
1368
|
+
class DeepAccessVM extends ViewModel<{ label: string }> {
|
|
1369
|
+
collection = new TestCollection();
|
|
1370
|
+
|
|
1371
|
+
get firstName(): string | undefined {
|
|
1372
|
+
return this.collection.items[0]?.name;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const vm = new DeepAccessVM({ label: 'test' });
|
|
1377
|
+
vm.init();
|
|
1378
|
+
|
|
1379
|
+
// Empty collection
|
|
1380
|
+
const result1 = vm.firstName;
|
|
1381
|
+
expect(result1).toBeUndefined();
|
|
1382
|
+
|
|
1383
|
+
// Populate collection
|
|
1384
|
+
vm.collection.reset([{ id: '1', name: 'Alice' }]);
|
|
1385
|
+
const result2 = vm.firstName;
|
|
1386
|
+
expect(result2).toBe('Alice');
|
|
1387
|
+
|
|
1388
|
+
// Clear collection
|
|
1389
|
+
vm.collection.reset([]);
|
|
1390
|
+
const result3 = vm.firstName;
|
|
1391
|
+
expect(result3).toBeUndefined();
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('rapid state changes within same synchronous frame', () => {
|
|
1395
|
+
const vm = new CountedFilterViewModel({
|
|
1396
|
+
search: '',
|
|
1397
|
+
statusFilter: 'all',
|
|
1398
|
+
loading: false,
|
|
1399
|
+
error: null,
|
|
1400
|
+
});
|
|
1401
|
+
vm.collection.reset([
|
|
1402
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
1403
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
1404
|
+
{ id: '3', name: 'Alice Smith', status: 'active' },
|
|
1405
|
+
]);
|
|
1406
|
+
vm.init();
|
|
1407
|
+
|
|
1408
|
+
// Multiple rapid changes
|
|
1409
|
+
vm.setSearch('a');
|
|
1410
|
+
vm.setSearch('al');
|
|
1411
|
+
vm.setSearch('ali');
|
|
1412
|
+
|
|
1413
|
+
// After all changes, getter reflects final state
|
|
1414
|
+
const result = vm.filtered;
|
|
1415
|
+
expect(result).toHaveLength(2); // Alice and Alice Smith
|
|
1416
|
+
expect(vm.filteredCallCount).toBe(1); // Computed once after all changes
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
// ============================================================================
|
|
1421
|
+
// Collection Data via Getters (without subscribeTo)
|
|
1422
|
+
// ============================================================================
|
|
1423
|
+
|
|
1424
|
+
describe('Collection Data via Getters (without subscribeTo)', () => {
|
|
1425
|
+
it('getters read collection data directly without subscribeTo or set()', () => {
|
|
1426
|
+
// This is the prescribed pattern: getters read from collection members
|
|
1427
|
+
// and auto-tracking handles reactivity. No subscribeTo() + set() needed.
|
|
1428
|
+
const vm = new CountedFilterViewModel({
|
|
1429
|
+
search: '',
|
|
1430
|
+
statusFilter: 'all',
|
|
1431
|
+
loading: false,
|
|
1432
|
+
error: null,
|
|
1433
|
+
});
|
|
1434
|
+
vm.init();
|
|
1435
|
+
|
|
1436
|
+
// Initially empty
|
|
1437
|
+
expect(vm.filtered).toEqual([]);
|
|
1438
|
+
expect(vm.total).toBe(0);
|
|
1439
|
+
|
|
1440
|
+
// Populate collection — getter recomputes via auto-tracked subscribable
|
|
1441
|
+
vm.collection.reset([
|
|
1442
|
+
{ id: '1', name: 'Alice', status: 'active' },
|
|
1443
|
+
{ id: '2', name: 'Bob', status: 'inactive' },
|
|
1444
|
+
]);
|
|
1445
|
+
expect(vm.filtered).toHaveLength(2);
|
|
1446
|
+
expect(vm.total).toBe(2);
|
|
1447
|
+
|
|
1448
|
+
// Update collection — getter reflects new data
|
|
1449
|
+
vm.collection.update('1', { id: '1', name: 'Alice Updated', status: 'active' });
|
|
1450
|
+
expect(vm.filtered[0].name).toBe('Alice Updated');
|
|
1451
|
+
|
|
1452
|
+
// Filter via state — works with collection data
|
|
1453
|
+
vm.setSearch('bob');
|
|
1454
|
+
expect(vm.filtered).toHaveLength(1);
|
|
1455
|
+
expect(vm.filtered[0].name).toBe('Bob');
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it('subscriber notified on collection change without subscribeTo', () => {
|
|
1459
|
+
const vm = new FilterViewModel({
|
|
1460
|
+
search: '',
|
|
1461
|
+
statusFilter: 'all',
|
|
1462
|
+
loading: false,
|
|
1463
|
+
error: null,
|
|
1464
|
+
});
|
|
1465
|
+
vm.init();
|
|
1466
|
+
|
|
1467
|
+
const listener = vi.fn();
|
|
1468
|
+
vm.subscribe(listener);
|
|
1469
|
+
|
|
1470
|
+
// Collection change triggers notification to subscribers
|
|
1471
|
+
// (auto-tracking detects the subscribable and wires it up)
|
|
1472
|
+
vm.collection.reset([{ id: '1', name: 'Alice', status: 'active' }]);
|
|
1473
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1474
|
+
|
|
1475
|
+
// Subsequent changes also notify
|
|
1476
|
+
vm.collection.reset([{ id: '2', name: 'Bob', status: 'inactive' }]);
|
|
1477
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('stateless ViewModel (empty state) reads all data from collections', () => {
|
|
1481
|
+
// Pattern: ViewModel with S = {} and all data via getters
|
|
1482
|
+
class StatelessDashboardVM extends ViewModel {
|
|
1483
|
+
collection1 = new TestCollection();
|
|
1484
|
+
collection2 = new TestCollection();
|
|
1485
|
+
|
|
1486
|
+
get totalItems(): number {
|
|
1487
|
+
return this.collection1.length + this.collection2.length;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
get allItems(): Item[] {
|
|
1491
|
+
return [...this.collection1.items, ...this.collection2.items];
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const vm = new StatelessDashboardVM();
|
|
1496
|
+
vm.init();
|
|
1497
|
+
|
|
1498
|
+
expect(vm.totalItems).toBe(0);
|
|
1499
|
+
expect(vm.allItems).toEqual([]);
|
|
1500
|
+
|
|
1501
|
+
vm.collection1.reset([{ id: '1', name: 'Alice' }]);
|
|
1502
|
+
expect(vm.totalItems).toBe(1);
|
|
1503
|
+
|
|
1504
|
+
vm.collection2.reset([{ id: '2', name: 'Bob' }, { id: '3', name: 'Charlie' }]);
|
|
1505
|
+
expect(vm.totalItems).toBe(3);
|
|
1506
|
+
expect(vm.allItems).toHaveLength(3);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('draft mode: getter invalidates correctly on nested mutation', () => {
|
|
1510
|
+
interface ConfigState {
|
|
1511
|
+
config: { theme: string; fontSize: number };
|
|
1512
|
+
count: number;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
class ConfigVM extends ViewModel<ConfigState> {
|
|
1516
|
+
computeCount = 0;
|
|
1517
|
+
|
|
1518
|
+
get currentTheme() {
|
|
1519
|
+
this.computeCount++;
|
|
1520
|
+
return this.state.config.theme;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
setTheme(theme: string) {
|
|
1524
|
+
this.set((d) => { d.config.theme = theme; });
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
bumpCount() {
|
|
1528
|
+
this.set((d) => { d.count = d.count + 1; });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const vm = new ConfigVM({ config: { theme: 'dark', fontSize: 14 }, count: 0 });
|
|
1533
|
+
vm.init();
|
|
1534
|
+
|
|
1535
|
+
// First access → Tier 3 recompute
|
|
1536
|
+
expect(vm.currentTheme).toBe('dark');
|
|
1537
|
+
const countAfterFirst = vm.computeCount;
|
|
1538
|
+
|
|
1539
|
+
// Same access → Tier 1 (no revision change)
|
|
1540
|
+
expect(vm.currentTheme).toBe('dark');
|
|
1541
|
+
expect(vm.computeCount).toBe(countAfterFirst); // No recompute
|
|
1542
|
+
|
|
1543
|
+
// Draft mutation on a dep of this getter
|
|
1544
|
+
vm.setTheme('light');
|
|
1545
|
+
expect(vm.currentTheme).toBe('light');
|
|
1546
|
+
expect(vm.computeCount).toBe(countAfterFirst + 1); // Recomputed
|
|
1547
|
+
|
|
1548
|
+
// Draft mutation on unrelated state — getter depends on config, not count
|
|
1549
|
+
vm.bumpCount();
|
|
1550
|
+
expect(vm.currentTheme).toBe('light');
|
|
1551
|
+
// Tier 2 should detect that config reference didn't change → no recompute
|
|
1552
|
+
expect(vm.computeCount).toBe(countAfterFirst + 1);
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
it('smart-init pattern works: skip load when collection has data', () => {
|
|
1556
|
+
class SmartInitVM extends ViewModel {
|
|
1557
|
+
collection = new TestCollection();
|
|
1558
|
+
loadCalled = false;
|
|
1559
|
+
|
|
1560
|
+
protected onInit() {
|
|
1561
|
+
if (this.collection.length === 0) {
|
|
1562
|
+
this.loadCalled = true;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
get items() {
|
|
1567
|
+
return this.collection.items
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Scenario 1: empty collection → load is triggered
|
|
1572
|
+
const vm1 = new SmartInitVM();
|
|
1573
|
+
vm1.init();
|
|
1574
|
+
expect(vm1.loadCalled).toBe(true);
|
|
1575
|
+
|
|
1576
|
+
// Scenario 2: pre-populated collection → load is skipped
|
|
1577
|
+
const vm2 = new SmartInitVM();
|
|
1578
|
+
vm2.collection.reset([{ id: '1', name: 'Alice' }]);
|
|
1579
|
+
vm2.init();
|
|
1580
|
+
expect(vm2.loadCalled).toBe(false);
|
|
1581
|
+
expect(vm2.items).toHaveLength(1);
|
|
1582
|
+
});
|
|
1583
|
+
});
|