@blac/core 2.0.0-rc-8 → 2.0.0-rc-10

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.
@@ -0,0 +1,441 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { BlocBase, BlocLifecycleState } from '../BlocBase';
3
+ import { Blac } from '../Blac';
4
+
5
+ // Test implementation of BlocBase
6
+ class TestBloc extends BlocBase<number> {
7
+ constructor(initialState: number = 0) {
8
+ super(initialState);
9
+ }
10
+
11
+ increment() {
12
+ this._pushState(this.state + 1, this.state);
13
+ }
14
+ }
15
+
16
+ // Test bloc with static properties
17
+ class KeepAliveBloc extends BlocBase<string> {
18
+ static keepAlive = true;
19
+
20
+ constructor() {
21
+ super('initial');
22
+ }
23
+ }
24
+
25
+ class IsolatedBloc extends BlocBase<string> {
26
+ static isolated = true;
27
+
28
+ constructor() {
29
+ super('isolated');
30
+ }
31
+ }
32
+
33
+ describe('BlocBase Lifecycle Management', () => {
34
+ let blac: Blac;
35
+
36
+ beforeEach(() => {
37
+ blac = new Blac({ __unsafe_ignore_singleton: true });
38
+ vi.useFakeTimers();
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.useRealTimers();
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ describe('Atomic State Transitions', () => {
47
+ it('should transition through lifecycle states atomically', () => {
48
+ const bloc = new TestBloc();
49
+
50
+ // Initial state should be ACTIVE
51
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.ACTIVE);
52
+
53
+ // Transition to DISPOSAL_REQUESTED
54
+ const result1 = (bloc as any)._atomicStateTransition(
55
+ BlocLifecycleState.ACTIVE,
56
+ BlocLifecycleState.DISPOSAL_REQUESTED,
57
+ );
58
+ expect(result1.success).toBe(true);
59
+ expect((bloc as any)._disposalState).toBe(
60
+ BlocLifecycleState.DISPOSAL_REQUESTED,
61
+ );
62
+
63
+ // Transition to DISPOSING
64
+ const result2 = (bloc as any)._atomicStateTransition(
65
+ BlocLifecycleState.DISPOSAL_REQUESTED,
66
+ BlocLifecycleState.DISPOSING,
67
+ );
68
+ expect(result2.success).toBe(true);
69
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.DISPOSING);
70
+
71
+ // Transition to DISPOSED
72
+ const result3 = (bloc as any)._atomicStateTransition(
73
+ BlocLifecycleState.DISPOSING,
74
+ BlocLifecycleState.DISPOSED,
75
+ );
76
+ expect(result3.success).toBe(true);
77
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.DISPOSED);
78
+ });
79
+
80
+ it('should reject invalid state transitions', () => {
81
+ const bloc = new TestBloc();
82
+
83
+ // Try to transition from ACTIVE to DISPOSED directly (should work in _dispose but not here)
84
+ const result = (bloc as any)._atomicStateTransition(
85
+ BlocLifecycleState.DISPOSAL_REQUESTED,
86
+ BlocLifecycleState.DISPOSED,
87
+ );
88
+ expect(result.success).toBe(false);
89
+ expect(result.currentState).toBe(BlocLifecycleState.ACTIVE); // Still in ACTIVE because expectedState didn't match
90
+ });
91
+
92
+ it('should handle concurrent transition attempts', () => {
93
+ const bloc = new TestBloc();
94
+
95
+ // First transition succeeds
96
+ const result1 = (bloc as any)._atomicStateTransition(
97
+ BlocLifecycleState.ACTIVE,
98
+ BlocLifecycleState.DISPOSAL_REQUESTED,
99
+ );
100
+ expect(result1.success).toBe(true);
101
+
102
+ // Second attempt with same expected state fails
103
+ const result2 = (bloc as any)._atomicStateTransition(
104
+ BlocLifecycleState.ACTIVE,
105
+ BlocLifecycleState.DISPOSAL_REQUESTED,
106
+ );
107
+ expect(result2.success).toBe(false);
108
+ expect(result2.currentState).toBe(BlocLifecycleState.DISPOSAL_REQUESTED);
109
+ });
110
+ });
111
+
112
+ describe('Consumer Management', () => {
113
+ it('should register and unregister consumers', () => {
114
+ const bloc = new TestBloc();
115
+ const consumerId = 'test-consumer-1';
116
+ const consumerRef = {};
117
+
118
+ // Add consumer
119
+ const added = bloc._addConsumer(consumerId, consumerRef);
120
+ expect(added).toBe(true);
121
+ expect(bloc._consumers.has(consumerId)).toBe(true);
122
+ expect(bloc._consumers.size).toBe(1);
123
+
124
+ // Remove consumer
125
+ bloc._removeConsumer(consumerId);
126
+ expect(bloc._consumers.has(consumerId)).toBe(false);
127
+ expect(bloc._consumers.size).toBe(0);
128
+ });
129
+
130
+ it('should prevent duplicate consumer registration', () => {
131
+ const bloc = new TestBloc();
132
+ const consumerId = 'test-consumer-1';
133
+
134
+ bloc._addConsumer(consumerId);
135
+ expect(bloc._consumers.size).toBe(1);
136
+
137
+ // Try to add same consumer again
138
+ bloc._addConsumer(consumerId);
139
+ expect(bloc._consumers.size).toBe(1); // Should still be 1
140
+ });
141
+
142
+ it('should schedule disposal when last consumer is removed', async () => {
143
+ const bloc = new TestBloc();
144
+ const consumerId = 'test-consumer-1';
145
+
146
+ // Register bloc with Blac instance
147
+ blac.registerBlocInstance(bloc as BlocBase<unknown>);
148
+
149
+ // Add and remove consumer
150
+ bloc._addConsumer(consumerId);
151
+ bloc._removeConsumer(consumerId);
152
+
153
+ // Should transition to DISPOSAL_REQUESTED immediately
154
+ expect((bloc as any)._disposalState).toBe(
155
+ BlocLifecycleState.DISPOSAL_REQUESTED,
156
+ );
157
+
158
+ // After microtask, should be disposed
159
+ await vi.runAllTimersAsync();
160
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.DISPOSED);
161
+ });
162
+
163
+ it('should cancel disposal if consumer is re-added during grace period', () => {
164
+ const bloc = new TestBloc();
165
+ const consumerId1 = 'test-consumer-1';
166
+ const consumerId2 = 'test-consumer-2';
167
+
168
+ // Register bloc
169
+ blac.registerBlocInstance(bloc as BlocBase<unknown>);
170
+
171
+ // Add and remove consumer to trigger disposal
172
+ bloc._addConsumer(consumerId1);
173
+ bloc._removeConsumer(consumerId1);
174
+
175
+ // State should transition to DISPOSAL_REQUESTED immediately
176
+ expect((bloc as any)._disposalState).toBe(
177
+ BlocLifecycleState.DISPOSAL_REQUESTED,
178
+ );
179
+
180
+ // Add new consumer before microtask runs - should fail
181
+ const added = bloc._addConsumer(consumerId2);
182
+ expect(added).toBe(false); // Should fail because bloc is in disposal process
183
+ });
184
+
185
+ it('should clean up dead WeakRef consumers', () => {
186
+ const bloc = new TestBloc();
187
+ const consumerId1 = 'consumer-1';
188
+ const consumerId2 = 'consumer-2';
189
+ let consumerRef1: any = { name: 'consumer1' };
190
+ const consumerRef2 = { name: 'consumer2' };
191
+
192
+ // Add consumers with refs first
193
+ bloc._addConsumer(consumerId1, consumerRef1);
194
+ bloc._addConsumer(consumerId2, consumerRef2);
195
+ expect(bloc._consumers.size).toBe(2);
196
+
197
+ // Now mock the WeakRef's deref method to simulate garbage collection
198
+ const consumerRefsMap = (bloc as any)._consumerRefs as Map<
199
+ string,
200
+ WeakRef<object>
201
+ >;
202
+ const weakRef1 = consumerRefsMap.get(consumerId1);
203
+ const weakRef2 = consumerRefsMap.get(consumerId2);
204
+
205
+ if (weakRef1) {
206
+ // Mock the deref method to return undefined (simulating GC)
207
+ vi.spyOn(weakRef1, 'deref').mockReturnValue(undefined);
208
+ }
209
+
210
+ // Validate consumers
211
+ bloc._validateConsumers();
212
+
213
+ // First consumer should be removed
214
+ expect(bloc._consumers.size).toBe(1);
215
+ expect(bloc._consumers.has(consumerId1)).toBe(false);
216
+ expect(bloc._consumers.has(consumerId2)).toBe(true);
217
+ });
218
+
219
+ it('should reject consumer additions when bloc is disposed', () => {
220
+ const bloc = new TestBloc();
221
+
222
+ // Force dispose
223
+ bloc._dispose();
224
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.DISPOSED);
225
+
226
+ // Try to add consumer
227
+ const added = bloc._addConsumer('new-consumer');
228
+ expect(added).toBe(false);
229
+ expect(bloc._consumers.size).toBe(0);
230
+ });
231
+ });
232
+
233
+ describe('Disposal Behavior', () => {
234
+ it('should properly dispose bloc and clean up resources', () => {
235
+ const bloc = new TestBloc();
236
+ const onDisposeSpy = vi.fn();
237
+ bloc.onDispose = onDisposeSpy;
238
+
239
+ // Add some consumers and observers
240
+ bloc._addConsumer('consumer-1');
241
+ const unsubscribe = bloc._observer.subscribe({
242
+ id: 'observer-1',
243
+ fn: vi.fn(),
244
+ });
245
+
246
+ // Dispose
247
+ const disposed = bloc._dispose();
248
+ expect(disposed).toBe(true);
249
+ expect(onDisposeSpy).toHaveBeenCalled();
250
+ expect(bloc._consumers.size).toBe(0);
251
+ expect(bloc._observer.size).toBe(0);
252
+ expect(bloc.isDisposed).toBe(true);
253
+ });
254
+
255
+ it('should handle disposal failures gracefully', () => {
256
+ const bloc = new TestBloc();
257
+ bloc.onDispose = () => {
258
+ throw new Error('Disposal error');
259
+ };
260
+
261
+ // Disposal should reset state on error
262
+ expect(() => bloc._dispose()).toThrow('Disposal error');
263
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.ACTIVE);
264
+ });
265
+
266
+ it('should schedule disposal with microtask deferral', () => {
267
+ const bloc = new TestBloc();
268
+ blac.registerBlocInstance(bloc as BlocBase<unknown>);
269
+
270
+ // Remove last consumer
271
+ bloc._addConsumer('consumer-1');
272
+ bloc._removeConsumer('consumer-1');
273
+
274
+ // Disposal should be scheduled immediately
275
+ expect((bloc as any)._disposalState).toBe(
276
+ BlocLifecycleState.DISPOSAL_REQUESTED,
277
+ );
278
+ });
279
+
280
+ it('should be idempotent - multiple dispose calls should be safe', () => {
281
+ const bloc = new TestBloc();
282
+
283
+ const result1 = bloc._dispose();
284
+ expect(result1).toBe(true);
285
+
286
+ const result2 = bloc._dispose();
287
+ expect(result2).toBe(false); // Already disposed
288
+ });
289
+ });
290
+
291
+ describe('keepAlive and isolated flags', () => {
292
+ it('should respect keepAlive flag and not dispose when consumers are removed', async () => {
293
+ const bloc = new KeepAliveBloc();
294
+ blac.registerBlocInstance(bloc as BlocBase<unknown>);
295
+
296
+ expect(bloc.isKeepAlive).toBe(true);
297
+
298
+ // Add and remove consumer
299
+ bloc._addConsumer('consumer-1');
300
+ bloc._removeConsumer('consumer-1');
301
+
302
+ // Should not schedule disposal
303
+ await vi.runAllTimersAsync();
304
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.ACTIVE);
305
+ });
306
+
307
+ it('should properly inherit isolated flag from static property', () => {
308
+ const bloc = new IsolatedBloc();
309
+ expect(bloc.isIsolated).toBe(true);
310
+ });
311
+
312
+ it('should handle missing static properties gracefully', () => {
313
+ class BlocWithoutStatics extends BlocBase<number> {
314
+ constructor() {
315
+ super(0);
316
+ }
317
+ }
318
+
319
+ const bloc = new BlocWithoutStatics();
320
+ expect(bloc.isKeepAlive).toBe(false);
321
+ expect(bloc.isIsolated).toBe(false);
322
+ });
323
+ });
324
+
325
+ describe('State Access During Lifecycle', () => {
326
+ it('should allow state access during all non-disposed states', () => {
327
+ const bloc = new TestBloc(42);
328
+
329
+ // ACTIVE state
330
+ expect(bloc.state).toBe(42);
331
+
332
+ // DISPOSAL_REQUESTED state
333
+ (bloc as any)._atomicStateTransition(
334
+ BlocLifecycleState.ACTIVE,
335
+ BlocLifecycleState.DISPOSAL_REQUESTED,
336
+ );
337
+ expect(bloc.state).toBe(42);
338
+
339
+ // DISPOSING state
340
+ (bloc as any)._atomicStateTransition(
341
+ BlocLifecycleState.DISPOSAL_REQUESTED,
342
+ BlocLifecycleState.DISPOSING,
343
+ );
344
+ expect(bloc.state).toBe(42);
345
+
346
+ // DISPOSED state - should return last known state
347
+ (bloc as any)._atomicStateTransition(
348
+ BlocLifecycleState.DISPOSING,
349
+ BlocLifecycleState.DISPOSED,
350
+ );
351
+ expect(bloc.state).toBe(42);
352
+ });
353
+
354
+ it('should correctly report isDisposed status', () => {
355
+ const bloc = new TestBloc();
356
+
357
+ expect(bloc.isDisposed).toBe(false);
358
+
359
+ bloc._dispose();
360
+ expect(bloc.isDisposed).toBe(true);
361
+ });
362
+ });
363
+
364
+ describe('Edge Cases', () => {
365
+ it('should handle rapid consumer additions and removals', async () => {
366
+ const bloc = new TestBloc();
367
+ blac.registerBlocInstance(bloc as BlocBase<unknown>);
368
+
369
+ // Rapidly add and remove consumers
370
+ for (let i = 0; i < 10; i++) {
371
+ bloc._addConsumer(`consumer-${i}`);
372
+ bloc._removeConsumer(`consumer-${i}`);
373
+ }
374
+
375
+ // Should be in DISPOSAL_REQUESTED state
376
+ expect((bloc as any)._disposalState).toBe(
377
+ BlocLifecycleState.DISPOSAL_REQUESTED,
378
+ );
379
+
380
+ // After microtask, should be disposed
381
+ await vi.runAllTimersAsync();
382
+ expect((bloc as any)._disposalState).toBe(BlocLifecycleState.DISPOSED);
383
+ });
384
+
385
+ it('should handle disposal during active state mutations', () => {
386
+ const bloc = new TestBloc();
387
+ const observer = vi.fn();
388
+
389
+ bloc._observer.subscribe({
390
+ id: 'observer-1',
391
+ fn: observer,
392
+ });
393
+
394
+ // Start a state mutation
395
+ bloc.increment();
396
+ expect(observer).toHaveBeenCalledWith(1, 0, undefined);
397
+
398
+ // Dispose during active usage
399
+ bloc._dispose();
400
+
401
+ // Further mutations should not notify (bloc is disposed)
402
+ observer.mockClear();
403
+ bloc.increment();
404
+ expect(observer).not.toHaveBeenCalled();
405
+ });
406
+
407
+ it('should prevent memory leaks via WeakRef cleanup', () => {
408
+ const bloc = new TestBloc();
409
+ const consumerCount = 100;
410
+ const refs: any[] = [];
411
+
412
+ // Add many consumers
413
+ for (let i = 0; i < consumerCount; i++) {
414
+ const ref = { id: i };
415
+ refs.push(ref);
416
+ bloc._addConsumer(`consumer-${i}`, ref);
417
+ }
418
+
419
+ expect(bloc._consumers.size).toBe(consumerCount);
420
+
421
+ // Mock half of the WeakRefs to return undefined (simulating GC)
422
+ const consumerRefsMap = (bloc as any)._consumerRefs as Map<
423
+ string,
424
+ WeakRef<object>
425
+ >;
426
+ let mockCount = 0;
427
+
428
+ for (const [consumerId, weakRef] of consumerRefsMap) {
429
+ if (mockCount < consumerCount / 2) {
430
+ vi.spyOn(weakRef, 'deref').mockReturnValue(undefined);
431
+ mockCount++;
432
+ }
433
+ }
434
+
435
+ // Validate should clean up dead refs
436
+ bloc._validateConsumers();
437
+
438
+ expect(bloc._consumers.size).toBe(consumerCount / 2);
439
+ });
440
+ });
441
+ });