mvc-kit 2.12.0 → 2.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Model } from './Model';
|
|
3
|
+
import { Collection } from './Collection';
|
|
4
|
+
import { EventBus } from './EventBus';
|
|
5
|
+
import { singleton, teardownAll } from './singleton';
|
|
6
|
+
import type { ValidationErrors } from './types';
|
|
7
|
+
|
|
8
|
+
interface UserState {
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
age: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class UserModel extends Model<UserState> {
|
|
15
|
+
setName(name: string): void {
|
|
16
|
+
this.set({ name });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
setEmail(email: string): void {
|
|
20
|
+
this.set({ email });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setAge(age: number): void {
|
|
24
|
+
this.set({ age });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
update(partial: Partial<UserState>): void {
|
|
28
|
+
this.set(partial);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class ValidatedUserModel extends Model<UserState> {
|
|
33
|
+
protected validate(state: UserState): ValidationErrors<UserState> {
|
|
34
|
+
const errors: ValidationErrors<UserState> = {};
|
|
35
|
+
|
|
36
|
+
if (!state.name || state.name.trim() === '') {
|
|
37
|
+
errors.name = 'Name is required';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!state.email || !state.email.includes('@')) {
|
|
41
|
+
errors.email = 'Valid email is required';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (state.age < 0 || state.age > 150) {
|
|
45
|
+
errors.age = 'Age must be between 0 and 150';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setName(name: string): void {
|
|
52
|
+
this.set({ name });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setEmail(email: string): void {
|
|
56
|
+
this.set({ email });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setAge(age: number): void {
|
|
60
|
+
this.set({ age });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('Model', () => {
|
|
65
|
+
describe('state initialization', () => {
|
|
66
|
+
it('initializes with provided state', () => {
|
|
67
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
68
|
+
expect(model.state).toEqual({ name: 'John', email: 'john@example.com', age: 30 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('state is frozen', () => {
|
|
72
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
73
|
+
expect(Object.isFrozen(model.state)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('committed equals initial state', () => {
|
|
77
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
78
|
+
expect(model.committed).toEqual(model.state);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('starts not dirty', () => {
|
|
82
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
83
|
+
expect(model.dirty).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('starts not disposed', () => {
|
|
87
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
88
|
+
expect(model.disposed).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('state updates', () => {
|
|
93
|
+
it('updates state immutably', () => {
|
|
94
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
95
|
+
const prevState = model.state;
|
|
96
|
+
model.setName('Jane');
|
|
97
|
+
expect(model.state.name).toBe('Jane');
|
|
98
|
+
expect(model.state).not.toBe(prevState);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('new state is frozen', () => {
|
|
102
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
103
|
+
model.setName('Jane');
|
|
104
|
+
expect(Object.isFrozen(model.state)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('skips update when values unchanged', () => {
|
|
108
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
109
|
+
const listener = vi.fn();
|
|
110
|
+
model.subscribe(listener);
|
|
111
|
+
|
|
112
|
+
model.setName('John'); // Same value
|
|
113
|
+
expect(listener).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('dirty tracking', () => {
|
|
118
|
+
it('becomes dirty after state change', () => {
|
|
119
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
120
|
+
expect(model.dirty).toBe(false);
|
|
121
|
+
model.setName('Jane');
|
|
122
|
+
expect(model.dirty).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('committed state does not change on set', () => {
|
|
126
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
127
|
+
const committed = model.committed;
|
|
128
|
+
model.setName('Jane');
|
|
129
|
+
expect(model.committed).toBe(committed);
|
|
130
|
+
expect(model.committed.name).toBe('John');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('commit', () => {
|
|
135
|
+
it('marks current state as committed', () => {
|
|
136
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
137
|
+
model.setName('Jane');
|
|
138
|
+
expect(model.dirty).toBe(true);
|
|
139
|
+
|
|
140
|
+
model.commit();
|
|
141
|
+
expect(model.dirty).toBe(false);
|
|
142
|
+
expect(model.committed.name).toBe('Jane');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('throws on disposed model', () => {
|
|
146
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
147
|
+
model.dispose();
|
|
148
|
+
expect(() => model.commit()).toThrow('Cannot commit on disposed Model');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('rollback', () => {
|
|
153
|
+
it('reverts state to committed', () => {
|
|
154
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
155
|
+
model.setName('Jane');
|
|
156
|
+
model.setAge(25);
|
|
157
|
+
expect(model.state.name).toBe('Jane');
|
|
158
|
+
expect(model.state.age).toBe(25);
|
|
159
|
+
|
|
160
|
+
model.rollback();
|
|
161
|
+
expect(model.state.name).toBe('John');
|
|
162
|
+
expect(model.state.age).toBe(30);
|
|
163
|
+
expect(model.dirty).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('notifies listeners on rollback', () => {
|
|
167
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
168
|
+
model.setName('Jane');
|
|
169
|
+
|
|
170
|
+
const listener = vi.fn();
|
|
171
|
+
model.subscribe(listener);
|
|
172
|
+
model.rollback();
|
|
173
|
+
|
|
174
|
+
expect(listener).toHaveBeenCalledWith(
|
|
175
|
+
{ name: 'John', email: 'john@example.com', age: 30 },
|
|
176
|
+
{ name: 'Jane', email: 'john@example.com', age: 30 }
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does not notify if already at committed state', () => {
|
|
181
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
182
|
+
const listener = vi.fn();
|
|
183
|
+
model.subscribe(listener);
|
|
184
|
+
|
|
185
|
+
model.rollback();
|
|
186
|
+
expect(listener).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('throws on disposed model', () => {
|
|
190
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
191
|
+
model.dispose();
|
|
192
|
+
expect(() => model.rollback()).toThrow('Cannot rollback on disposed Model');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('validation', () => {
|
|
197
|
+
it('valid is true by default', () => {
|
|
198
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
199
|
+
expect(model.valid).toBe(true);
|
|
200
|
+
expect(model.errors).toEqual({});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('validates state', () => {
|
|
204
|
+
const model = new ValidatedUserModel({ name: '', email: 'invalid', age: 200 });
|
|
205
|
+
expect(model.valid).toBe(false);
|
|
206
|
+
expect(model.errors).toEqual({
|
|
207
|
+
name: 'Name is required',
|
|
208
|
+
email: 'Valid email is required',
|
|
209
|
+
age: 'Age must be between 0 and 150',
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('valid when all fields pass', () => {
|
|
214
|
+
const model = new ValidatedUserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
215
|
+
expect(model.valid).toBe(true);
|
|
216
|
+
expect(model.errors).toEqual({});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('updates validation on state change', () => {
|
|
220
|
+
const model = new ValidatedUserModel({ name: '', email: 'john@example.com', age: 30 });
|
|
221
|
+
expect(model.valid).toBe(false);
|
|
222
|
+
expect(model.errors.name).toBe('Name is required');
|
|
223
|
+
|
|
224
|
+
model.setName('John');
|
|
225
|
+
expect(model.valid).toBe(true);
|
|
226
|
+
expect(model.errors).toEqual({});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('subscriptions', () => {
|
|
231
|
+
it('notifies subscriber with next and prev state', () => {
|
|
232
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
233
|
+
const listener = vi.fn();
|
|
234
|
+
model.subscribe(listener);
|
|
235
|
+
|
|
236
|
+
model.setName('Jane');
|
|
237
|
+
|
|
238
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(listener).toHaveBeenCalledWith(
|
|
240
|
+
{ name: 'Jane', email: 'john@example.com', age: 30 },
|
|
241
|
+
{ name: 'John', email: 'john@example.com', age: 30 }
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('unsubscribe function works', () => {
|
|
246
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
247
|
+
const listener = vi.fn();
|
|
248
|
+
const unsubscribe = model.subscribe(listener);
|
|
249
|
+
|
|
250
|
+
model.setName('Jane');
|
|
251
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
252
|
+
|
|
253
|
+
unsubscribe();
|
|
254
|
+
model.setName('Bob');
|
|
255
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('dispose', () => {
|
|
260
|
+
it('sets disposed to true', () => {
|
|
261
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
262
|
+
model.dispose();
|
|
263
|
+
expect(model.disposed).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('returns no-op on subscribe after dispose', () => {
|
|
267
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
268
|
+
model.dispose();
|
|
269
|
+
const unsub = model.subscribe(() => {});
|
|
270
|
+
expect(typeof unsub).toBe('function');
|
|
271
|
+
expect(() => unsub()).not.toThrow();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('throws on set after dispose', () => {
|
|
275
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
276
|
+
model.dispose();
|
|
277
|
+
expect(() => model.setName('Jane')).toThrow('Cannot set state on disposed Model');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('init', () => {
|
|
282
|
+
it('starts not initialized', () => {
|
|
283
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
284
|
+
expect(model.initialized).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('sets initialized to true after init()', () => {
|
|
288
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
289
|
+
model.init();
|
|
290
|
+
expect(model.initialized).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('calls onInit hook', () => {
|
|
294
|
+
let called = false;
|
|
295
|
+
class InitModel extends Model<UserState> {
|
|
296
|
+
protected onInit() {
|
|
297
|
+
called = true;
|
|
298
|
+
}
|
|
299
|
+
setName(name: string) { this.set({ name }); }
|
|
300
|
+
}
|
|
301
|
+
const model = new InitModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
302
|
+
model.init();
|
|
303
|
+
expect(called).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('is idempotent — onInit called only once', () => {
|
|
307
|
+
let callCount = 0;
|
|
308
|
+
class CountingModel extends Model<UserState> {
|
|
309
|
+
protected onInit() {
|
|
310
|
+
callCount++;
|
|
311
|
+
}
|
|
312
|
+
setName(name: string) { this.set({ name }); }
|
|
313
|
+
}
|
|
314
|
+
const model = new CountingModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
315
|
+
model.init();
|
|
316
|
+
model.init();
|
|
317
|
+
model.init();
|
|
318
|
+
expect(callCount).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('supports async onInit', async () => {
|
|
322
|
+
let resolved = false;
|
|
323
|
+
class AsyncModel extends Model<UserState> {
|
|
324
|
+
protected async onInit() {
|
|
325
|
+
await Promise.resolve();
|
|
326
|
+
resolved = true;
|
|
327
|
+
}
|
|
328
|
+
setName(name: string) { this.set({ name }); }
|
|
329
|
+
}
|
|
330
|
+
const model = new AsyncModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
331
|
+
await model.init();
|
|
332
|
+
expect(resolved).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('is a no-op after dispose', () => {
|
|
336
|
+
let called = false;
|
|
337
|
+
class InitModel extends Model<UserState> {
|
|
338
|
+
protected onInit() {
|
|
339
|
+
called = true;
|
|
340
|
+
}
|
|
341
|
+
setName(name: string) { this.set({ name }); }
|
|
342
|
+
}
|
|
343
|
+
const model = new InitModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
344
|
+
model.dispose();
|
|
345
|
+
model.init();
|
|
346
|
+
expect(called).toBe(false);
|
|
347
|
+
expect(model.initialized).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('can set state in onInit', () => {
|
|
351
|
+
class InitSetModel extends Model<UserState> {
|
|
352
|
+
protected onInit() {
|
|
353
|
+
this.set({ name: 'Initialized' });
|
|
354
|
+
}
|
|
355
|
+
setName(name: string) { this.set({ name }); }
|
|
356
|
+
}
|
|
357
|
+
const model = new InitSetModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
358
|
+
model.init();
|
|
359
|
+
expect(model.state.name).toBe('Initialized');
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('singleton integration', () => {
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
teardownAll();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('can be used with singleton registry', () => {
|
|
369
|
+
const m1 = singleton(UserModel, { name: 'John', email: 'john@example.com', age: 30 });
|
|
370
|
+
const m2 = singleton(UserModel, { name: 'Jane', email: 'jane@example.com', age: 25 });
|
|
371
|
+
expect(m1).toBe(m2);
|
|
372
|
+
expect(m2.state.name).toBe('John');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('signal and addCleanup', () => {
|
|
377
|
+
it('signal returns an AbortSignal', () => {
|
|
378
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
379
|
+
expect(model.disposeSignal).toBeInstanceOf(AbortSignal);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('returns the same signal on multiple accesses', () => {
|
|
383
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
384
|
+
const s1 = model.disposeSignal;
|
|
385
|
+
const s2 = model.disposeSignal;
|
|
386
|
+
expect(s1).toBe(s2);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('signal is not aborted before dispose', () => {
|
|
390
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
391
|
+
expect(model.disposeSignal.aborted).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('signal is aborted after dispose', () => {
|
|
395
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
396
|
+
const signal = model.disposeSignal;
|
|
397
|
+
model.dispose();
|
|
398
|
+
expect(signal.aborted).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('signal is aborted before onDispose runs', () => {
|
|
402
|
+
let wasAbortedDuringDispose = false;
|
|
403
|
+
class CheckModel extends Model<UserState> {
|
|
404
|
+
protected onDispose(): void {
|
|
405
|
+
wasAbortedDuringDispose = this.disposeSignal.aborted;
|
|
406
|
+
}
|
|
407
|
+
setName(name: string) { this.set({ name }); }
|
|
408
|
+
}
|
|
409
|
+
const model = new CheckModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
410
|
+
model.disposeSignal; // force lazy creation
|
|
411
|
+
model.dispose();
|
|
412
|
+
expect(wasAbortedDuringDispose).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('addCleanup fires on dispose', () => {
|
|
416
|
+
let cleaned = false;
|
|
417
|
+
class CleanupModel extends Model<UserState> {
|
|
418
|
+
setup() {
|
|
419
|
+
this.addCleanup(() => { cleaned = true; });
|
|
420
|
+
}
|
|
421
|
+
setName(name: string) { this.set({ name }); }
|
|
422
|
+
}
|
|
423
|
+
const model = new CleanupModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
424
|
+
model.setup();
|
|
425
|
+
expect(cleaned).toBe(false);
|
|
426
|
+
model.dispose();
|
|
427
|
+
expect(cleaned).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('dispose works without accessing signal (lazy, zero cost)', () => {
|
|
431
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
432
|
+
model.dispose();
|
|
433
|
+
expect(model.disposed).toBe(true);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe('subscribeTo', () => {
|
|
438
|
+
interface Item { id: string; value: number }
|
|
439
|
+
|
|
440
|
+
it('listener is called when source changes', () => {
|
|
441
|
+
const collection = new Collection<Item>();
|
|
442
|
+
|
|
443
|
+
class SubModel extends Model<UserState> {
|
|
444
|
+
values: number[] = [];
|
|
445
|
+
setup(col: Collection<Item>) {
|
|
446
|
+
this.subscribeTo(col, (items) => {
|
|
447
|
+
this.values = items.map(i => i.value);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
setName(name: string) { this.set({ name }); }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const model = new SubModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
454
|
+
model.setup(collection);
|
|
455
|
+
|
|
456
|
+
collection.add({ id: '1', value: 42 });
|
|
457
|
+
expect(model.values).toEqual([42]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('auto-unsubscribes on dispose', () => {
|
|
461
|
+
const collection = new Collection<Item>();
|
|
462
|
+
const listener = vi.fn();
|
|
463
|
+
|
|
464
|
+
class SubModel extends Model<UserState> {
|
|
465
|
+
setup(col: Collection<Item>) {
|
|
466
|
+
this.subscribeTo(col, listener);
|
|
467
|
+
}
|
|
468
|
+
setName(name: string) { this.set({ name }); }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const model = new SubModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
472
|
+
model.setup(collection);
|
|
473
|
+
|
|
474
|
+
collection.add({ id: '1', value: 1 });
|
|
475
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
476
|
+
|
|
477
|
+
model.dispose();
|
|
478
|
+
|
|
479
|
+
collection.add({ id: '2', value: 2 });
|
|
480
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('returns unsubscribe function for manual cleanup', () => {
|
|
484
|
+
const collection = new Collection<Item>();
|
|
485
|
+
const listener = vi.fn();
|
|
486
|
+
|
|
487
|
+
class SubModel extends Model<UserState> {
|
|
488
|
+
setup(col: Collection<Item>): () => void {
|
|
489
|
+
return this.subscribeTo(col, listener);
|
|
490
|
+
}
|
|
491
|
+
setName(name: string) { this.set({ name }); }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const model = new SubModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
495
|
+
const unsub = model.setup(collection);
|
|
496
|
+
|
|
497
|
+
collection.add({ id: '1', value: 1 });
|
|
498
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
499
|
+
|
|
500
|
+
unsub();
|
|
501
|
+
|
|
502
|
+
collection.add({ id: '2', value: 2 });
|
|
503
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe('listenTo', () => {
|
|
508
|
+
interface TestEvents {
|
|
509
|
+
update: { field: string };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
it('handler is called when event is emitted', () => {
|
|
513
|
+
const bus = new EventBus<TestEvents>();
|
|
514
|
+
const updates: TestEvents['update'][] = [];
|
|
515
|
+
|
|
516
|
+
class ListenModel extends Model<UserState> {
|
|
517
|
+
setup(b: EventBus<TestEvents>) {
|
|
518
|
+
this.listenTo(b, 'update', (u) => updates.push(u));
|
|
519
|
+
}
|
|
520
|
+
setName(name: string) { this.set({ name }); }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const model = new ListenModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
524
|
+
model.setup(bus);
|
|
525
|
+
|
|
526
|
+
bus.emit('update', { field: 'name' });
|
|
527
|
+
expect(updates).toEqual([{ field: 'name' }]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('auto-unsubscribes on dispose', () => {
|
|
531
|
+
const bus = new EventBus<TestEvents>();
|
|
532
|
+
const handler = vi.fn();
|
|
533
|
+
|
|
534
|
+
class ListenModel extends Model<UserState> {
|
|
535
|
+
setup(b: EventBus<TestEvents>) {
|
|
536
|
+
this.listenTo(b, 'update', handler);
|
|
537
|
+
}
|
|
538
|
+
setName(name: string) { this.set({ name }); }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const model = new ListenModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
542
|
+
model.setup(bus);
|
|
543
|
+
|
|
544
|
+
bus.emit('update', { field: 'name' });
|
|
545
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
546
|
+
|
|
547
|
+
model.dispose();
|
|
548
|
+
|
|
549
|
+
bus.emit('update', { field: 'email' });
|
|
550
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('returns unsubscribe function for manual cleanup', () => {
|
|
554
|
+
const bus = new EventBus<TestEvents>();
|
|
555
|
+
const handler = vi.fn();
|
|
556
|
+
|
|
557
|
+
class ListenModel extends Model<UserState> {
|
|
558
|
+
setup(b: EventBus<TestEvents>): () => void {
|
|
559
|
+
return this.listenTo(b, 'update', handler);
|
|
560
|
+
}
|
|
561
|
+
setName(name: string) { this.set({ name }); }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const model = new ListenModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
565
|
+
const unsub = model.setup(bus);
|
|
566
|
+
|
|
567
|
+
bus.emit('update', { field: 'name' });
|
|
568
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
569
|
+
|
|
570
|
+
unsub();
|
|
571
|
+
|
|
572
|
+
bus.emit('update', { field: 'email' });
|
|
573
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('method binding', () => {
|
|
578
|
+
it('destructured base class methods work point-free', () => {
|
|
579
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
580
|
+
model.setName('Jane');
|
|
581
|
+
const { commit, rollback } = model;
|
|
582
|
+
commit();
|
|
583
|
+
expect(model.committed.name).toBe('Jane');
|
|
584
|
+
model.setName('Bob');
|
|
585
|
+
rollback();
|
|
586
|
+
expect(model.state.name).toBe('Jane');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('subclass methods are bound', () => {
|
|
590
|
+
const model = new UserModel({ name: 'John', email: 'john@example.com', age: 30 });
|
|
591
|
+
const { setName, setAge } = model;
|
|
592
|
+
setName('Jane');
|
|
593
|
+
expect(model.state.name).toBe('Jane');
|
|
594
|
+
setAge(25);
|
|
595
|
+
expect(model.state.age).toBe(25);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe('draft mode in set()', () => {
|
|
600
|
+
class DraftModel extends Model<UserState> {
|
|
601
|
+
draftName(name: string) {
|
|
602
|
+
this.set((d) => { d.name = name; });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
updaterName(name: string) {
|
|
606
|
+
this.set(() => ({ name }));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
it('draft mutation updates state', () => {
|
|
611
|
+
const model = new DraftModel({ name: 'John', email: 'j@e.com', age: 30 });
|
|
612
|
+
model.draftName('Jane');
|
|
613
|
+
expect(model.state.name).toBe('Jane');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('draft no-op does not notify listeners', () => {
|
|
617
|
+
const model = new DraftModel({ name: 'John', email: 'j@e.com', age: 30 });
|
|
618
|
+
const listener = vi.fn();
|
|
619
|
+
model.subscribe(listener);
|
|
620
|
+
|
|
621
|
+
model.draftName('John'); // same value
|
|
622
|
+
expect(listener).not.toHaveBeenCalled();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('existing updater pattern still works', () => {
|
|
626
|
+
const model = new DraftModel({ name: 'John', email: 'j@e.com', age: 30 });
|
|
627
|
+
model.updaterName('Jane');
|
|
628
|
+
expect(model.state.name).toBe('Jane');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('draft updates dirty tracking correctly', () => {
|
|
632
|
+
const model = new DraftModel({ name: 'John', email: 'j@e.com', age: 30 });
|
|
633
|
+
expect(model.dirty).toBe(false);
|
|
634
|
+
|
|
635
|
+
model.draftName('Jane');
|
|
636
|
+
expect(model.dirty).toBe(true);
|
|
637
|
+
|
|
638
|
+
model.commit();
|
|
639
|
+
expect(model.dirty).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
});
|