mvc-kit 2.12.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +10 -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,957 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Channel, type ChannelStatus } from './Channel';
3
+ import { EventBus } from './EventBus';
4
+ import { ViewModel } from './ViewModel';
5
+ import { singleton, teardownAll } from './singleton';
6
+
7
+ // ── Test helpers ──────────────────────────────────────────────────
8
+
9
+ interface TestMessages {
10
+ chat: { text: string };
11
+ typing: { userId: string };
12
+ status: { online: boolean };
13
+ }
14
+
15
+ class TestChannel extends Channel<TestMessages> {
16
+ openCalls: AbortSignal[] = [];
17
+ closeCalls = 0;
18
+ openBehavior: 'resolve' | 'reject' | 'pending' = 'resolve';
19
+ openError = new Error('connection failed');
20
+ private _pendingResolve: (() => void) | null = null;
21
+ private _pendingReject: ((e: Error) => void) | null = null;
22
+
23
+ // Deterministic delay for test predictability
24
+ protected override _calculateDelay() { return 1000; }
25
+
26
+ protected open(signal: AbortSignal): Promise<void> {
27
+ this.openCalls.push(signal);
28
+ if (this.openBehavior === 'resolve') {
29
+ return Promise.resolve();
30
+ }
31
+ if (this.openBehavior === 'reject') {
32
+ return Promise.reject(this.openError);
33
+ }
34
+ // pending — manual control
35
+ return new Promise<void>((resolve, reject) => {
36
+ this._pendingResolve = resolve;
37
+ this._pendingReject = reject;
38
+ });
39
+ }
40
+
41
+ protected close(): void {
42
+ this.closeCalls++;
43
+ }
44
+
45
+ // Test helpers
46
+ resolveOpen(): void { this._pendingResolve?.(); this._pendingResolve = null; }
47
+ rejectOpen(e?: Error): void { this._pendingReject?.(e ?? this.openError); this._pendingReject = null; }
48
+ simulateDisconnect(): void { this.disconnected(); }
49
+ simulateReceive<K extends keyof TestMessages>(type: K, payload: TestMessages[K]): void {
50
+ this.receive(type, payload);
51
+ }
52
+ }
53
+
54
+ class SyncOpenChannel extends Channel<TestMessages> {
55
+ openCalls = 0;
56
+ closeCalls = 0;
57
+
58
+ protected open(_signal: AbortSignal): void {
59
+ this.openCalls++;
60
+ // synchronous open — no Promise returned
61
+ }
62
+
63
+ protected close(): void {
64
+ this.closeCalls++;
65
+ }
66
+
67
+ simulateDisconnect(): void { this.disconnected(); }
68
+ simulateReceive<K extends keyof TestMessages>(type: K, payload: TestMessages[K]): void {
69
+ this.receive(type, payload);
70
+ }
71
+ }
72
+
73
+ // ── Tests ─────────────────────────────────────────────────────────
74
+
75
+ describe('Channel', () => {
76
+ beforeEach(() => {
77
+ vi.useFakeTimers();
78
+ teardownAll();
79
+ });
80
+
81
+ afterEach(() => {
82
+ vi.useRealTimers();
83
+ teardownAll();
84
+ });
85
+
86
+ // ── Initialization & Dispose ────────────────────────────────────
87
+
88
+ describe('initialization', () => {
89
+ it('starts uninitialized and undisposed', () => {
90
+ const ch = new TestChannel();
91
+ expect(ch.initialized).toBe(false);
92
+ expect(ch.disposed).toBe(false);
93
+ });
94
+
95
+ it('init sets initialized flag', () => {
96
+ const ch = new TestChannel();
97
+ ch.init();
98
+ expect(ch.initialized).toBe(true);
99
+ });
100
+
101
+ it('init is idempotent', () => {
102
+ let calls = 0;
103
+ class InitChannel extends Channel<TestMessages> {
104
+ protected open(): void {}
105
+ protected close(): void {}
106
+ protected onInit() { calls++; }
107
+ }
108
+ const ch = new InitChannel();
109
+ ch.init();
110
+ ch.init();
111
+ expect(calls).toBe(1);
112
+ });
113
+
114
+ it('init does not run after dispose', () => {
115
+ let calls = 0;
116
+ class InitChannel extends Channel<TestMessages> {
117
+ protected open(): void {}
118
+ protected close(): void {}
119
+ protected onInit() { calls++; }
120
+ }
121
+ const ch = new InitChannel();
122
+ ch.dispose();
123
+ ch.init();
124
+ expect(calls).toBe(0);
125
+ });
126
+
127
+ it('onInit is called during init', () => {
128
+ let called = false;
129
+ class InitChannel extends Channel<TestMessages> {
130
+ protected open(): void {}
131
+ protected close(): void {}
132
+ protected onInit() { called = true; }
133
+ }
134
+ const ch = new InitChannel();
135
+ ch.init();
136
+ expect(called).toBe(true);
137
+ });
138
+ });
139
+
140
+ describe('dispose', () => {
141
+ it('sets disposed flag', () => {
142
+ const ch = new TestChannel();
143
+ ch.dispose();
144
+ expect(ch.disposed).toBe(true);
145
+ });
146
+
147
+ it('is idempotent', () => {
148
+ let calls = 0;
149
+ class DisposeChannel extends Channel<TestMessages> {
150
+ protected open(): void {}
151
+ protected close(): void {}
152
+ protected onDispose() { calls++; }
153
+ }
154
+ const ch = new DisposeChannel();
155
+ ch.dispose();
156
+ ch.dispose();
157
+ expect(calls).toBe(1);
158
+ });
159
+
160
+ it('calls onDispose', () => {
161
+ let called = false;
162
+ class DisposeChannel extends Channel<TestMessages> {
163
+ protected open(): void {}
164
+ protected close(): void {}
165
+ protected onDispose() { called = true; }
166
+ }
167
+ const ch = new DisposeChannel();
168
+ ch.dispose();
169
+ expect(called).toBe(true);
170
+ });
171
+
172
+ it('calls close on dispose', () => {
173
+ const ch = new TestChannel();
174
+ ch.init();
175
+ ch.connect();
176
+ // Let open resolve
177
+ vi.runAllTimers();
178
+ ch.dispose();
179
+ expect(ch.closeCalls).toBe(1);
180
+ });
181
+
182
+ it('aborts disposeSignal on dispose', () => {
183
+ const ch = new TestChannel();
184
+ const signal = ch.disposeSignal;
185
+ expect(signal.aborted).toBe(false);
186
+ ch.dispose();
187
+ expect(signal.aborted).toBe(true);
188
+ });
189
+
190
+ it('runs cleanups on dispose', () => {
191
+ let cleaned = false;
192
+ class CleanupChannel extends Channel<TestMessages> {
193
+ protected open(): void {}
194
+ protected close(): void {}
195
+ protected onInit() { this.addCleanup(() => { cleaned = true; }); }
196
+ }
197
+ const ch = new CleanupChannel();
198
+ ch.init();
199
+ ch.dispose();
200
+ expect(cleaned).toBe(true);
201
+ });
202
+
203
+ it('cancels pending reconnect timer on dispose', async () => {
204
+ const ch = new TestChannel();
205
+ ch.openBehavior = 'reject';
206
+ ch.init();
207
+ ch.connect();
208
+ await vi.advanceTimersByTimeAsync(0); // let open reject
209
+ // Now in reconnecting state with a timer scheduled
210
+ expect(ch.state.reconnecting).toBe(true);
211
+ ch.dispose();
212
+ // Timer should not fire after dispose
213
+ vi.advanceTimersByTime(100_000);
214
+ expect(ch.disposed).toBe(true);
215
+ });
216
+ });
217
+
218
+ // ── Subscribable ────────────────────────────────────────────────
219
+
220
+ describe('subscribable status', () => {
221
+ it('initial status is idle', () => {
222
+ const ch = new TestChannel();
223
+ expect(ch.state).toEqual({
224
+ connected: false,
225
+ reconnecting: false,
226
+ attempt: 0,
227
+ error: null,
228
+ });
229
+ });
230
+
231
+ it('status is frozen', () => {
232
+ const ch = new TestChannel();
233
+ expect(Object.isFrozen(ch.state)).toBe(true);
234
+ });
235
+
236
+ it('subscribe notifies on status change', async () => {
237
+ const ch = new TestChannel();
238
+ ch.init();
239
+ const statuses: ChannelStatus[] = [];
240
+ ch.subscribe((next) => statuses.push(next));
241
+
242
+ ch.connect();
243
+ await vi.advanceTimersByTimeAsync(0);
244
+
245
+ expect(statuses.length).toBeGreaterThanOrEqual(1);
246
+ expect(statuses[statuses.length - 1].connected).toBe(true);
247
+ });
248
+
249
+ it('subscribe returns unsubscribe function', async () => {
250
+ const ch = new TestChannel();
251
+ ch.init();
252
+ const statuses: ChannelStatus[] = [];
253
+ const unsub = ch.subscribe((next) => statuses.push(next));
254
+ unsub();
255
+
256
+ ch.connect();
257
+ await vi.advanceTimersByTimeAsync(0);
258
+
259
+ expect(statuses).toHaveLength(0);
260
+ });
261
+
262
+ it('subscribe on disposed channel returns no-op', () => {
263
+ const ch = new TestChannel();
264
+ ch.dispose();
265
+ const unsub = ch.subscribe(() => {});
266
+ expect(typeof unsub).toBe('function');
267
+ unsub(); // should not throw
268
+ });
269
+
270
+ it('listener receives prev and next', async () => {
271
+ const ch = new TestChannel();
272
+ ch.init();
273
+ let received: { prev: ChannelStatus; next: ChannelStatus } | null = null;
274
+ ch.subscribe((next, prev) => { received = { prev, next }; });
275
+
276
+ ch.connect();
277
+ await vi.advanceTimersByTimeAsync(0);
278
+
279
+ expect(received).not.toBeNull();
280
+ expect(received!.prev.connected).toBe(false);
281
+ expect(received!.next.connected).toBe(true);
282
+ });
283
+ });
284
+
285
+ // ── Message Routing ─────────────────────────────────────────────
286
+
287
+ describe('message routing', () => {
288
+ it('on() receives messages', () => {
289
+ const ch = new TestChannel();
290
+ ch.init();
291
+ const messages: TestMessages['chat'][] = [];
292
+ ch.on('chat', (msg) => messages.push(msg));
293
+
294
+ ch.simulateReceive('chat', { text: 'hello' });
295
+ expect(messages).toEqual([{ text: 'hello' }]);
296
+ });
297
+
298
+ it('on() returns unsubscribe function', () => {
299
+ const ch = new TestChannel();
300
+ ch.init();
301
+ const messages: TestMessages['chat'][] = [];
302
+ const unsub = ch.on('chat', (msg) => messages.push(msg));
303
+
304
+ ch.simulateReceive('chat', { text: 'hello' });
305
+ unsub();
306
+ ch.simulateReceive('chat', { text: 'world' });
307
+
308
+ expect(messages).toEqual([{ text: 'hello' }]);
309
+ });
310
+
311
+ it('multiple handlers for same type', () => {
312
+ const ch = new TestChannel();
313
+ ch.init();
314
+ const a: string[] = [];
315
+ const b: string[] = [];
316
+ ch.on('chat', (msg) => a.push(msg.text));
317
+ ch.on('chat', (msg) => b.push(msg.text));
318
+
319
+ ch.simulateReceive('chat', { text: 'hello' });
320
+ expect(a).toEqual(['hello']);
321
+ expect(b).toEqual(['hello']);
322
+ });
323
+
324
+ it('handlers for different types are independent', () => {
325
+ const ch = new TestChannel();
326
+ ch.init();
327
+ const chats: TestMessages['chat'][] = [];
328
+ const typings: TestMessages['typing'][] = [];
329
+ ch.on('chat', (msg) => chats.push(msg));
330
+ ch.on('typing', (msg) => typings.push(msg));
331
+
332
+ ch.simulateReceive('chat', { text: 'hello' });
333
+ ch.simulateReceive('typing', { userId: 'u1' });
334
+
335
+ expect(chats).toEqual([{ text: 'hello' }]);
336
+ expect(typings).toEqual([{ userId: 'u1' }]);
337
+ });
338
+
339
+ it('once() fires only once', () => {
340
+ const ch = new TestChannel();
341
+ ch.init();
342
+ const messages: TestMessages['chat'][] = [];
343
+ ch.once('chat', (msg) => messages.push(msg));
344
+
345
+ ch.simulateReceive('chat', { text: 'first' });
346
+ ch.simulateReceive('chat', { text: 'second' });
347
+
348
+ expect(messages).toEqual([{ text: 'first' }]);
349
+ });
350
+
351
+ it('once() unsubscribe prevents callback', () => {
352
+ const ch = new TestChannel();
353
+ ch.init();
354
+ const messages: TestMessages['chat'][] = [];
355
+ const unsub = ch.once('chat', (msg) => messages.push(msg));
356
+ unsub();
357
+
358
+ ch.simulateReceive('chat', { text: 'hello' });
359
+ expect(messages).toEqual([]);
360
+ });
361
+
362
+ it('receive after dispose is ignored', () => {
363
+ const ch = new TestChannel();
364
+ ch.init();
365
+ const messages: TestMessages['chat'][] = [];
366
+ ch.on('chat', (msg) => messages.push(msg));
367
+ ch.dispose();
368
+
369
+ // handlers are cleared on dispose, so this should be a no-op
370
+ ch.simulateReceive('chat', { text: 'hello' });
371
+ expect(messages).toEqual([]);
372
+ });
373
+
374
+ it('on() on disposed channel returns no-op', () => {
375
+ const ch = new TestChannel();
376
+ ch.dispose();
377
+ const unsub = ch.on('chat', () => {});
378
+ expect(typeof unsub).toBe('function');
379
+ unsub(); // should not throw
380
+ });
381
+ });
382
+
383
+ // ── Connection Lifecycle ────────────────────────────────────────
384
+
385
+ describe('connect lifecycle', () => {
386
+ it('connect calls open with signal', async () => {
387
+ const ch = new TestChannel();
388
+ ch.init();
389
+ ch.connect();
390
+ await vi.advanceTimersByTimeAsync(0);
391
+
392
+ expect(ch.openCalls).toHaveLength(1);
393
+ expect(ch.openCalls[0]).toBeInstanceOf(AbortSignal);
394
+ });
395
+
396
+ it('status transitions to connected on success', async () => {
397
+ const ch = new TestChannel();
398
+ ch.init();
399
+ ch.connect();
400
+ await vi.advanceTimersByTimeAsync(0);
401
+
402
+ expect(ch.state.connected).toBe(true);
403
+ expect(ch.state.reconnecting).toBe(false);
404
+ expect(ch.state.attempt).toBe(0);
405
+ expect(ch.state.error).toBeNull();
406
+ });
407
+
408
+ it('synchronous open works', () => {
409
+ const ch = new SyncOpenChannel();
410
+ ch.init();
411
+ ch.connect();
412
+
413
+ expect(ch.openCalls).toBe(1);
414
+ expect(ch.state.connected).toBe(true);
415
+ });
416
+
417
+ it('connect is idempotent when already connected', async () => {
418
+ const ch = new TestChannel();
419
+ ch.init();
420
+ ch.connect();
421
+ await vi.advanceTimersByTimeAsync(0);
422
+
423
+ ch.connect();
424
+ expect(ch.openCalls).toHaveLength(1); // not called again
425
+ });
426
+
427
+ it('connect is idempotent when connecting', () => {
428
+ const ch = new TestChannel();
429
+ ch.openBehavior = 'pending';
430
+ ch.init();
431
+ ch.connect();
432
+ ch.connect();
433
+ expect(ch.openCalls).toHaveLength(1);
434
+ });
435
+
436
+ it('connect after dispose is ignored', () => {
437
+ const ch = new TestChannel();
438
+ ch.init();
439
+ ch.dispose();
440
+ ch.connect();
441
+ expect(ch.openCalls).toHaveLength(0);
442
+ });
443
+ });
444
+
445
+ describe('disconnect', () => {
446
+ it('disconnect calls close and resets status', async () => {
447
+ const ch = new TestChannel();
448
+ ch.init();
449
+ ch.connect();
450
+ await vi.advanceTimersByTimeAsync(0);
451
+ expect(ch.state.connected).toBe(true);
452
+
453
+ ch.disconnect();
454
+ expect(ch.closeCalls).toBe(1);
455
+ expect(ch.state.connected).toBe(false);
456
+ expect(ch.state.reconnecting).toBe(false);
457
+ });
458
+
459
+ it('disconnect cancels pending reconnect', async () => {
460
+ const ch = new TestChannel();
461
+ ch.openBehavior = 'reject';
462
+ ch.init();
463
+ ch.connect();
464
+ await vi.advanceTimersByTimeAsync(0);
465
+ expect(ch.state.reconnecting).toBe(true);
466
+
467
+ ch.disconnect();
468
+ expect(ch.state.reconnecting).toBe(false);
469
+ // Advance timers — should not attempt reconnect
470
+ vi.advanceTimersByTime(100_000);
471
+ // Only 1 open call (the initial failed one)
472
+ expect(ch.openCalls).toHaveLength(1);
473
+ });
474
+
475
+ it('disconnect aborts in-flight connect', () => {
476
+ const ch = new TestChannel();
477
+ ch.openBehavior = 'pending';
478
+ ch.init();
479
+ ch.connect();
480
+
481
+ const signal = ch.openCalls[0];
482
+ expect(signal.aborted).toBe(false);
483
+
484
+ ch.disconnect();
485
+ expect(signal.aborted).toBe(true);
486
+ });
487
+
488
+ it('disconnect on idle is no-op', () => {
489
+ const ch = new TestChannel();
490
+ ch.init();
491
+ ch.disconnect();
492
+ expect(ch.closeCalls).toBe(0);
493
+ });
494
+
495
+ it('disconnect on disposed is no-op', () => {
496
+ const ch = new TestChannel();
497
+ ch.dispose();
498
+ const closesBefore = ch.closeCalls;
499
+ ch.disconnect();
500
+ expect(ch.closeCalls).toBe(closesBefore); // disconnect added no extra close calls
501
+ });
502
+ });
503
+
504
+ // ── Reconnection ───────────────────────────────────────────────
505
+
506
+ describe('reconnection', () => {
507
+ it('reconnects after open failure', async () => {
508
+ const ch = new TestChannel();
509
+ ch.openBehavior = 'reject';
510
+ ch.init();
511
+ ch.connect();
512
+ await vi.advanceTimersByTimeAsync(0);
513
+
514
+ expect(ch.state.reconnecting).toBe(true);
515
+ expect(ch.state.attempt).toBe(1);
516
+ expect(ch.state.error).toBe('connection failed');
517
+
518
+ // Switch to resolve for next attempt
519
+ ch.openBehavior = 'resolve';
520
+ await vi.advanceTimersByTimeAsync(60_000); // plenty of time
521
+
522
+ expect(ch.state.connected).toBe(true);
523
+ expect(ch.state.reconnecting).toBe(false);
524
+ expect(ch.openCalls).toHaveLength(2);
525
+ });
526
+
527
+ it('reconnects after disconnected() signal', async () => {
528
+ const ch = new TestChannel();
529
+ ch.init();
530
+ ch.connect();
531
+ await vi.advanceTimersByTimeAsync(0);
532
+ expect(ch.state.connected).toBe(true);
533
+
534
+ ch.simulateDisconnect();
535
+ expect(ch.state.connected).toBe(false);
536
+ expect(ch.state.reconnecting).toBe(true);
537
+ expect(ch.state.attempt).toBe(1);
538
+
539
+ await vi.advanceTimersByTimeAsync(60_000);
540
+ expect(ch.state.connected).toBe(true);
541
+ expect(ch.openCalls).toHaveLength(2);
542
+ });
543
+
544
+ it('stops after MAX_ATTEMPTS', async () => {
545
+ class LimitedChannel extends TestChannel {
546
+ static override MAX_ATTEMPTS = 2;
547
+ protected override _calculateDelay() { return 100; }
548
+ }
549
+ const ch = new LimitedChannel();
550
+ ch.openBehavior = 'reject';
551
+ ch.init();
552
+ ch.connect();
553
+
554
+ // Attempt 0 (initial) → fail → schedule attempt 1
555
+ await vi.advanceTimersByTimeAsync(0);
556
+ expect(ch.state.reconnecting).toBe(true);
557
+ expect(ch.state.attempt).toBe(1);
558
+
559
+ // Timer fires → attempt 1 → fail → schedule attempt 2
560
+ await vi.advanceTimersByTimeAsync(100);
561
+ expect(ch.state.attempt).toBe(2);
562
+
563
+ // Timer fires → attempt 2 → fail → attempt 3 > MAX_ATTEMPTS(2) → stop
564
+ await vi.advanceTimersByTimeAsync(100);
565
+ expect(ch.state.reconnecting).toBe(false);
566
+ expect(ch.state.connected).toBe(false);
567
+ expect(ch.state.error).not.toBeNull();
568
+ });
569
+
570
+ it('static config overrides affect backoff', () => {
571
+ class FastChannel extends Channel<TestMessages> {
572
+ static override RECONNECT_BASE = 100;
573
+ static override RECONNECT_MAX = 500;
574
+ static override RECONNECT_FACTOR = 1;
575
+ protected open(): void {}
576
+ protected close(): void {}
577
+ }
578
+
579
+ const ch = new FastChannel();
580
+ // Jitter ensures delay is <= RECONNECT_BASE for attempt 0
581
+ const delay = ch['_calculateDelay'](0);
582
+ expect(delay).toBeLessThanOrEqual(100);
583
+ expect(delay).toBeGreaterThanOrEqual(0);
584
+ });
585
+
586
+ it('backoff delay respects max', () => {
587
+ class MaxChannel extends Channel<TestMessages> {
588
+ static override RECONNECT_BASE = 1000;
589
+ static override RECONNECT_MAX = 5000;
590
+ static override RECONNECT_FACTOR = 10;
591
+ protected open(): void {}
592
+ protected close(): void {}
593
+ }
594
+
595
+ const ch = new MaxChannel();
596
+ // High attempt should cap at max
597
+ const delay = ch['_calculateDelay'](100);
598
+ expect(delay).toBeLessThanOrEqual(5000);
599
+ });
600
+
601
+ it('reconnect can be interrupted by disconnect', async () => {
602
+ const ch = new TestChannel();
603
+ ch.openBehavior = 'reject';
604
+ ch.init();
605
+ ch.connect();
606
+ await vi.advanceTimersByTimeAsync(0);
607
+ expect(ch.state.reconnecting).toBe(true);
608
+
609
+ ch.disconnect();
610
+ expect(ch.state.reconnecting).toBe(false);
611
+
612
+ // No more connect attempts
613
+ vi.advanceTimersByTime(100_000);
614
+ expect(ch.openCalls).toHaveLength(1);
615
+ });
616
+
617
+ it('connect during reconnecting resets attempt', async () => {
618
+ const ch = new TestChannel();
619
+ ch.openBehavior = 'reject';
620
+ ch.init();
621
+ ch.connect();
622
+ await vi.advanceTimersByTimeAsync(0);
623
+ expect(ch.state.reconnecting).toBe(true);
624
+ expect(ch.state.attempt).toBe(1);
625
+
626
+ // User calls connect() — should reset
627
+ ch.openBehavior = 'resolve';
628
+ ch.connect();
629
+ await vi.advanceTimersByTimeAsync(0);
630
+
631
+ expect(ch.state.connected).toBe(true);
632
+ expect(ch.state.attempt).toBe(0);
633
+ });
634
+ });
635
+
636
+ // ── Edge Cases ──────────────────────────────────────────────────
637
+
638
+ describe('edge cases', () => {
639
+ it('disconnected during idle is ignored', () => {
640
+ const ch = new TestChannel();
641
+ ch.init();
642
+ ch.simulateDisconnect(); // not connected
643
+ expect(ch.state.reconnecting).toBe(false);
644
+ });
645
+
646
+ it('disconnected during reconnect is ignored', async () => {
647
+ const ch = new TestChannel();
648
+ ch.openBehavior = 'reject';
649
+ ch.init();
650
+ ch.connect();
651
+ await vi.advanceTimersByTimeAsync(0);
652
+ expect(ch.state.reconnecting).toBe(true);
653
+ const attempt = ch.state.attempt;
654
+
655
+ // disconnected() during reconnect should be ignored
656
+ ch.simulateDisconnect();
657
+ expect(ch.state.attempt).toBe(attempt); // unchanged
658
+ });
659
+
660
+ it('dispose during pending open', () => {
661
+ const ch = new TestChannel();
662
+ ch.openBehavior = 'pending';
663
+ ch.init();
664
+ ch.connect();
665
+
666
+ const signal = ch.openCalls[0];
667
+ ch.dispose();
668
+
669
+ expect(signal.aborted).toBe(true);
670
+ expect(ch.disposed).toBe(true);
671
+ });
672
+
673
+ it('dispose during backoff timer', async () => {
674
+ const ch = new TestChannel();
675
+ ch.openBehavior = 'reject';
676
+ ch.init();
677
+ ch.connect();
678
+ await vi.advanceTimersByTimeAsync(0);
679
+ expect(ch.state.reconnecting).toBe(true);
680
+
681
+ ch.dispose();
682
+ // Timer should not fire
683
+ vi.advanceTimersByTime(100_000);
684
+ expect(ch.openCalls).toHaveLength(1);
685
+ });
686
+
687
+ it('close error during dispose does not throw', () => {
688
+ class ThrowCloseChannel extends Channel<TestMessages> {
689
+ protected open(): void {}
690
+ protected close(): void { throw new Error('close failed'); }
691
+ }
692
+ const ch = new ThrowCloseChannel();
693
+ ch.init();
694
+ expect(() => ch.dispose()).not.toThrow();
695
+ });
696
+
697
+ it('subscribeTo auto-cleans up on dispose', () => {
698
+ let unsubbed = false;
699
+ const fakeSource = {
700
+ state: {},
701
+ disposed: false,
702
+ disposeSignal: new AbortController().signal,
703
+ subscribe: () => {
704
+ return () => { unsubbed = true; };
705
+ },
706
+ dispose: () => {},
707
+ };
708
+
709
+ class SubChannel extends Channel<TestMessages> {
710
+ protected open(): void {}
711
+ protected close(): void {}
712
+ protected onInit() {
713
+ this.subscribeTo(fakeSource as any, () => {});
714
+ }
715
+ }
716
+
717
+ const ch = new SubChannel();
718
+ ch.init();
719
+ expect(unsubbed).toBe(false);
720
+ ch.dispose();
721
+ expect(unsubbed).toBe(true);
722
+ });
723
+
724
+ it('connect then disconnect then connect works', async () => {
725
+ const ch = new TestChannel();
726
+ ch.init();
727
+ ch.connect();
728
+ await vi.advanceTimersByTimeAsync(0);
729
+ expect(ch.state.connected).toBe(true);
730
+
731
+ ch.disconnect();
732
+ expect(ch.state.connected).toBe(false);
733
+
734
+ ch.connect();
735
+ await vi.advanceTimersByTimeAsync(0);
736
+ expect(ch.state.connected).toBe(true);
737
+ expect(ch.openCalls).toHaveLength(2);
738
+ });
739
+ });
740
+
741
+ // ── Singleton integration ───────────────────────────────────────
742
+
743
+ describe('singleton integration', () => {
744
+ it('works with singleton registry', () => {
745
+ const ch1 = singleton(TestChannel);
746
+ const ch2 = singleton(TestChannel);
747
+ expect(ch1).toBe(ch2);
748
+ });
749
+
750
+ it('teardownAll disposes channel', () => {
751
+ const ch = singleton(TestChannel);
752
+ expect(ch.disposed).toBe(false);
753
+ teardownAll();
754
+ expect(ch.disposed).toBe(true);
755
+ });
756
+ });
757
+
758
+ // ── Auto-tracking with ViewModel ────────────────────────────────
759
+
760
+ describe('ViewModel auto-tracking', () => {
761
+ interface VMState { label: string }
762
+
763
+ it('channel status changes trigger ViewModel re-render', async () => {
764
+ const ch = singleton(TestChannel);
765
+ ch.init();
766
+
767
+ class TrackingVM extends ViewModel<VMState> {
768
+ private channel = singleton(TestChannel);
769
+ get isConnected() { return this.channel.state.connected; }
770
+ }
771
+
772
+ const vm = new TrackingVM({ label: '' });
773
+ vm.init();
774
+
775
+ expect(vm.isConnected).toBe(false);
776
+
777
+ const listener = vi.fn();
778
+ vm.subscribe(listener);
779
+
780
+ ch.connect();
781
+ await vi.advanceTimersByTimeAsync(0);
782
+
783
+ // ViewModel listener should have been called (subscribable notification)
784
+ expect(listener).toHaveBeenCalled();
785
+ expect(vm.isConnected).toBe(true);
786
+
787
+ vm.dispose();
788
+ });
789
+
790
+ it('channel is auto-detected as subscribable', () => {
791
+ const ch = new TestChannel();
792
+ // Duck-type check: has subscribe method
793
+ expect(typeof ch.subscribe).toBe('function');
794
+ });
795
+
796
+ it('getter re-evaluates when channel status changes', async () => {
797
+ const ch = singleton(TestChannel);
798
+ ch.init();
799
+
800
+ let computeCount = 0;
801
+
802
+ class CountVM extends ViewModel<VMState> {
803
+ private channel = singleton(TestChannel);
804
+ get connectionStatus(): string {
805
+ computeCount++;
806
+ return this.channel.state.connected ? 'online' : 'offline';
807
+ }
808
+ }
809
+
810
+ const vm = new CountVM({ label: '' });
811
+ vm.init();
812
+
813
+ expect(vm.connectionStatus).toBe('offline');
814
+ const firstCount = computeCount;
815
+
816
+ // Access again — should be memoized (tier 1)
817
+ expect(vm.connectionStatus).toBe('offline');
818
+ expect(computeCount).toBe(firstCount);
819
+
820
+ // Change channel status
821
+ ch.connect();
822
+ await vi.advanceTimersByTimeAsync(0);
823
+
824
+ // Getter should recompute
825
+ expect(vm.connectionStatus).toBe('online');
826
+ expect(computeCount).toBeGreaterThan(firstCount);
827
+
828
+ vm.dispose();
829
+ });
830
+ });
831
+
832
+ // ── disposeSignal ───────────────────────────────────────────────
833
+
834
+ describe('disposeSignal', () => {
835
+ it('is lazily created', () => {
836
+ const ch = new TestChannel();
837
+ const signal = ch.disposeSignal;
838
+ expect(signal).toBeInstanceOf(AbortSignal);
839
+ expect(signal).toBe(ch.disposeSignal); // same instance
840
+ });
841
+
842
+ it('is composed into open signal', async () => {
843
+ const ch = new TestChannel();
844
+ ch.openBehavior = 'pending';
845
+ ch.init();
846
+ ch.connect();
847
+
848
+ const openSignal = ch.openCalls[0];
849
+ expect(openSignal.aborted).toBe(false);
850
+
851
+ ch.dispose();
852
+ expect(openSignal.aborted).toBe(true);
853
+ });
854
+ });
855
+
856
+ // ── Status change dedup ─────────────────────────────────────────
857
+
858
+ describe('status deduplication', () => {
859
+ it('does not notify when status is unchanged', () => {
860
+ const ch = new TestChannel();
861
+ ch.init();
862
+ const listener = vi.fn();
863
+ ch.subscribe(listener);
864
+
865
+ // disconnect on idle should not produce a status change
866
+ ch.disconnect();
867
+ expect(listener).not.toHaveBeenCalled();
868
+ });
869
+ });
870
+
871
+ describe('listenTo', () => {
872
+ interface AppEvents {
873
+ logout: { reason: string };
874
+ }
875
+
876
+ it('handler is called when event is emitted', () => {
877
+ const bus = new EventBus<AppEvents>();
878
+ const reasons: string[] = [];
879
+
880
+ class ListenChannel extends Channel<TestMessages> {
881
+ protected open(): void {}
882
+ protected close(): void {}
883
+ protected onInit() {
884
+ this.listenTo(bus, 'logout', (e) => reasons.push(e.reason));
885
+ }
886
+ }
887
+
888
+ const ch = new ListenChannel();
889
+ ch.init();
890
+
891
+ bus.emit('logout', { reason: 'expired' });
892
+ expect(reasons).toEqual(['expired']);
893
+ });
894
+
895
+ it('auto-unsubscribes on dispose', () => {
896
+ const bus = new EventBus<AppEvents>();
897
+ const handler = vi.fn();
898
+
899
+ class ListenChannel extends Channel<TestMessages> {
900
+ protected open(): void {}
901
+ protected close(): void {}
902
+ protected onInit() {
903
+ this.listenTo(bus, 'logout', handler);
904
+ }
905
+ }
906
+
907
+ const ch = new ListenChannel();
908
+ ch.init();
909
+
910
+ bus.emit('logout', { reason: 'expired' });
911
+ expect(handler).toHaveBeenCalledTimes(1);
912
+
913
+ ch.dispose();
914
+
915
+ bus.emit('logout', { reason: 'manual' });
916
+ expect(handler).toHaveBeenCalledTimes(1);
917
+ });
918
+
919
+ it('returns unsubscribe function for manual cleanup', () => {
920
+ const bus = new EventBus<AppEvents>();
921
+ const handler = vi.fn();
922
+ let unsub!: () => void;
923
+
924
+ class ListenChannel extends Channel<TestMessages> {
925
+ protected open(): void {}
926
+ protected close(): void {}
927
+ protected onInit() {
928
+ unsub = this.listenTo(bus, 'logout', handler);
929
+ }
930
+ }
931
+
932
+ const ch = new ListenChannel();
933
+ ch.init();
934
+
935
+ bus.emit('logout', { reason: 'expired' });
936
+ expect(handler).toHaveBeenCalledTimes(1);
937
+
938
+ unsub();
939
+
940
+ bus.emit('logout', { reason: 'manual' });
941
+ expect(handler).toHaveBeenCalledTimes(1);
942
+ });
943
+ });
944
+
945
+ describe('method binding', () => {
946
+ it('destructured methods work point-free', () => {
947
+ const ch = new TestChannel();
948
+ const { on } = ch;
949
+ const handler = vi.fn();
950
+ on('chat', handler);
951
+ ch.init();
952
+ ch.resolveOpen();
953
+ ch.simulateReceive('chat', { text: 'hello' });
954
+ expect(handler).toHaveBeenCalledWith({ text: 'hello' });
955
+ });
956
+ });
957
+ });