mvc-kit 2.12.4 → 2.13.0
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 +4 -3
- package/agent-config/bin/setup.mjs +5 -1
- package/agent-config/claude-code/agents/mvc-kit-architect.md +11 -8
- package/agent-config/claude-code/skills/guide/SKILL.md +20 -7
- package/agent-config/claude-code/skills/guide/patterns.md +12 -0
- package/agent-config/claude-code/skills/guide/recipes.md +510 -0
- package/agent-config/claude-code/skills/guide/testing.md +297 -0
- package/agent-config/claude-code/skills/review/SKILL.md +3 -13
- package/agent-config/claude-code/skills/review/checklist.md +30 -5
- package/agent-config/claude-code/skills/scaffold/SKILL.md +4 -13
- package/agent-config/lib/install-claude.mjs +84 -25
- package/dist/Channel.cjs +276 -300
- package/dist/Channel.cjs.map +1 -1
- package/dist/Channel.js +275 -299
- package/dist/Channel.js.map +1 -1
- package/dist/Collection.cjs +424 -504
- package/dist/Collection.cjs.map +1 -1
- package/dist/Collection.js +423 -503
- package/dist/Collection.js.map +1 -1
- package/dist/Controller.cjs +70 -67
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.js +69 -66
- package/dist/Controller.js.map +1 -1
- package/dist/EventBus.cjs +77 -88
- package/dist/EventBus.cjs.map +1 -1
- package/dist/EventBus.js +76 -87
- package/dist/EventBus.js.map +1 -1
- package/dist/Feed.cjs +81 -77
- package/dist/Feed.cjs.map +1 -1
- package/dist/Feed.js +80 -76
- package/dist/Feed.js.map +1 -1
- package/dist/Model.cjs +181 -207
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.js +179 -205
- package/dist/Model.js.map +1 -1
- package/dist/Pagination.cjs +75 -73
- package/dist/Pagination.cjs.map +1 -1
- package/dist/Pagination.js +74 -72
- package/dist/Pagination.js.map +1 -1
- package/dist/Pending.cjs +255 -287
- package/dist/Pending.cjs.map +1 -1
- package/dist/Pending.js +253 -285
- package/dist/Pending.js.map +1 -1
- package/dist/PersistentCollection.cjs +242 -285
- package/dist/PersistentCollection.cjs.map +1 -1
- package/dist/PersistentCollection.js +241 -284
- package/dist/PersistentCollection.js.map +1 -1
- package/dist/Resource.cjs +166 -174
- package/dist/Resource.cjs.map +1 -1
- package/dist/Resource.js +164 -172
- package/dist/Resource.js.map +1 -1
- package/dist/Selection.cjs +84 -94
- package/dist/Selection.cjs.map +1 -1
- package/dist/Selection.js +83 -93
- package/dist/Selection.js.map +1 -1
- package/dist/Service.cjs +54 -55
- package/dist/Service.cjs.map +1 -1
- package/dist/Service.js +53 -54
- package/dist/Service.js.map +1 -1
- package/dist/Sorting.cjs +102 -101
- package/dist/Sorting.cjs.map +1 -1
- package/dist/Sorting.js +102 -101
- package/dist/Sorting.js.map +1 -1
- package/dist/Trackable.cjs +112 -80
- package/dist/Trackable.cjs.map +1 -1
- package/dist/Trackable.js +111 -79
- package/dist/Trackable.js.map +1 -1
- package/dist/ViewModel.cjs +528 -576
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.js +525 -573
- package/dist/ViewModel.js.map +1 -1
- package/dist/bindPublicMethods.cjs +43 -24
- package/dist/bindPublicMethods.cjs.map +1 -1
- package/dist/bindPublicMethods.js +43 -24
- package/dist/bindPublicMethods.js.map +1 -1
- package/dist/errors.cjs +67 -68
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.js +68 -71
- package/dist/errors.js.map +1 -1
- package/dist/mvc-kit.cjs +44 -46
- package/dist/mvc-kit.js +5 -32
- package/dist/produceDraft.cjs +105 -95
- package/dist/produceDraft.cjs.map +1 -1
- package/dist/produceDraft.js +106 -97
- package/dist/produceDraft.js.map +1 -1
- package/dist/react/components/CardList.cjs +30 -40
- package/dist/react/components/CardList.cjs.map +1 -1
- package/dist/react/components/CardList.js +31 -41
- package/dist/react/components/CardList.js.map +1 -1
- package/dist/react/components/DataTable.cjs +146 -169
- package/dist/react/components/DataTable.cjs.map +1 -1
- package/dist/react/components/DataTable.js +147 -170
- package/dist/react/components/DataTable.js.map +1 -1
- package/dist/react/components/InfiniteScroll.cjs +51 -42
- package/dist/react/components/InfiniteScroll.cjs.map +1 -1
- package/dist/react/components/InfiniteScroll.js +52 -43
- package/dist/react/components/InfiniteScroll.js.map +1 -1
- package/dist/react/components/types.cjs +10 -6
- package/dist/react/components/types.cjs.map +1 -1
- package/dist/react/components/types.js +11 -9
- package/dist/react/components/types.js.map +1 -1
- package/dist/react/guards.cjs +10 -6
- package/dist/react/guards.cjs.map +1 -1
- package/dist/react/guards.js +11 -9
- package/dist/react/guards.js.map +1 -1
- package/dist/react/provider.cjs +23 -20
- package/dist/react/provider.cjs.map +1 -1
- package/dist/react/provider.js +23 -21
- package/dist/react/provider.js.map +1 -1
- package/dist/react/use-event-bus.cjs +24 -20
- package/dist/react/use-event-bus.cjs.map +1 -1
- package/dist/react/use-event-bus.js +24 -21
- package/dist/react/use-event-bus.js.map +1 -1
- package/dist/react/use-instance.cjs +43 -36
- package/dist/react/use-instance.cjs.map +1 -1
- package/dist/react/use-instance.js +43 -36
- package/dist/react/use-instance.js.map +1 -1
- package/dist/react/use-local.cjs +48 -64
- package/dist/react/use-local.cjs.map +1 -1
- package/dist/react/use-local.js +47 -63
- package/dist/react/use-local.js.map +1 -1
- package/dist/react/use-model.cjs +84 -98
- package/dist/react/use-model.cjs.map +1 -1
- package/dist/react/use-model.js +84 -100
- package/dist/react/use-model.js.map +1 -1
- package/dist/react/use-singleton.cjs +19 -23
- package/dist/react/use-singleton.cjs.map +1 -1
- package/dist/react/use-singleton.js +16 -20
- package/dist/react/use-singleton.js.map +1 -1
- package/dist/react/use-subscribe-only.cjs +28 -22
- package/dist/react/use-subscribe-only.cjs.map +1 -1
- package/dist/react/use-subscribe-only.js +28 -22
- package/dist/react/use-subscribe-only.js.map +1 -1
- package/dist/react/use-teardown.cjs +20 -19
- package/dist/react/use-teardown.cjs.map +1 -1
- package/dist/react/use-teardown.js +20 -19
- package/dist/react/use-teardown.js.map +1 -1
- package/dist/react-native/NativeCollection.cjs +98 -78
- package/dist/react-native/NativeCollection.cjs.map +1 -1
- package/dist/react-native/NativeCollection.js +97 -77
- package/dist/react-native/NativeCollection.js.map +1 -1
- package/dist/react-native.cjs +2 -4
- package/dist/react-native.js +1 -4
- package/dist/react.cjs +24 -26
- package/dist/react.js +1 -17
- package/dist/singleton.cjs +28 -22
- package/dist/singleton.cjs.map +1 -1
- package/dist/singleton.js +29 -26
- package/dist/singleton.js.map +1 -1
- package/dist/walkPrototypeChain.cjs +20 -12
- package/dist/walkPrototypeChain.cjs.map +1 -1
- package/dist/walkPrototypeChain.js +21 -13
- package/dist/walkPrototypeChain.js.map +1 -1
- package/dist/web/IndexedDBCollection.cjs +53 -36
- package/dist/web/IndexedDBCollection.cjs.map +1 -1
- package/dist/web/IndexedDBCollection.js +52 -35
- package/dist/web/IndexedDBCollection.js.map +1 -1
- package/dist/web/WebStorageCollection.cjs +82 -84
- package/dist/web/WebStorageCollection.cjs.map +1 -1
- package/dist/web/WebStorageCollection.js +81 -83
- package/dist/web/WebStorageCollection.js.map +1 -1
- package/dist/web/idb.cjs +107 -99
- package/dist/web/idb.cjs.map +1 -1
- package/dist/web/idb.js +108 -105
- package/dist/web/idb.js.map +1 -1
- package/dist/web.cjs +4 -6
- package/dist/web.js +1 -5
- package/dist/wrapAsyncMethods.cjs +141 -168
- package/dist/wrapAsyncMethods.cjs.map +1 -1
- package/dist/wrapAsyncMethods.js +141 -168
- package/dist/wrapAsyncMethods.js.map +1 -1
- package/package.json +8 -8
- package/src/Pending.test.ts +1 -2
- package/src/Sorting.test.ts +1 -1
- package/src/produceDraft.test.ts +3 -3
- package/src/react/components/CardList.test.tsx +1 -1
- package/src/react/components/DataTable.test.tsx +1 -1
- package/src/react/components/InfiniteScroll.test.tsx +5 -5
- package/dist/mvc-kit.cjs.map +0 -1
- package/dist/mvc-kit.js.map +0 -1
- package/dist/react-native.cjs.map +0 -1
- package/dist/react-native.js.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/web.cjs.map +0 -1
- package/dist/web.js.map +0 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# mvc-kit Testing Patterns
|
|
2
|
+
|
|
3
|
+
These patterns are specific to testing mvc-kit classes. Follow them when writing or reviewing tests.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Setup: Singleton Cleanup
|
|
8
|
+
|
|
9
|
+
Every test file that uses singletons needs `teardownAll()` in `beforeEach`. Without this, singleton state leaks between tests.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { teardownAll } from 'mvc-kit';
|
|
13
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
teardownAll();
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Why:** Singletons persist in a module-scoped registry. After a test disposes a singleton ViewModel, the registry still holds the reference. `teardownAll()` disposes all singletons and clears the registry.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Testing ViewModels
|
|
26
|
+
|
|
27
|
+
### Basic: State and Getters
|
|
28
|
+
|
|
29
|
+
Construct, init, call methods, assert `vm.state` and getter properties.
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { UsersViewModel } from './UsersViewModel';
|
|
33
|
+
|
|
34
|
+
describe('UsersViewModel', () => {
|
|
35
|
+
it('filters by search', async () => {
|
|
36
|
+
const vm = new UsersViewModel({ search: '', roleFilter: 'all', statusFilter: 'all' });
|
|
37
|
+
await vm.init();
|
|
38
|
+
|
|
39
|
+
// Wait for data to load (if onInit fetches)
|
|
40
|
+
await vi.waitFor(() => expect(vm.items.length).toBeGreaterThan(0));
|
|
41
|
+
|
|
42
|
+
vm.setSearch('alice');
|
|
43
|
+
expect(vm.filtered.every(u => u.firstName.toLowerCase().includes('alice'))).toBe(true);
|
|
44
|
+
|
|
45
|
+
vm.dispose();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Async Tracking Assertions
|
|
51
|
+
|
|
52
|
+
After calling an async method, assert `vm.async.methodName` during and after the call.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
it('tracks loading state', async () => {
|
|
56
|
+
const vm = new UsersViewModel({ search: '', roleFilter: 'all', statusFilter: 'all' });
|
|
57
|
+
await vm.init();
|
|
58
|
+
|
|
59
|
+
// Before call
|
|
60
|
+
expect(vm.async.load.loading).toBe(false);
|
|
61
|
+
expect(vm.async.load.error).toBeNull();
|
|
62
|
+
|
|
63
|
+
// During call
|
|
64
|
+
const promise = vm.load();
|
|
65
|
+
expect(vm.async.load.loading).toBe(true);
|
|
66
|
+
|
|
67
|
+
await promise;
|
|
68
|
+
|
|
69
|
+
// After call
|
|
70
|
+
expect(vm.async.load.loading).toBe(false);
|
|
71
|
+
expect(vm.async.load.error).toBeNull();
|
|
72
|
+
|
|
73
|
+
vm.dispose();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('captures errors in async tracking', async () => {
|
|
77
|
+
vi.spyOn(service, 'getAll').mockRejectedValue(new Error('Network error'));
|
|
78
|
+
|
|
79
|
+
const vm = new MyViewModel({ ... });
|
|
80
|
+
await vm.init();
|
|
81
|
+
|
|
82
|
+
await vm.load().catch(() => {}); // Swallow the re-thrown error
|
|
83
|
+
|
|
84
|
+
expect(vm.async.load.error).toBeInstanceOf(Error);
|
|
85
|
+
expect(vm.async.load.error!.message).toBe('Network error');
|
|
86
|
+
|
|
87
|
+
vm.dispose();
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Counter-Based Getter Testing (Memoization Verification)
|
|
92
|
+
|
|
93
|
+
Use a subclass override to count getter recomputations. Verifies that memoization works correctly.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
class TestableVM extends UsersViewModel {
|
|
97
|
+
filteredCallCount = 0;
|
|
98
|
+
|
|
99
|
+
get filtered(): UserState[] {
|
|
100
|
+
this.filteredCallCount++;
|
|
101
|
+
return super.filtered;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('memoizes filtered getter', async () => {
|
|
106
|
+
const vm = new TestableVM({ search: '', roleFilter: 'all', statusFilter: 'all' });
|
|
107
|
+
await vm.init();
|
|
108
|
+
|
|
109
|
+
// First access — computes
|
|
110
|
+
const result1 = vm.filtered;
|
|
111
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
112
|
+
|
|
113
|
+
// Second access, same state — cache hit (no recompute)
|
|
114
|
+
const result2 = vm.filtered;
|
|
115
|
+
expect(vm.filteredCallCount).toBe(1);
|
|
116
|
+
|
|
117
|
+
// State change — invalidates cache
|
|
118
|
+
vm.setSearch('test');
|
|
119
|
+
const result3 = vm.filtered;
|
|
120
|
+
expect(vm.filteredCallCount).toBe(2);
|
|
121
|
+
|
|
122
|
+
vm.dispose();
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Testing Collections
|
|
129
|
+
|
|
130
|
+
Set up test data with `reset()`, then assert mutations.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { UsersResource } from './UsersResource';
|
|
134
|
+
|
|
135
|
+
it('resets with data', () => {
|
|
136
|
+
const resource = new UsersResource();
|
|
137
|
+
resource.init();
|
|
138
|
+
|
|
139
|
+
resource.reset([
|
|
140
|
+
{ id: '1', firstName: 'Alice', lastName: 'Smith', email: 'alice@test.com', role: 'admin', status: 'active' },
|
|
141
|
+
{ id: '2', firstName: 'Bob', lastName: 'Jones', email: 'bob@test.com', role: 'viewer', status: 'inactive' },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
expect(resource.length).toBe(2);
|
|
145
|
+
expect(resource.get('1')?.firstName).toBe('Alice');
|
|
146
|
+
|
|
147
|
+
resource.remove('1');
|
|
148
|
+
expect(resource.length).toBe(1);
|
|
149
|
+
|
|
150
|
+
resource.dispose();
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Testing ViewModel + Collection Integration
|
|
155
|
+
|
|
156
|
+
Mock the Service, let the ViewModel and Collection interact naturally.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
it('loads data through resource into getters', async () => {
|
|
160
|
+
// Mock the service
|
|
161
|
+
vi.spyOn(UserService.prototype, 'getAll').mockResolvedValue([
|
|
162
|
+
{ id: '1', firstName: 'Alice', role: 'admin', status: 'active' },
|
|
163
|
+
{ id: '2', firstName: 'Bob', role: 'viewer', status: 'inactive' },
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const vm = new UsersViewModel({ search: '', roleFilter: 'all', statusFilter: 'all' });
|
|
167
|
+
await vm.init();
|
|
168
|
+
|
|
169
|
+
// Wait for onInit's load to complete
|
|
170
|
+
await vi.waitFor(() => expect(vm.items.length).toBe(2));
|
|
171
|
+
|
|
172
|
+
// Test getters
|
|
173
|
+
expect(vm.total).toBe(2);
|
|
174
|
+
expect(vm.filteredCount).toBe(2);
|
|
175
|
+
|
|
176
|
+
vm.setRoleFilter('admin');
|
|
177
|
+
expect(vm.filteredCount).toBe(1);
|
|
178
|
+
expect(vm.filtered[0].firstName).toBe('Alice');
|
|
179
|
+
|
|
180
|
+
vm.dispose();
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Testing Services
|
|
187
|
+
|
|
188
|
+
Mock `fetch`, assert correct URL/method/body, verify `HttpError` is thrown on failures.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { UserService } from './UserService';
|
|
192
|
+
|
|
193
|
+
it('fetches all users', async () => {
|
|
194
|
+
const mockUsers = [{ id: '1', firstName: 'Alice' }];
|
|
195
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
196
|
+
new Response(JSON.stringify(mockUsers), { status: 200 }),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const service = new UserService();
|
|
200
|
+
const result = await service.getAll();
|
|
201
|
+
|
|
202
|
+
expect(fetch).toHaveBeenCalledWith('/api/users', expect.objectContaining({ method: 'GET' }));
|
|
203
|
+
expect(result).toEqual(mockUsers);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('throws HttpError on failure', async () => {
|
|
207
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
208
|
+
new Response('Not Found', { status: 404, statusText: 'Not Found' }),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const service = new UserService();
|
|
212
|
+
await expect(service.getById('999')).rejects.toThrow();
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Testing Models
|
|
219
|
+
|
|
220
|
+
Assert validation, dirty state, commit, and rollback.
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import { LocationFormModel } from './LocationFormModel';
|
|
224
|
+
|
|
225
|
+
it('validates required fields', () => {
|
|
226
|
+
const model = new LocationFormModel({ name: '', city: '', capacity: 0 });
|
|
227
|
+
|
|
228
|
+
expect(model.valid).toBe(false);
|
|
229
|
+
expect(model.errors.name).toBeDefined();
|
|
230
|
+
|
|
231
|
+
model.setName('HQ');
|
|
232
|
+
model.setCity('Austin');
|
|
233
|
+
model.setCapacity(100);
|
|
234
|
+
expect(model.valid).toBe(true);
|
|
235
|
+
expect(model.errors.name).toBeUndefined();
|
|
236
|
+
|
|
237
|
+
model.dispose();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('tracks dirty state and supports commit/rollback', () => {
|
|
241
|
+
const model = new LocationFormModel({ name: 'HQ', city: 'Austin', capacity: 100 });
|
|
242
|
+
|
|
243
|
+
expect(model.dirty).toBe(false);
|
|
244
|
+
|
|
245
|
+
model.setName('New HQ');
|
|
246
|
+
expect(model.dirty).toBe(true);
|
|
247
|
+
|
|
248
|
+
model.rollback();
|
|
249
|
+
expect(model.state.name).toBe('HQ');
|
|
250
|
+
expect(model.dirty).toBe(false);
|
|
251
|
+
|
|
252
|
+
model.setName('New HQ');
|
|
253
|
+
model.commit();
|
|
254
|
+
expect(model.state.name).toBe('New HQ');
|
|
255
|
+
expect(model.dirty).toBe(false);
|
|
256
|
+
|
|
257
|
+
model.dispose();
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Testing React Components
|
|
264
|
+
|
|
265
|
+
React tests need the jsdom environment directive. Use `renderHook` for hook tests.
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// @vitest-environment jsdom
|
|
269
|
+
import { renderHook, act } from '@testing-library/react';
|
|
270
|
+
import { useLocal } from 'mvc-kit/react';
|
|
271
|
+
|
|
272
|
+
it('useLocal creates and disposes ViewModel', () => {
|
|
273
|
+
const { result, unmount } = renderHook(() =>
|
|
274
|
+
useLocal(CounterViewModel, { count: 0 }),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const [state, vm] = result.current;
|
|
278
|
+
expect(state.count).toBe(0);
|
|
279
|
+
|
|
280
|
+
act(() => vm.increment());
|
|
281
|
+
expect(result.current[0].count).toBe(1);
|
|
282
|
+
|
|
283
|
+
unmount(); // auto-disposes
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Test Checklist
|
|
290
|
+
|
|
291
|
+
- [ ] `teardownAll()` in `beforeEach` (singleton cleanup)
|
|
292
|
+
- [ ] `vm.dispose()` at end of each test (or rely on `afterEach` cleanup)
|
|
293
|
+
- [ ] Assert `vm.state.x` for raw state, `vm.x` for getters
|
|
294
|
+
- [ ] Assert `vm.async.method.loading` and `vm.async.method.error` for async methods
|
|
295
|
+
- [ ] Mock Services at the `fetch` level or via `vi.spyOn(ServiceClass.prototype, 'method')`
|
|
296
|
+
- [ ] React tests: add `// @vitest-environment jsdom` at top of file
|
|
297
|
+
- [ ] Never test internal fields (`_state`, `_revision`) — test public API only
|
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: review
|
|
2
|
+
name: mvc-kit-review
|
|
3
3
|
description: "Review code for mvc-kit pattern adherence. Usage: /mvc-kit:review <path>"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
- model
|
|
7
|
-
user_instructions: |
|
|
8
|
-
Usage: /mvc-kit:review <path>
|
|
9
|
-
|
|
10
|
-
Reviews files at the given path for mvc-kit pattern adherence.
|
|
11
|
-
Accepts a file path or directory.
|
|
12
|
-
|
|
13
|
-
Examples:
|
|
14
|
-
/mvc-kit:review src/viewmodels/
|
|
15
|
-
/mvc-kit:review src/viewmodels/OrderViewModel.ts
|
|
4
|
+
argument-hint: "<path>"
|
|
5
|
+
allowed-tools: Read Grep Glob
|
|
16
6
|
---
|
|
17
7
|
|
|
18
8
|
# Review mvc-kit Code
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
|
|
130
130
|
---
|
|
131
131
|
|
|
132
|
-
## Composable Helper Checks (
|
|
132
|
+
## Composable Helper Checks (9)
|
|
133
133
|
|
|
134
134
|
### Critical
|
|
135
135
|
1. **Correct ownership** — Sorting, Pagination, Selection belong on ViewModel. Pending belongs on singleton Resource (survives unmount).
|
|
@@ -139,18 +139,43 @@
|
|
|
139
139
|
3. **`apply()` in getters** — Helpers should be composed via `apply()` in getter pipelines: `pagination.apply(sorting.apply(filtered))`.
|
|
140
140
|
4. **Feed uses `setResult()` with Resource** — When items live in a Resource, Feed tracks only cursor/hasMore via `setResult()`. Use `appendPage()` only for component-scoped item accumulation.
|
|
141
141
|
5. **No `disposeSignal` in Pending execute** — Pending's `enqueue` callback receives its own signal. Do not pass `vm.disposeSignal` (would abort on component unmount).
|
|
142
|
+
6. **Pagination reset on filter changes** — Every setter that changes filter/sort criteria must call `pagination.reset()`. Without this, users can land on invalid pages after filtering.
|
|
143
|
+
7. **Helpers are `readonly` public** — Sorting, Pagination, Selection should be `readonly` public properties. DataTable and headless components need direct access.
|
|
144
|
+
8. **Getter pipeline order** — Pipeline should be `items → filtered → sorted → paged`. Selection operates on `filtered` (not `paged`) for cross-page selection.
|
|
145
|
+
|
|
146
|
+
### Suggestion
|
|
147
|
+
9. **Pass `filteredCount` as pagination total** — DataTable/pagination components need the filtered count, not the total item count.
|
|
142
148
|
|
|
143
149
|
---
|
|
144
150
|
|
|
145
|
-
## Test Checks (
|
|
151
|
+
## Test Checks (8)
|
|
146
152
|
|
|
147
153
|
### Critical
|
|
148
154
|
1. **`teardownAll()` in beforeEach** — Singleton registry must be reset between tests.
|
|
149
155
|
2. **Dispose after test** — ViewModel/Model instances created in tests must be disposed.
|
|
156
|
+
3. **Test file exists** — Every ViewModel, Resource, and Service should have a colocated `.test.ts` file.
|
|
157
|
+
|
|
158
|
+
### Warning
|
|
159
|
+
4. **Test state and getters** — Tests should assert `vm.state.x` and `vm.x` (getters), not internal fields.
|
|
160
|
+
5. **Test async tracking** — For async methods, assert `vm.async.method.loading` and `vm.async.method.error`.
|
|
161
|
+
6. **Mock at Service boundary** — Mock `fetch` or `vi.spyOn(ServiceClass.prototype, 'method')`. Don't mock Collections or Resources — let them interact naturally.
|
|
162
|
+
7. **React tests use jsdom** — React test files need `// @vitest-environment jsdom` directive at top of file.
|
|
163
|
+
|
|
164
|
+
### Suggestion
|
|
165
|
+
8. **Counter-based getter testing** — Use subclass override pattern for verifying getter memoization.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Composition Checks (6)
|
|
170
|
+
|
|
171
|
+
### Critical
|
|
172
|
+
1. **Model lifecycle pairing** — If a ViewModel creates a Model in `onInit()` or an action, it must dispose it in `onDispose()`.
|
|
150
173
|
|
|
151
174
|
### Warning
|
|
152
|
-
|
|
153
|
-
|
|
175
|
+
2. **Per-call cancellation for rapid interactions** — When users can switch contexts rapidly (conversations, search-as-you-type), use `AbortSignal.any([this.disposeSignal, controller.signal])` instead of `disposeSignal` alone.
|
|
176
|
+
3. **Two-tier events** — ViewModel `.emit()` for component-scoped reactions (navigate, show error). EventBus `.emit()` for app-wide side effects (toasts, analytics). Don't use EventBus for single-component concerns.
|
|
177
|
+
4. **Singleton DEFAULT_STATE** — Singleton ViewModels used with `useSingleton()` should have `static DEFAULT_STATE` to avoid repeating initial state at every call site.
|
|
178
|
+
5. **Smart init** — `onInit()` should check `if (this.collection.length === 0)` before fetching to prevent re-fetching when component re-mounts and data is already loaded.
|
|
154
179
|
|
|
155
180
|
### Suggestion
|
|
156
|
-
|
|
181
|
+
6. **File naming convention** — Files should follow `{Name}{Role}.ts` pattern: `UsersViewModel.ts`, `UserService.ts`, `UsersCollection.ts`, `UsersResource.ts`, `LocationFormModel.ts`, `AppEventBus.ts`.
|
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: scaffold
|
|
2
|
+
name: mvc-kit-scaffold
|
|
3
3
|
description: "Scaffold mvc-kit classes with correct patterns. Usage: /mvc-kit:scaffold <type> <Name>"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
user_instructions: |
|
|
8
|
-
Usage: /mvc-kit:scaffold <type> <Name>
|
|
9
|
-
|
|
10
|
-
Types: viewmodel, model, collection, persistent-collection, resource, service, eventbus, channel, controller, page-component
|
|
11
|
-
|
|
12
|
-
Examples:
|
|
13
|
-
/mvc-kit:scaffold viewmodel OrderList
|
|
14
|
-
/mvc-kit:scaffold service Payment
|
|
15
|
-
/mvc-kit:scaffold page-component Users
|
|
4
|
+
argument-hint: "<type> <Name>"
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
allowed-tools: Read Write Edit
|
|
16
7
|
---
|
|
17
8
|
|
|
18
9
|
# Scaffold mvc-kit Class
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
@@ -7,13 +7,22 @@ const __dirname = dirname(__filename);
|
|
|
7
7
|
const SKILLS_DIR = join(__dirname, '..', 'claude-code', 'skills');
|
|
8
8
|
const AGENTS_DIR = join(__dirname, '..', 'claude-code', 'agents');
|
|
9
9
|
|
|
10
|
-
const AUTO_HEADER = '<!-- Auto-generated by mvc-kit. Updated on npm install/update. Do not edit.
|
|
10
|
+
const AUTO_HEADER = '<!-- Auto-generated by mvc-kit. Updated on npm install/update. Do not edit. -->';
|
|
11
11
|
|
|
12
12
|
function stripFrontmatter(content) {
|
|
13
13
|
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
|
|
14
14
|
return match ? match[1].trim() : content.trim();
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Insert auto-generated header after YAML frontmatter (preserves parsing). */
|
|
18
|
+
function addHeader(content) {
|
|
19
|
+
const match = content.match(/^(---\n[\s\S]*?\n---\n)([\s\S]*)$/);
|
|
20
|
+
if (match) {
|
|
21
|
+
return match[1] + '\n' + AUTO_HEADER + '\n\n' + match[2].trim() + '\n';
|
|
22
|
+
}
|
|
23
|
+
return AUTO_HEADER + '\n\n' + content;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
function ensureDir(dir) {
|
|
18
27
|
if (!existsSync(dir)) {
|
|
19
28
|
mkdirSync(dir, { recursive: true });
|
|
@@ -24,7 +33,9 @@ function ensureDir(dir) {
|
|
|
24
33
|
* Install Claude Code integration files into a project's .claude/ directory.
|
|
25
34
|
*
|
|
26
35
|
* Creates:
|
|
27
|
-
* .claude/skills/mvc-kit
|
|
36
|
+
* .claude/skills/mvc-kit/ — Framework reference skill + supporting docs
|
|
37
|
+
* .claude/skills/mvc-kit-review/ — Code review skill + checklist
|
|
38
|
+
* .claude/skills/mvc-kit-scaffold/ — Scaffolding skill + templates
|
|
28
39
|
* .claude/agents/mvc-kit-architect.md — Architecture planning agent
|
|
29
40
|
*
|
|
30
41
|
* @param {string} projectRoot — Absolute path to the consuming project's root
|
|
@@ -32,50 +43,98 @@ function ensureDir(dir) {
|
|
|
32
43
|
*/
|
|
33
44
|
export function installClaude(projectRoot) {
|
|
34
45
|
const claudeDir = join(projectRoot, '.claude');
|
|
35
|
-
const
|
|
46
|
+
const guideDir = join(claudeDir, 'skills', 'mvc-kit');
|
|
47
|
+
const reviewDir = join(claudeDir, 'skills', 'mvc-kit-review');
|
|
48
|
+
const scaffoldDir = join(claudeDir, 'skills', 'mvc-kit-scaffold');
|
|
49
|
+
const templatesDir = join(scaffoldDir, 'templates');
|
|
36
50
|
const agentsDir = join(claudeDir, 'agents');
|
|
37
51
|
|
|
38
|
-
ensureDir(
|
|
52
|
+
ensureDir(guideDir);
|
|
53
|
+
ensureDir(reviewDir);
|
|
54
|
+
ensureDir(scaffoldDir);
|
|
55
|
+
ensureDir(templatesDir);
|
|
39
56
|
ensureDir(agentsDir);
|
|
40
57
|
|
|
41
58
|
const files = [];
|
|
42
59
|
|
|
43
|
-
// 1. Guide skill — framework reference (
|
|
60
|
+
// ─── 1. Guide skill — framework reference (model-invocable, auto-loaded) ───
|
|
61
|
+
|
|
44
62
|
const guideSkill = readFileSync(join(SKILLS_DIR, 'guide', 'SKILL.md'), 'utf-8');
|
|
45
63
|
const guideBody = stripFrontmatter(guideSkill)
|
|
46
|
-
// Remove the "Supporting Files" section — replaced
|
|
64
|
+
// Remove the "Supporting Files" section — replaced with colocated file references below
|
|
47
65
|
.replace(/\n## Supporting Files[\s\S]*$/, '');
|
|
48
66
|
|
|
49
|
-
const
|
|
67
|
+
const GUIDE_FRONTMATTER = `---
|
|
50
68
|
name: mvc-kit
|
|
51
69
|
description: "mvc-kit framework reference — class roles, architecture rules, React hooks, and decision framework. Loaded on-demand when working with mvc-kit code."
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
---
|
|
70
|
+
user-invocable: false
|
|
71
|
+
---`;
|
|
55
72
|
|
|
56
|
-
|
|
73
|
+
const skillContent = GUIDE_FRONTMATTER + '\n\n' + AUTO_HEADER + '\n\n' + guideBody + `
|
|
57
74
|
|
|
58
|
-
|
|
75
|
+
## Supporting Files
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- \`api-reference.md\` — Full API reference for all classes and hooks
|
|
66
|
-
- \`patterns.md\` — Prescribed patterns with code examples
|
|
67
|
-
- \`anti-patterns.md\` — Anti-patterns to reject with fixes
|
|
77
|
+
- [api-reference.md](api-reference.md) — Full API reference for all classes and hooks
|
|
78
|
+
- [patterns.md](patterns.md) — Prescribed patterns with code examples
|
|
79
|
+
- [anti-patterns.md](anti-patterns.md) — Anti-patterns to reject with fixes
|
|
80
|
+
- [recipes.md](recipes.md) — Composition recipes for real-world features
|
|
81
|
+
- [testing.md](testing.md) — Testing patterns (teardownAll, async assertions, memoization verification)
|
|
68
82
|
|
|
69
83
|
For detailed per-class documentation, read the \`.md\` files colocated with source in:
|
|
70
84
|
\`node_modules/mvc-kit/src/\`
|
|
71
85
|
`;
|
|
72
86
|
|
|
73
|
-
writeFileSync(join(
|
|
87
|
+
writeFileSync(join(guideDir, 'SKILL.md'), skillContent, 'utf-8');
|
|
74
88
|
files.push('.claude/skills/mvc-kit/SKILL.md');
|
|
75
89
|
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
90
|
+
// Copy supporting files alongside SKILL.md
|
|
91
|
+
const guideSupportFiles = ['api-reference.md', 'patterns.md', 'anti-patterns.md', 'recipes.md', 'testing.md'];
|
|
92
|
+
for (const file of guideSupportFiles) {
|
|
93
|
+
const src = join(SKILLS_DIR, 'guide', file);
|
|
94
|
+
if (existsSync(src)) {
|
|
95
|
+
copyFileSync(src, join(guideDir, file));
|
|
96
|
+
files.push(`.claude/skills/mvc-kit/${file}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── 2. Review skill — code review with checklist ──────────────────────────
|
|
101
|
+
|
|
102
|
+
const reviewSkill = readFileSync(join(SKILLS_DIR, 'review', 'SKILL.md'), 'utf-8');
|
|
103
|
+
writeFileSync(join(reviewDir, 'SKILL.md'), addHeader(reviewSkill), 'utf-8');
|
|
104
|
+
files.push('.claude/skills/mvc-kit-review/SKILL.md');
|
|
105
|
+
|
|
106
|
+
copyFileSync(
|
|
107
|
+
join(SKILLS_DIR, 'review', 'checklist.md'),
|
|
108
|
+
join(reviewDir, 'checklist.md'),
|
|
109
|
+
);
|
|
110
|
+
files.push('.claude/skills/mvc-kit-review/checklist.md');
|
|
111
|
+
|
|
112
|
+
// ─── 3. Scaffold skill — code generation with templates ────────────────────
|
|
113
|
+
|
|
114
|
+
const scaffoldSkill = readFileSync(join(SKILLS_DIR, 'scaffold', 'SKILL.md'), 'utf-8');
|
|
115
|
+
writeFileSync(join(scaffoldDir, 'SKILL.md'), addHeader(scaffoldSkill), 'utf-8');
|
|
116
|
+
files.push('.claude/skills/mvc-kit-scaffold/SKILL.md');
|
|
117
|
+
|
|
118
|
+
const templatesSrc = join(SKILLS_DIR, 'scaffold', 'templates');
|
|
119
|
+
if (existsSync(templatesSrc)) {
|
|
120
|
+
for (const file of readdirSync(templatesSrc)) {
|
|
121
|
+
if (file.endsWith('.md')) {
|
|
122
|
+
copyFileSync(join(templatesSrc, file), join(templatesDir, file));
|
|
123
|
+
files.push(`.claude/skills/mvc-kit-scaffold/templates/${file}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── 4. Architect agent ────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
let architectAgent = readFileSync(join(AGENTS_DIR, 'mvc-kit-architect.md'), 'utf-8');
|
|
131
|
+
// The source agent preloads "mvc-kit-guide" (the source skill name).
|
|
132
|
+
// In the installed context the guide skill is named "mvc-kit", so fix the reference.
|
|
133
|
+
architectAgent = architectAgent.replace(
|
|
134
|
+
/^(\s*- )mvc-kit-guide$/m,
|
|
135
|
+
'$1mvc-kit',
|
|
136
|
+
);
|
|
137
|
+
writeFileSync(join(agentsDir, 'mvc-kit-architect.md'), addHeader(architectAgent), 'utf-8');
|
|
79
138
|
files.push('.claude/agents/mvc-kit-architect.md');
|
|
80
139
|
|
|
81
140
|
return { files };
|