mvc-kit 2.12.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,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
|
+
});
|