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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,1236 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ViewModel } from './ViewModel';
3
+ import { Collection } from './Collection';
4
+ import { EventBus } from './EventBus';
5
+ import { Channel } from './Channel';
6
+
7
+ interface TestState {
8
+ count: number;
9
+ name: string;
10
+ }
11
+
12
+ class TestViewModel extends ViewModel<TestState> {
13
+ increment() {
14
+ this.set({ count: this.state.count + 1 });
15
+ }
16
+
17
+ setName(name: string) {
18
+ this.set({ name });
19
+ }
20
+
21
+ updateWithFunction() {
22
+ this.set((prev) => ({ count: prev.count + 10 }));
23
+ }
24
+
25
+ // Expose protected method for testing
26
+ publicSet(partial: Partial<TestState>) {
27
+ this.set(partial);
28
+ }
29
+ }
30
+
31
+ class HookTestViewModel extends ViewModel<TestState> {
32
+ onSetCalls: Array<{ prev: TestState; next: TestState }> = [];
33
+ onDisposeCalled = false;
34
+
35
+ protected onSet(prev: TestState, next: TestState) {
36
+ this.onSetCalls.push({ prev: { ...prev }, next: { ...next } });
37
+ }
38
+
39
+ protected onDispose() {
40
+ this.onDisposeCalled = true;
41
+ }
42
+
43
+ increment() {
44
+ this.set({ count: this.state.count + 1 });
45
+ }
46
+ }
47
+
48
+ describe('ViewModel', () => {
49
+ describe('state initialization', () => {
50
+ it('initializes with provided state', () => {
51
+ const vm = new TestViewModel({ count: 0, name: 'test' });
52
+ expect(vm.state).toEqual({ count: 0, name: 'test' });
53
+ });
54
+
55
+ it('state is frozen', () => {
56
+ const vm = new TestViewModel({ count: 0, name: 'test' });
57
+ expect(Object.isFrozen(vm.state)).toBe(true);
58
+ });
59
+
60
+ it('initial disposed is false', () => {
61
+ const vm = new TestViewModel({ count: 0, name: 'test' });
62
+ expect(vm.disposed).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('state updates', () => {
67
+ it('updates state immutably', () => {
68
+ const vm = new TestViewModel({ count: 0, name: 'test' });
69
+ const prevState = vm.state;
70
+ vm.increment();
71
+ expect(vm.state).toEqual({ count: 1, name: 'test' });
72
+ expect(vm.state).not.toBe(prevState);
73
+ });
74
+
75
+ it('new state is frozen', () => {
76
+ const vm = new TestViewModel({ count: 0, name: 'test' });
77
+ vm.increment();
78
+ expect(Object.isFrozen(vm.state)).toBe(true);
79
+ });
80
+
81
+ it('supports updater function', () => {
82
+ const vm = new TestViewModel({ count: 5, name: 'test' });
83
+ vm.updateWithFunction();
84
+ expect(vm.state.count).toBe(15);
85
+ });
86
+
87
+ it('skips update when values unchanged', () => {
88
+ const vm = new TestViewModel({ count: 0, name: 'test' });
89
+ const listener = vi.fn();
90
+ vm.subscribe(listener);
91
+
92
+ vm.publicSet({ count: 0 }); // Same value
93
+ expect(listener).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('skips update when all partial values unchanged', () => {
97
+ const vm = new TestViewModel({ count: 0, name: 'test' });
98
+ const listener = vi.fn();
99
+ vm.subscribe(listener);
100
+
101
+ vm.publicSet({ count: 0, name: 'test' }); // Same values
102
+ expect(listener).not.toHaveBeenCalled();
103
+ });
104
+ });
105
+
106
+ describe('subscriptions', () => {
107
+ it('notifies subscriber with next and prev state', () => {
108
+ const vm = new TestViewModel({ count: 0, name: 'test' });
109
+ const listener = vi.fn();
110
+ vm.subscribe(listener);
111
+
112
+ vm.increment();
113
+
114
+ expect(listener).toHaveBeenCalledTimes(1);
115
+ expect(listener).toHaveBeenCalledWith(
116
+ { count: 1, name: 'test' },
117
+ { count: 0, name: 'test' }
118
+ );
119
+ });
120
+
121
+ it('notifies multiple subscribers', () => {
122
+ const vm = new TestViewModel({ count: 0, name: 'test' });
123
+ const listener1 = vi.fn();
124
+ const listener2 = vi.fn();
125
+ vm.subscribe(listener1);
126
+ vm.subscribe(listener2);
127
+
128
+ vm.increment();
129
+
130
+ expect(listener1).toHaveBeenCalledTimes(1);
131
+ expect(listener2).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it('unsubscribe function works', () => {
135
+ const vm = new TestViewModel({ count: 0, name: 'test' });
136
+ const listener = vi.fn();
137
+ const unsubscribe = vm.subscribe(listener);
138
+
139
+ vm.increment();
140
+ expect(listener).toHaveBeenCalledTimes(1);
141
+
142
+ unsubscribe();
143
+ vm.increment();
144
+ expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called again
145
+ });
146
+ });
147
+
148
+ describe('dispose', () => {
149
+ it('sets disposed to true', () => {
150
+ const vm = new TestViewModel({ count: 0, name: 'test' });
151
+ vm.dispose();
152
+ expect(vm.disposed).toBe(true);
153
+ });
154
+
155
+ it('is idempotent', () => {
156
+ const vm = new HookTestViewModel({ count: 0, name: 'test' });
157
+ vm.dispose();
158
+ vm.dispose();
159
+ vm.dispose();
160
+ expect(vm.onDisposeCalled).toBe(true);
161
+ expect(vm.disposed).toBe(true);
162
+ });
163
+
164
+ it('returns no-op on subscribe after dispose', () => {
165
+ const vm = new TestViewModel({ count: 0, name: 'test' });
166
+ vm.dispose();
167
+ const unsub = vm.subscribe(() => {});
168
+ expect(typeof unsub).toBe('function');
169
+ expect(() => unsub()).not.toThrow();
170
+ });
171
+
172
+ it('set is a no-op after dispose', () => {
173
+ const vm = new TestViewModel({ count: 0, name: 'test' });
174
+ const stateBeforeDispose = vm.state;
175
+ vm.dispose();
176
+ vm.increment(); // should not throw
177
+ expect(vm.state).toBe(stateBeforeDispose);
178
+ });
179
+
180
+ it('clears listeners on dispose', () => {
181
+ const vm = new TestViewModel({ count: 0, name: 'test' });
182
+ const listener = vi.fn();
183
+ vm.subscribe(listener);
184
+ vm.dispose();
185
+
186
+ // Can't call set after dispose, but listeners should be cleared
187
+ // We verify by checking that unsubscribe after dispose doesn't error
188
+ expect(vm.disposed).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe('hooks', () => {
193
+ it('calls onSet with prev and next state', () => {
194
+ const vm = new HookTestViewModel({ count: 0, name: 'test' });
195
+ vm.increment();
196
+ vm.increment();
197
+
198
+ expect(vm.onSetCalls).toHaveLength(2);
199
+ expect(vm.onSetCalls[0]).toEqual({
200
+ prev: { count: 0, name: 'test' },
201
+ next: { count: 1, name: 'test' },
202
+ });
203
+ expect(vm.onSetCalls[1]).toEqual({
204
+ prev: { count: 1, name: 'test' },
205
+ next: { count: 2, name: 'test' },
206
+ });
207
+ });
208
+
209
+ it('calls onDispose on dispose', () => {
210
+ const vm = new HookTestViewModel({ count: 0, name: 'test' });
211
+ expect(vm.onDisposeCalled).toBe(false);
212
+ vm.dispose();
213
+ expect(vm.onDisposeCalled).toBe(true);
214
+ });
215
+
216
+ it('onDispose only called once even with multiple dispose calls', () => {
217
+ let callCount = 0;
218
+ class CountingViewModel extends ViewModel<TestState> {
219
+ protected onDispose() {
220
+ callCount++;
221
+ }
222
+ }
223
+ const vm = new CountingViewModel({ count: 0, name: 'test' });
224
+ vm.dispose();
225
+ vm.dispose();
226
+ vm.dispose();
227
+ expect(callCount).toBe(1);
228
+ });
229
+ });
230
+
231
+ describe('init', () => {
232
+ it('starts not initialized', () => {
233
+ const vm = new TestViewModel({ count: 0, name: 'test' });
234
+ expect(vm.initialized).toBe(false);
235
+ });
236
+
237
+ it('sets initialized to true after init()', () => {
238
+ const vm = new TestViewModel({ count: 0, name: 'test' });
239
+ vm.init();
240
+ expect(vm.initialized).toBe(true);
241
+ });
242
+
243
+ it('calls onInit hook', () => {
244
+ let called = false;
245
+ class InitVM extends ViewModel<TestState> {
246
+ protected onInit() {
247
+ called = true;
248
+ }
249
+ }
250
+ const vm = new InitVM({ count: 0, name: 'test' });
251
+ vm.init();
252
+ expect(called).toBe(true);
253
+ });
254
+
255
+ it('is idempotent — onInit called only once', () => {
256
+ let callCount = 0;
257
+ class CountingVM extends ViewModel<TestState> {
258
+ protected onInit() {
259
+ callCount++;
260
+ }
261
+ }
262
+ const vm = new CountingVM({ count: 0, name: 'test' });
263
+ vm.init();
264
+ vm.init();
265
+ vm.init();
266
+ expect(callCount).toBe(1);
267
+ });
268
+
269
+ it('supports async onInit', async () => {
270
+ let resolved = false;
271
+ class AsyncVM extends ViewModel<TestState> {
272
+ protected async onInit() {
273
+ await Promise.resolve();
274
+ resolved = true;
275
+ }
276
+ }
277
+ const vm = new AsyncVM({ count: 0, name: 'test' });
278
+ await vm.init();
279
+ expect(resolved).toBe(true);
280
+ });
281
+
282
+ it('is a no-op after dispose', () => {
283
+ let called = false;
284
+ class InitVM extends ViewModel<TestState> {
285
+ protected onInit() {
286
+ called = true;
287
+ }
288
+ }
289
+ const vm = new InitVM({ count: 0, name: 'test' });
290
+ vm.dispose();
291
+ vm.init();
292
+ expect(called).toBe(false);
293
+ expect(vm.initialized).toBe(false);
294
+ });
295
+
296
+ it('can set state in onInit', () => {
297
+ class InitSetVM extends ViewModel<TestState> {
298
+ protected onInit() {
299
+ this.set({ count: 42 });
300
+ }
301
+ increment() {
302
+ this.set({ count: this.state.count + 1 });
303
+ }
304
+ }
305
+ const vm = new InitSetVM({ count: 0, name: 'test' });
306
+ vm.init();
307
+ expect(vm.state.count).toBe(42);
308
+ });
309
+ });
310
+
311
+ describe('signal and addCleanup', () => {
312
+ it('signal returns an AbortSignal', () => {
313
+ const vm = new TestViewModel({ count: 0, name: 'test' });
314
+ expect(vm.disposeSignal).toBeInstanceOf(AbortSignal);
315
+ });
316
+
317
+ it('returns the same signal on multiple accesses', () => {
318
+ const vm = new TestViewModel({ count: 0, name: 'test' });
319
+ const s1 = vm.disposeSignal;
320
+ const s2 = vm.disposeSignal;
321
+ expect(s1).toBe(s2);
322
+ });
323
+
324
+ it('signal is not aborted before dispose', () => {
325
+ const vm = new TestViewModel({ count: 0, name: 'test' });
326
+ expect(vm.disposeSignal.aborted).toBe(false);
327
+ });
328
+
329
+ it('signal is aborted after dispose', () => {
330
+ const vm = new TestViewModel({ count: 0, name: 'test' });
331
+ const signal = vm.disposeSignal;
332
+ vm.dispose();
333
+ expect(signal.aborted).toBe(true);
334
+ });
335
+
336
+ it('signal is aborted before onDispose runs', () => {
337
+ let wasAbortedDuringDispose = false;
338
+ class CheckVM extends ViewModel<TestState> {
339
+ protected onDispose(): void {
340
+ wasAbortedDuringDispose = this.disposeSignal.aborted;
341
+ }
342
+ }
343
+ const vm = new CheckVM({ count: 0, name: 'test' });
344
+ vm.disposeSignal; // force lazy creation
345
+ vm.dispose();
346
+ expect(wasAbortedDuringDispose).toBe(true);
347
+ });
348
+
349
+ it('addCleanup fires on dispose', () => {
350
+ let cleaned = false;
351
+ class CleanupVM extends ViewModel<TestState> {
352
+ setup() {
353
+ this.addCleanup(() => { cleaned = true; });
354
+ }
355
+ }
356
+ const vm = new CleanupVM({ count: 0, name: 'test' });
357
+ vm.setup();
358
+ expect(cleaned).toBe(false);
359
+ vm.dispose();
360
+ expect(cleaned).toBe(true);
361
+ });
362
+
363
+ it('dispose works without accessing signal (lazy, zero cost)', () => {
364
+ const vm = new TestViewModel({ count: 0, name: 'test' });
365
+ vm.dispose();
366
+ expect(vm.disposed).toBe(true);
367
+ });
368
+ });
369
+
370
+ describe('subscribeTo', () => {
371
+ it('listener is called when source changes', () => {
372
+ interface Item { id: string; value: number }
373
+ const collection = new Collection<Item>();
374
+
375
+ class SubVM extends ViewModel<TestState> {
376
+ values: number[] = [];
377
+ setup(col: Collection<Item>) {
378
+ this.subscribeTo(col, (items) => {
379
+ this.values = items.map(i => i.value);
380
+ });
381
+ }
382
+ }
383
+
384
+ const vm = new SubVM({ count: 0, name: 'test' });
385
+ vm.setup(collection);
386
+
387
+ collection.add({ id: '1', value: 42 });
388
+ expect(vm.values).toEqual([42]);
389
+
390
+ collection.add({ id: '2', value: 99 });
391
+ expect(vm.values).toEqual([42, 99]);
392
+ });
393
+
394
+ it('auto-unsubscribes on dispose', () => {
395
+ interface Item { id: string; value: number }
396
+ const collection = new Collection<Item>();
397
+ const listener = vi.fn();
398
+
399
+ class SubVM extends ViewModel<TestState> {
400
+ setup(col: Collection<Item>) {
401
+ this.subscribeTo(col, listener);
402
+ }
403
+ }
404
+
405
+ const vm = new SubVM({ count: 0, name: 'test' });
406
+ vm.setup(collection);
407
+
408
+ collection.add({ id: '1', value: 1 });
409
+ expect(listener).toHaveBeenCalledTimes(1);
410
+
411
+ vm.dispose();
412
+
413
+ collection.add({ id: '2', value: 2 });
414
+ expect(listener).toHaveBeenCalledTimes(1); // not called again
415
+ });
416
+
417
+ it('returns unsubscribe function for manual cleanup', () => {
418
+ interface Item { id: string; value: number }
419
+ const collection = new Collection<Item>();
420
+ const listener = vi.fn();
421
+
422
+ class SubVM extends ViewModel<TestState> {
423
+ setup(col: Collection<Item>): () => void {
424
+ return this.subscribeTo(col, listener);
425
+ }
426
+ }
427
+
428
+ const vm = new SubVM({ count: 0, name: 'test' });
429
+ const unsub = vm.setup(collection);
430
+
431
+ collection.add({ id: '1', value: 1 });
432
+ expect(listener).toHaveBeenCalledTimes(1);
433
+
434
+ unsub();
435
+
436
+ collection.add({ id: '2', value: 2 });
437
+ expect(listener).toHaveBeenCalledTimes(1);
438
+ });
439
+
440
+ it('works with other ViewModels as source', () => {
441
+ const source = new TestViewModel({ count: 0, name: 'source' });
442
+ const listener = vi.fn();
443
+
444
+ class SubVM extends ViewModel<TestState> {
445
+ setup(src: TestViewModel) {
446
+ this.subscribeTo(src, listener);
447
+ }
448
+ }
449
+
450
+ const vm = new SubVM({ count: 0, name: 'test' });
451
+ vm.setup(source);
452
+
453
+ source.increment();
454
+ expect(listener).toHaveBeenCalledTimes(1);
455
+ expect(listener).toHaveBeenCalledWith(
456
+ { count: 1, name: 'source' },
457
+ { count: 0, name: 'source' }
458
+ );
459
+ });
460
+ });
461
+
462
+ describe('listenTo', () => {
463
+ interface TestEvents {
464
+ message: { text: string };
465
+ status: { online: boolean };
466
+ }
467
+
468
+ it('handler is called when event is emitted', () => {
469
+ const bus = new EventBus<TestEvents>();
470
+ const messages: TestEvents['message'][] = [];
471
+
472
+ class ListenVM extends ViewModel<TestState> {
473
+ setup(b: EventBus<TestEvents>) {
474
+ this.listenTo(b, 'message', (msg) => messages.push(msg));
475
+ }
476
+ }
477
+
478
+ const vm = new ListenVM({ count: 0, name: 'test' });
479
+ vm.setup(bus);
480
+
481
+ bus.emit('message', { text: 'hello' });
482
+ expect(messages).toEqual([{ text: 'hello' }]);
483
+ });
484
+
485
+ it('auto-unsubscribes on dispose', () => {
486
+ const bus = new EventBus<TestEvents>();
487
+ const handler = vi.fn();
488
+
489
+ class ListenVM extends ViewModel<TestState> {
490
+ setup(b: EventBus<TestEvents>) {
491
+ this.listenTo(b, 'message', handler);
492
+ }
493
+ }
494
+
495
+ const vm = new ListenVM({ count: 0, name: 'test' });
496
+ vm.setup(bus);
497
+
498
+ bus.emit('message', { text: 'hello' });
499
+ expect(handler).toHaveBeenCalledTimes(1);
500
+
501
+ vm.dispose();
502
+
503
+ bus.emit('message', { text: 'world' });
504
+ expect(handler).toHaveBeenCalledTimes(1); // not called again
505
+ });
506
+
507
+ it('returns unsubscribe function for manual cleanup', () => {
508
+ const bus = new EventBus<TestEvents>();
509
+ const handler = vi.fn();
510
+
511
+ class ListenVM extends ViewModel<TestState> {
512
+ setup(b: EventBus<TestEvents>): () => void {
513
+ return this.listenTo(b, 'message', handler);
514
+ }
515
+ }
516
+
517
+ const vm = new ListenVM({ count: 0, name: 'test' });
518
+ const unsub = vm.setup(bus);
519
+
520
+ bus.emit('message', { text: 'hello' });
521
+ expect(handler).toHaveBeenCalledTimes(1);
522
+
523
+ unsub();
524
+
525
+ bus.emit('message', { text: 'world' });
526
+ expect(handler).toHaveBeenCalledTimes(1);
527
+ });
528
+
529
+ it('works with any EventSource (structural typing)', () => {
530
+ // Duck-type a minimal EventSource-compatible object with on()
531
+ // Include _types phantom for payload type narrowing (same pattern as EventBus/Channel)
532
+ const handlers = new Map<string, Set<(p: any) => void>>();
533
+ const source: { readonly _types: TestEvents; on<K extends keyof TestEvents>(event: K, handler: (payload: TestEvents[K]) => void): () => void } = {
534
+ _types: undefined as unknown as TestEvents,
535
+ on<K extends keyof TestEvents>(event: K, handler: (payload: TestEvents[K]) => void) {
536
+ let set = handlers.get(event as string);
537
+ if (!set) { set = new Set(); handlers.set(event as string, set); }
538
+ set.add(handler);
539
+ return () => { set!.delete(handler); };
540
+ },
541
+ };
542
+
543
+ const received: TestEvents['message'][] = [];
544
+
545
+ class ListenVM extends ViewModel<TestState> {
546
+ setup() {
547
+ this.listenTo(source, 'message', (msg) => received.push(msg));
548
+ }
549
+ }
550
+
551
+ const vm = new ListenVM({ count: 0, name: 'test' });
552
+ vm.setup();
553
+
554
+ handlers.get('message')!.forEach(h => h({ text: 'hi' }));
555
+ expect(received).toEqual([{ text: 'hi' }]);
556
+
557
+ vm.dispose();
558
+
559
+ handlers.get('message')!.forEach(h => h({ text: 'bye' }));
560
+ expect(received).toEqual([{ text: 'hi' }]); // not called after dispose
561
+ });
562
+
563
+ it('double-unsub is safe', () => {
564
+ const bus = new EventBus<TestEvents>();
565
+ const handler = vi.fn();
566
+
567
+ class ListenVM extends ViewModel<TestState> {
568
+ setup(b: EventBus<TestEvents>): () => void {
569
+ return this.listenTo(b, 'message', handler);
570
+ }
571
+ }
572
+
573
+ const vm = new ListenVM({ count: 0, name: 'test' });
574
+ const unsub = vm.setup(bus);
575
+
576
+ unsub(); // manual unsub
577
+ vm.dispose(); // framework unsub — should not throw
578
+ });
579
+
580
+ it('works with a concrete Channel subclass (generic variance regression)', () => {
581
+ interface ChatMessages {
582
+ activity: { user: string };
583
+ chat: { text: string };
584
+ }
585
+
586
+ class ChatChannel extends Channel<ChatMessages> {
587
+ protected open() { /* no-op */ }
588
+ protected close() { /* no-op */ }
589
+ }
590
+
591
+ const channel = new ChatChannel();
592
+ const received: ChatMessages['activity'][] = [];
593
+
594
+ class ListenVM extends ViewModel<TestState> {
595
+ setup(ch: ChatChannel) {
596
+ // Type-level regression: payload must be narrowed to { user: string },
597
+ // not the union { user: string } | { text: string }
598
+ this.listenTo(ch, 'activity', (payload) => {
599
+ void payload.user; // would fail to compile if payload were a union
600
+ received.push(payload);
601
+ });
602
+ }
603
+ }
604
+
605
+ const vm = new ListenVM({ count: 0, name: 'test' });
606
+ vm.setup(channel);
607
+
608
+ // Simulate receiving a message via the channel's protected receive()
609
+ (channel as any).receive('activity', { user: 'alice' });
610
+ expect(received).toEqual([{ user: 'alice' }]);
611
+
612
+ vm.dispose();
613
+
614
+ (channel as any).receive('activity', { user: 'bob' });
615
+ expect(received).toEqual([{ user: 'alice' }]); // not called after dispose
616
+ });
617
+
618
+ it('cleans up on reset and re-establishes in onInit', () => {
619
+ const bus = new EventBus<TestEvents>();
620
+ const handler = vi.fn();
621
+
622
+ class ListenVM extends ViewModel<TestState> {
623
+ protected onInit() {
624
+ this.listenTo(bus, 'message', handler);
625
+ }
626
+ }
627
+
628
+ const vm = new ListenVM({ count: 0, name: 'test' });
629
+ vm.init();
630
+
631
+ bus.emit('message', { text: 'before-reset' });
632
+ expect(handler).toHaveBeenCalledTimes(1);
633
+
634
+ vm.reset();
635
+
636
+ // Old subscription cleaned up + new one established via onInit re-run
637
+ bus.emit('message', { text: 'after-reset' });
638
+ expect(handler).toHaveBeenCalledTimes(2); // exactly 1 more call, not 2 (no duplicate)
639
+ });
640
+ });
641
+
642
+ describe('pipeChannel', () => {
643
+ interface DataMessages {
644
+ data: { id: string; value: number };
645
+ status: { online: boolean };
646
+ }
647
+
648
+ class TestChannel extends Channel<DataMessages> {
649
+ protected open() { /* no-op */ }
650
+ protected close() { /* no-op */ }
651
+ }
652
+
653
+ it('upserts into collection when channel receives a message', () => {
654
+ const channel = new TestChannel();
655
+ const collection = new Collection<{ id: string; value: number }>();
656
+
657
+ class PipeVM extends ViewModel<TestState> {
658
+ protected onInit() {
659
+ this.pipeChannel(channel, 'data', collection);
660
+ }
661
+ }
662
+
663
+ const vm = new PipeVM({ count: 0, name: 'test' });
664
+ vm.init();
665
+
666
+ (channel as any).receive('data', { id: '1', value: 42 });
667
+ expect(collection.items).toEqual([{ id: '1', value: 42 }]);
668
+
669
+ (channel as any).receive('data', { id: '2', value: 99 });
670
+ expect(collection.items).toEqual([{ id: '1', value: 42 }, { id: '2', value: 99 }]);
671
+
672
+ vm.dispose();
673
+ });
674
+
675
+ it('calls channel.init() automatically', () => {
676
+ const channel = new TestChannel();
677
+ const collection = new Collection<{ id: string; value: number }>();
678
+ const initSpy = vi.spyOn(channel, 'init');
679
+
680
+ class PipeVM extends ViewModel<TestState> {
681
+ protected onInit() {
682
+ this.pipeChannel(channel, 'data', collection);
683
+ }
684
+ }
685
+
686
+ const vm = new PipeVM({ count: 0, name: 'test' });
687
+ vm.init();
688
+
689
+ expect(initSpy).toHaveBeenCalledTimes(1);
690
+
691
+ vm.dispose();
692
+ });
693
+
694
+ it('is idempotent if channel already initialized', () => {
695
+ const channel = new TestChannel();
696
+ const collection = new Collection<{ id: string; value: number }>();
697
+ channel.init();
698
+
699
+ class PipeVM extends ViewModel<TestState> {
700
+ protected onInit() {
701
+ this.pipeChannel(channel, 'data', collection);
702
+ }
703
+ }
704
+
705
+ const vm = new PipeVM({ count: 0, name: 'test' });
706
+ expect(() => vm.init()).not.toThrow();
707
+
708
+ (channel as any).receive('data', { id: '1', value: 1 });
709
+ expect(collection.items).toEqual([{ id: '1', value: 1 }]);
710
+
711
+ vm.dispose();
712
+ channel.dispose();
713
+ });
714
+
715
+ it('auto-unsubscribes on dispose', () => {
716
+ const channel = new TestChannel();
717
+ const collection = new Collection<{ id: string; value: number }>();
718
+
719
+ class PipeVM extends ViewModel<TestState> {
720
+ protected onInit() {
721
+ this.pipeChannel(channel, 'data', collection);
722
+ }
723
+ }
724
+
725
+ const vm = new PipeVM({ count: 0, name: 'test' });
726
+ vm.init();
727
+
728
+ (channel as any).receive('data', { id: '1', value: 1 });
729
+ expect(collection.items).toHaveLength(1);
730
+
731
+ vm.dispose();
732
+
733
+ (channel as any).receive('data', { id: '2', value: 2 });
734
+ expect(collection.items).toHaveLength(1); // not upserted after dispose
735
+ });
736
+
737
+ it('returns unsubscribe function for manual cleanup', () => {
738
+ const channel = new TestChannel();
739
+ const collection = new Collection<{ id: string; value: number }>();
740
+
741
+ class PipeVM extends ViewModel<TestState> {
742
+ unsub!: () => void;
743
+ protected onInit() {
744
+ this.unsub = this.pipeChannel(channel, 'data', collection);
745
+ }
746
+ }
747
+
748
+ const vm = new PipeVM({ count: 0, name: 'test' });
749
+ vm.init();
750
+
751
+ (channel as any).receive('data', { id: '1', value: 1 });
752
+ expect(collection.items).toHaveLength(1);
753
+
754
+ vm.unsub();
755
+
756
+ (channel as any).receive('data', { id: '2', value: 2 });
757
+ expect(collection.items).toHaveLength(1); // stopped after manual unsub
758
+
759
+ vm.dispose();
760
+ });
761
+
762
+ it('cleans up on reset and re-establishes in onInit', () => {
763
+ const channel = new TestChannel();
764
+ const collection = new Collection<{ id: string; value: number }>();
765
+ const upsertSpy = vi.spyOn(collection, 'upsert');
766
+
767
+ class PipeVM extends ViewModel<TestState> {
768
+ protected onInit() {
769
+ this.pipeChannel(channel, 'data', collection);
770
+ }
771
+ }
772
+
773
+ const vm = new PipeVM({ count: 0, name: 'test' });
774
+ vm.init();
775
+
776
+ (channel as any).receive('data', { id: '1', value: 1 });
777
+ expect(upsertSpy).toHaveBeenCalledTimes(1);
778
+
779
+ vm.reset();
780
+
781
+ // After reset: old subscription cleaned up + new one via onInit re-run
782
+ (channel as any).receive('data', { id: '2', value: 2 });
783
+ expect(upsertSpy).toHaveBeenCalledTimes(2); // exactly 1 more call, not 2 (no duplicate)
784
+
785
+ vm.dispose();
786
+ });
787
+
788
+ it('type-narrowing with concrete Channel subclass', () => {
789
+ const channel = new TestChannel();
790
+ const collection = new Collection<{ id: string; value: number }>();
791
+
792
+ class PipeVM extends ViewModel<TestState> {
793
+ protected onInit() {
794
+ // Type-level: payload should be narrowed to DataMessages['data']
795
+ this.pipeChannel(channel, 'data', collection);
796
+ }
797
+ }
798
+
799
+ const vm = new PipeVM({ count: 0, name: 'test' });
800
+ vm.init();
801
+
802
+ (channel as any).receive('data', { id: '1', value: 42 });
803
+ expect(collection.get('1')).toEqual({ id: '1', value: 42 });
804
+
805
+ vm.dispose();
806
+ });
807
+ });
808
+
809
+ describe('reset', () => {
810
+ it('resets to initial state when no arg', () => {
811
+ const vm = new TestViewModel({ count: 0, name: 'test' });
812
+ vm.init();
813
+ vm.increment();
814
+ expect(vm.state.count).toBe(1);
815
+
816
+ vm.reset();
817
+ expect(vm.state).toEqual({ count: 0, name: 'test' });
818
+ });
819
+
820
+ it('resets to provided state', () => {
821
+ const vm = new TestViewModel({ count: 0, name: 'test' });
822
+ vm.init();
823
+ vm.increment();
824
+
825
+ vm.reset({ count: 99, name: 'new' });
826
+ expect(vm.state).toEqual({ count: 99, name: 'new' });
827
+ });
828
+
829
+ it('clears async tracking', async () => {
830
+ class AsyncVM extends ViewModel<TestState> {
831
+ async load() {
832
+ await Promise.resolve();
833
+ this.set({ count: 42 });
834
+ }
835
+ }
836
+
837
+ const vm = new AsyncVM({ count: 0, name: 'test' });
838
+ vm.init();
839
+ await vm.load();
840
+
841
+ // After load, async state exists
842
+ expect(vm.async.load.loading).toBe(false);
843
+ expect(vm.state.count).toBe(42);
844
+
845
+ vm.reset();
846
+
847
+ // Async tracking cleared — returns default
848
+ expect(vm.async.load).toEqual({ loading: false, error: null, errorCode: null });
849
+ // State reset
850
+ expect(vm.state.count).toBe(0);
851
+ });
852
+
853
+ it('re-runs onInit', () => {
854
+ let initCount = 0;
855
+ class InitVM extends ViewModel<TestState> {
856
+ protected onInit() {
857
+ initCount++;
858
+ }
859
+ }
860
+
861
+ const vm = new InitVM({ count: 0, name: 'test' });
862
+ vm.init();
863
+ expect(initCount).toBe(1);
864
+
865
+ vm.reset();
866
+ expect(initCount).toBe(2);
867
+ });
868
+
869
+ it('aborts disposeSignal, new one available after reset', () => {
870
+ const vm = new TestViewModel({ count: 0, name: 'test' });
871
+ vm.init();
872
+ const oldSignal = vm.disposeSignal;
873
+
874
+ vm.reset();
875
+
876
+ expect(oldSignal.aborted).toBe(true);
877
+ expect(vm.disposeSignal.aborted).toBe(false);
878
+ expect(vm.disposeSignal).not.toBe(oldSignal);
879
+
880
+ vm.dispose();
881
+ });
882
+
883
+ it('subscribeTo re-established after reset', () => {
884
+ interface Item { id: string; value: number }
885
+ const collection = new Collection<Item>();
886
+
887
+ class SubVM extends ViewModel<{ items: readonly Item[] }> {
888
+ protected onInit() {
889
+ this.subscribeTo(collection, (items) => {
890
+ this.set({ items });
891
+ });
892
+ this.set({ items: collection.items });
893
+ }
894
+ }
895
+
896
+ const vm = new SubVM({ items: [] });
897
+ vm.init();
898
+
899
+ collection.add({ id: '1', value: 1 });
900
+ expect(vm.state.items).toHaveLength(1);
901
+
902
+ vm.reset();
903
+
904
+ // onInit re-subscribes and mirrors current collection
905
+ expect(vm.state.items).toHaveLength(1);
906
+
907
+ // New subscription works
908
+ collection.add({ id: '2', value: 2 });
909
+ expect(vm.state.items).toHaveLength(2);
910
+
911
+ vm.dispose();
912
+ });
913
+
914
+ it('is no-op when disposed', () => {
915
+ let initCount = 0;
916
+ class InitVM extends ViewModel<TestState> {
917
+ protected onInit() {
918
+ initCount++;
919
+ }
920
+ }
921
+
922
+ const vm = new InitVM({ count: 0, name: 'test' });
923
+ vm.init();
924
+ expect(initCount).toBe(1);
925
+
926
+ vm.dispose();
927
+ vm.reset();
928
+
929
+ expect(initCount).toBe(1); // not called again
930
+ });
931
+
932
+ it('notifies state listeners', () => {
933
+ const vm = new TestViewModel({ count: 0, name: 'test' });
934
+ vm.init();
935
+ vm.increment();
936
+
937
+ const listener = vi.fn();
938
+ vm.subscribe(listener);
939
+
940
+ vm.reset();
941
+
942
+ expect(listener).toHaveBeenCalled();
943
+ expect(listener.mock.calls[0][0]).toEqual({ count: 0, name: 'test' });
944
+
945
+ vm.dispose();
946
+ });
947
+
948
+ it('notifies async listeners', () => {
949
+ class AsyncVM extends ViewModel<TestState> {
950
+ async load() {
951
+ await Promise.resolve();
952
+ }
953
+ }
954
+
955
+ const vm = new AsyncVM({ count: 0, name: 'test' });
956
+ vm.init();
957
+
958
+ const listener = vi.fn();
959
+ vm.subscribeAsync(listener);
960
+
961
+ vm.reset();
962
+
963
+ expect(listener).toHaveBeenCalled();
964
+
965
+ vm.dispose();
966
+ });
967
+ });
968
+
969
+ describe('events', () => {
970
+ interface TestEvents {
971
+ saved: { id: string };
972
+ error: { message: string };
973
+ }
974
+
975
+ class EventVM extends ViewModel<TestState, TestEvents> {
976
+ save(id: string) {
977
+ this.emit('saved', { id });
978
+ }
979
+
980
+ fail(message: string) {
981
+ this.emit('error', { message });
982
+ }
983
+ }
984
+
985
+ it('events getter returns an EventBus', () => {
986
+ const vm = new EventVM({ count: 0, name: 'test' });
987
+ expect(vm.events).toBeInstanceOf(EventBus);
988
+ });
989
+
990
+ it('events getter is lazy — returns same instance', () => {
991
+ const vm = new EventVM({ count: 0, name: 'test' });
992
+ const bus1 = vm.events;
993
+ const bus2 = vm.events;
994
+ expect(bus1).toBe(bus2);
995
+ });
996
+
997
+ it('emit fires handlers via events.on()', () => {
998
+ const vm = new EventVM({ count: 0, name: 'test' });
999
+ const handler = vi.fn();
1000
+ vm.events.on('saved', handler);
1001
+
1002
+ vm.save('abc');
1003
+
1004
+ expect(handler).toHaveBeenCalledTimes(1);
1005
+ expect(handler).toHaveBeenCalledWith({ id: 'abc' });
1006
+ });
1007
+
1008
+ it('eventBus is auto-disposed when ViewModel disposes', () => {
1009
+ const vm = new EventVM({ count: 0, name: 'test' });
1010
+ const bus = vm.events;
1011
+ expect(bus.disposed).toBe(false);
1012
+
1013
+ vm.dispose();
1014
+ expect(bus.disposed).toBe(true);
1015
+ });
1016
+
1017
+ it('emit is a no-op after dispose', () => {
1018
+ const vm = new EventVM({ count: 0, name: 'test' });
1019
+ const handler = vi.fn();
1020
+ vm.events.on('saved', handler);
1021
+ vm.dispose();
1022
+
1023
+ vm.save('abc'); // should not throw
1024
+ expect(handler).not.toHaveBeenCalled();
1025
+ });
1026
+
1027
+ it('no EventBus allocated if events getter is never accessed', () => {
1028
+ const vm = new TestViewModel({ count: 0, name: 'test' });
1029
+ vm.dispose();
1030
+ // If no error is thrown and dispose works, no bus was created
1031
+ expect(vm.disposed).toBe(true);
1032
+ });
1033
+
1034
+ it('cleanup callbacks can still emit events before eventBus disposes', () => {
1035
+ const handler = vi.fn();
1036
+
1037
+ class CleanupEmitVM extends ViewModel<TestState, TestEvents> {
1038
+ setup() {
1039
+ this.addCleanup(() => {
1040
+ this.emit('saved', { id: 'final' });
1041
+ });
1042
+ }
1043
+ }
1044
+
1045
+ const vm = new CleanupEmitVM({ count: 0, name: 'test' });
1046
+ vm.events.on('saved', handler);
1047
+ vm.setup();
1048
+
1049
+ vm.dispose();
1050
+ expect(handler).toHaveBeenCalledWith({ id: 'final' });
1051
+ });
1052
+ });
1053
+
1054
+ describe('method binding', () => {
1055
+ it('methods can be called point-free (detached from instance)', () => {
1056
+ const vm = new TestViewModel({ count: 0, name: 'test' });
1057
+ const { increment, setName } = vm;
1058
+ increment();
1059
+ expect(vm.state.count).toBe(1);
1060
+ setName('updated');
1061
+ expect(vm.state.name).toBe('updated');
1062
+ });
1063
+
1064
+ it('bound methods work before init()', () => {
1065
+ const vm = new TestViewModel({ count: 0, name: 'test' });
1066
+ const fn = vm.increment;
1067
+ fn();
1068
+ expect(vm.state.count).toBe(1);
1069
+ });
1070
+
1071
+ it('bound methods work after init() replaces them with async wrappers', async () => {
1072
+ class AsyncVM extends ViewModel<{ value: number }> {
1073
+ async load() {
1074
+ this.set({ value: 42 });
1075
+ }
1076
+ setValue(value: number) {
1077
+ this.set({ value });
1078
+ }
1079
+ }
1080
+
1081
+ const vm = new AsyncVM({ value: 0 });
1082
+ await vm.init();
1083
+
1084
+ // After init, async wrappers replace the constructor bindings
1085
+ const { load, setValue } = vm;
1086
+ await load();
1087
+ expect(vm.state.value).toBe(42);
1088
+ setValue(99);
1089
+ expect(vm.state.value).toBe(99);
1090
+ });
1091
+
1092
+ it('bound methods survive being passed as React-style callbacks', () => {
1093
+ const vm = new TestViewModel({ count: 0, name: 'test' });
1094
+ // Simulate React passing the method as a callback
1095
+ const callback: (name: string) => void = vm.setName;
1096
+ callback('from-callback');
1097
+ expect(vm.state.name).toBe('from-callback');
1098
+ });
1099
+
1100
+ it('method reference captured before init() still works after init()', async () => {
1101
+ class FilterVM extends ViewModel<{ filter: string }> {
1102
+ async load() { /* noop */ }
1103
+ setFilter(filter: string) { this.set({ filter }); }
1104
+ }
1105
+
1106
+ const vm = new FilterVM({ filter: '' });
1107
+ // Capture reference BEFORE init (simulates React first render)
1108
+ const setFilter = vm.setFilter;
1109
+
1110
+ await vm.init(); // wrappers replace constructor bindings on the instance
1111
+
1112
+ // The captured reference is the constructor-bound function, still works
1113
+ setFilter('active');
1114
+ expect(vm.state.filter).toBe('active');
1115
+ });
1116
+ });
1117
+
1118
+ describe('draft mode in set()', () => {
1119
+ interface DraftState {
1120
+ count: number;
1121
+ name: string;
1122
+ config: { theme: string; fontSize: number };
1123
+ }
1124
+
1125
+ class DraftVM extends ViewModel<DraftState> {
1126
+ draftFlat(count: number) {
1127
+ this.set((d) => { d.count = count; });
1128
+ }
1129
+
1130
+ draftNested(theme: string) {
1131
+ this.set((d) => { d.config.theme = theme; });
1132
+ }
1133
+
1134
+ draftMultiple(count: number, theme: string) {
1135
+ this.set((d) => {
1136
+ d.count = count;
1137
+ d.config.theme = theme;
1138
+ });
1139
+ }
1140
+
1141
+ // Existing updater pattern
1142
+ updaterFlat(count: number) {
1143
+ this.set(() => ({ count }));
1144
+ }
1145
+
1146
+ // Object literal pattern
1147
+ literalFlat(count: number) {
1148
+ this.set({ count });
1149
+ }
1150
+ }
1151
+
1152
+ function createVM() {
1153
+ return new DraftVM({ count: 0, name: 'test', config: { theme: 'dark', fontSize: 14 } });
1154
+ }
1155
+
1156
+ it('draft mode: flat mutation updates state', () => {
1157
+ const vm = createVM();
1158
+ vm.draftFlat(5);
1159
+ expect(vm.state.count).toBe(5);
1160
+ });
1161
+
1162
+ it('draft mode: nested mutation updates state', () => {
1163
+ const vm = createVM();
1164
+ vm.draftNested('light');
1165
+ expect(vm.state.config.theme).toBe('light');
1166
+ expect(vm.state.config.fontSize).toBe(14); // preserved
1167
+ });
1168
+
1169
+ it('draft mode: mixed flat + nested mutation', () => {
1170
+ const vm = createVM();
1171
+ vm.draftMultiple(10, 'blue');
1172
+ expect(vm.state.count).toBe(10);
1173
+ expect(vm.state.config.theme).toBe('blue');
1174
+ expect(vm.state.name).toBe('test'); // unchanged
1175
+ });
1176
+
1177
+ it('draft mode: no-op does not notify listeners', () => {
1178
+ const vm = createVM();
1179
+ const listener = vi.fn();
1180
+ vm.subscribe(listener);
1181
+
1182
+ vm.draftFlat(0); // same as initial
1183
+ expect(listener).not.toHaveBeenCalled();
1184
+ });
1185
+
1186
+ it('draft mode: nested no-op does not notify', () => {
1187
+ const vm = createVM();
1188
+ const listener = vi.fn();
1189
+ vm.subscribe(listener);
1190
+
1191
+ vm.draftNested('dark'); // same as initial
1192
+ expect(listener).not.toHaveBeenCalled();
1193
+ });
1194
+
1195
+ it('existing updater pattern still works', () => {
1196
+ const vm = createVM();
1197
+ vm.updaterFlat(42);
1198
+ expect(vm.state.count).toBe(42);
1199
+ });
1200
+
1201
+ it('object literal pattern still works', () => {
1202
+ const vm = createVM();
1203
+ vm.literalFlat(99);
1204
+ expect(vm.state.count).toBe(99);
1205
+ });
1206
+
1207
+ it('draft mode: onSet hook receives correct prev/next', () => {
1208
+ class HookDraftVM extends ViewModel<DraftState> {
1209
+ calls: Array<{ prev: DraftState; next: DraftState }> = [];
1210
+ protected onSet(prev: DraftState, next: DraftState) {
1211
+ this.calls.push({ prev: { ...prev }, next: { ...next } });
1212
+ }
1213
+ draftUpdate(count: number) {
1214
+ this.set((d) => { d.count = count; });
1215
+ }
1216
+ }
1217
+
1218
+ const vm = new HookDraftVM({ count: 0, name: 'test', config: { theme: 'dark', fontSize: 14 } });
1219
+ vm.draftUpdate(5);
1220
+
1221
+ expect(vm.calls).toHaveLength(1);
1222
+ expect(vm.calls[0].prev.count).toBe(0);
1223
+ expect(vm.calls[0].next.count).toBe(5);
1224
+ });
1225
+
1226
+ it('draft mode: structural sharing preserves unchanged nested refs', () => {
1227
+ const vm = createVM();
1228
+ const configBefore = vm.state.config;
1229
+
1230
+ vm.draftFlat(1); // Only change count, not config
1231
+
1232
+ // Config reference should be unchanged (not in the partial)
1233
+ expect(vm.state.config).toBe(configBefore);
1234
+ });
1235
+ });
1236
+ });