@blac/core 2.0.0-rc-10 → 2.0.0-rc-13
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/README.md +27 -0
- package/dist/index.cjs.js +2 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +294 -11
- package/dist/index.es.js +869 -397
- package/dist/index.es.js.map +1 -1
- package/package.json +1 -1
- package/src/Blac.ts +94 -9
- package/src/BlacObserver.ts +3 -0
- package/src/Bloc.ts +43 -49
- package/src/BlocBase.ts +107 -4
- package/src/Cubit.ts +14 -0
- package/src/__tests__/Blac.config.test.ts +86 -0
- package/src/__tests__/plugins.test.ts +454 -0
- package/src/__tests__/props.test.ts +256 -0
- package/src/adapter/BlacAdapter.ts +73 -8
- package/src/events.ts +3 -0
- package/src/index.ts +5 -0
- package/src/plugins/BlocPluginRegistry.ts +220 -0
- package/src/plugins/SystemPluginRegistry.ts +192 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/types.ts +100 -0
- package/src/types.ts +1 -10
- package/src/utils/shallowEqual.ts +40 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Blac } from '../Blac';
|
|
3
|
+
import { Cubit } from '../Cubit';
|
|
4
|
+
import { Bloc } from '../Bloc';
|
|
5
|
+
import { BlacPlugin, BlocPlugin, PluginCapabilities, ErrorContext } from '../plugins';
|
|
6
|
+
import { BlocBase } from '../BlocBase';
|
|
7
|
+
|
|
8
|
+
// Test Cubit
|
|
9
|
+
class CounterCubit extends Cubit<number> {
|
|
10
|
+
constructor() {
|
|
11
|
+
super(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
increment = () => this.emit(this.state + 1);
|
|
15
|
+
decrement = () => this.emit(this.state - 1);
|
|
16
|
+
setValue = (value: number) => this.emit(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Test Bloc
|
|
20
|
+
abstract class CounterEvent {}
|
|
21
|
+
class Increment extends CounterEvent {}
|
|
22
|
+
class Decrement extends CounterEvent {}
|
|
23
|
+
class SetValue extends CounterEvent {
|
|
24
|
+
constructor(public value: number) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class CounterBloc extends Bloc<number, CounterEvent> {
|
|
30
|
+
constructor() {
|
|
31
|
+
super(0);
|
|
32
|
+
|
|
33
|
+
this.on(Increment, (event, emit) => emit(this.state + 1));
|
|
34
|
+
this.on(Decrement, (event, emit) => emit(this.state - 1));
|
|
35
|
+
this.on(SetValue, (event, emit) => emit(event.value));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Test implementations of plugins
|
|
40
|
+
class TestLoggingPlugin implements BlacPlugin {
|
|
41
|
+
readonly name = 'logging';
|
|
42
|
+
readonly version = '1.0.0';
|
|
43
|
+
private logLevel: 'debug' | 'info' | 'warn' | 'error';
|
|
44
|
+
|
|
45
|
+
constructor(options: { logLevel?: 'debug' | 'info' | 'warn' | 'error' } = {}) {
|
|
46
|
+
this.logLevel = options.logLevel || 'info';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onBlocCreated(bloc: BlocBase<any>): void {
|
|
50
|
+
this.log('debug', `Bloc created: ${bloc._name}:${bloc._id}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onStateChanged(bloc: BlocBase<any>, previousState: any, currentState: any): void {
|
|
54
|
+
this.log('debug', `State changed in ${bloc._name}:${bloc._id}`, {
|
|
55
|
+
previousState,
|
|
56
|
+
currentState
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any): void {
|
|
61
|
+
const levels = ['debug', 'info', 'warn', 'error'];
|
|
62
|
+
const currentLevelIndex = levels.indexOf(this.logLevel);
|
|
63
|
+
const messageLevelIndex = levels.indexOf(level);
|
|
64
|
+
|
|
65
|
+
if (messageLevelIndex >= currentLevelIndex) {
|
|
66
|
+
const timestamp = new Date().toISOString();
|
|
67
|
+
const logMessage = `[BlaC] [${timestamp}] ${message}`;
|
|
68
|
+
|
|
69
|
+
switch (level) {
|
|
70
|
+
case 'debug':
|
|
71
|
+
console.debug(logMessage, data);
|
|
72
|
+
break;
|
|
73
|
+
case 'info':
|
|
74
|
+
console.log(logMessage, data);
|
|
75
|
+
break;
|
|
76
|
+
case 'warn':
|
|
77
|
+
console.warn(logMessage, data);
|
|
78
|
+
break;
|
|
79
|
+
case 'error':
|
|
80
|
+
console.error(logMessage, data);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface StorageAdapter {
|
|
88
|
+
getItem(key: string): string | null;
|
|
89
|
+
setItem(key: string, value: string): void;
|
|
90
|
+
removeItem(key: string): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class TestPersistencePlugin<TState> implements BlocPlugin<TState> {
|
|
94
|
+
readonly name = 'persistence';
|
|
95
|
+
readonly version = '1.0.0';
|
|
96
|
+
readonly capabilities: PluginCapabilities = {
|
|
97
|
+
readState: true,
|
|
98
|
+
transformState: true,
|
|
99
|
+
interceptEvents: false,
|
|
100
|
+
persistData: true,
|
|
101
|
+
accessMetadata: false
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
private storage: StorageAdapter;
|
|
105
|
+
private key: string;
|
|
106
|
+
private saveDebounceMs: number;
|
|
107
|
+
private saveTimer?: any;
|
|
108
|
+
|
|
109
|
+
constructor(options: {
|
|
110
|
+
key: string;
|
|
111
|
+
storage?: StorageAdapter;
|
|
112
|
+
saveDebounceMs?: number;
|
|
113
|
+
}) {
|
|
114
|
+
this.key = options.key;
|
|
115
|
+
this.storage = options.storage || new MockStorage();
|
|
116
|
+
this.saveDebounceMs = options.saveDebounceMs ?? 100;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onAttach(bloc: BlocBase<TState, any>): void {
|
|
120
|
+
const savedData = this.storage.getItem(this.key);
|
|
121
|
+
if (savedData) {
|
|
122
|
+
const restoredState = JSON.parse(savedData);
|
|
123
|
+
(bloc as any)._state = restoredState;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onStateChange(previousState: TState, currentState: TState): void {
|
|
128
|
+
if (this.saveTimer) {
|
|
129
|
+
clearTimeout(this.saveTimer);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.saveDebounceMs > 0) {
|
|
133
|
+
this.saveTimer = setTimeout(() => {
|
|
134
|
+
this.storage.setItem(this.key, JSON.stringify(currentState));
|
|
135
|
+
}, this.saveDebounceMs);
|
|
136
|
+
} else {
|
|
137
|
+
this.storage.setItem(this.key, JSON.stringify(currentState));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class TestValidationPlugin<T> implements BlocPlugin<T> {
|
|
143
|
+
readonly name = 'validation';
|
|
144
|
+
readonly version = '1.0.0';
|
|
145
|
+
readonly capabilities: PluginCapabilities = {
|
|
146
|
+
readState: true,
|
|
147
|
+
transformState: true,
|
|
148
|
+
interceptEvents: false,
|
|
149
|
+
persistData: false,
|
|
150
|
+
accessMetadata: false
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
constructor(private validator: (state: T) => boolean | string) {}
|
|
154
|
+
|
|
155
|
+
transformState(previousState: T, nextState: T): T {
|
|
156
|
+
const result = this.validator(nextState);
|
|
157
|
+
|
|
158
|
+
if (result === true) {
|
|
159
|
+
return nextState;
|
|
160
|
+
} else if (result === false) {
|
|
161
|
+
console.warn('State change rejected by validation plugin');
|
|
162
|
+
return previousState;
|
|
163
|
+
} else {
|
|
164
|
+
console.error(`State validation failed: ${result}`);
|
|
165
|
+
return previousState;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Mock storage
|
|
171
|
+
class MockStorage implements StorageAdapter {
|
|
172
|
+
private store = new Map<string, string>();
|
|
173
|
+
|
|
174
|
+
getItem(key: string): string | null {
|
|
175
|
+
return this.store.get(key) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setItem(key: string, value: string): void {
|
|
179
|
+
this.store.set(key, value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
removeItem(key: string): void {
|
|
183
|
+
this.store.delete(key);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
describe('New Plugin System', () => {
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
Blac.resetInstance();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('System Plugins (BlacPlugin)', () => {
|
|
193
|
+
it('should register and execute system plugins', () => {
|
|
194
|
+
const onBlocCreated = vi.fn();
|
|
195
|
+
const onStateChanged = vi.fn();
|
|
196
|
+
|
|
197
|
+
const plugin: BlacPlugin = {
|
|
198
|
+
name: 'test-system-plugin',
|
|
199
|
+
version: '1.0.0',
|
|
200
|
+
onBlocCreated,
|
|
201
|
+
onStateChanged
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
Blac.instance.plugins.add(plugin);
|
|
205
|
+
|
|
206
|
+
const cubit = Blac.getBloc(CounterCubit);
|
|
207
|
+
expect(onBlocCreated).toHaveBeenCalledWith(cubit);
|
|
208
|
+
|
|
209
|
+
cubit.increment();
|
|
210
|
+
expect(onStateChanged).toHaveBeenCalledWith(cubit, 0, 1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle plugin errors gracefully', () => {
|
|
214
|
+
const errorPlugin: BlacPlugin = {
|
|
215
|
+
name: 'error-plugin',
|
|
216
|
+
version: '1.0.0',
|
|
217
|
+
onBlocCreated: () => {
|
|
218
|
+
throw new Error('Plugin error');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
Blac.instance.plugins.add(errorPlugin);
|
|
223
|
+
|
|
224
|
+
// Should not throw when creating bloc
|
|
225
|
+
expect(() => Blac.getBloc(CounterCubit)).not.toThrow();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should execute bootstrap hooks', () => {
|
|
229
|
+
const beforeBootstrap = vi.fn();
|
|
230
|
+
const afterBootstrap = vi.fn();
|
|
231
|
+
|
|
232
|
+
const plugin: BlacPlugin = {
|
|
233
|
+
name: 'bootstrap-plugin',
|
|
234
|
+
version: '1.0.0',
|
|
235
|
+
beforeBootstrap,
|
|
236
|
+
afterBootstrap
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
Blac.resetInstance();
|
|
240
|
+
Blac.instance.plugins.add(plugin);
|
|
241
|
+
Blac.instance.bootstrap();
|
|
242
|
+
|
|
243
|
+
expect(beforeBootstrap).toHaveBeenCalled();
|
|
244
|
+
expect(afterBootstrap).toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should track metrics', () => {
|
|
248
|
+
const plugin: BlacPlugin = {
|
|
249
|
+
name: 'metrics-plugin',
|
|
250
|
+
version: '1.0.0',
|
|
251
|
+
onBlocCreated: () => {
|
|
252
|
+
// Do something
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
Blac.instance.plugins.add(plugin);
|
|
257
|
+
Blac.getBloc(CounterCubit);
|
|
258
|
+
|
|
259
|
+
const metrics = Blac.instance.plugins.getMetrics('metrics-plugin');
|
|
260
|
+
expect(metrics).toBeDefined();
|
|
261
|
+
expect(metrics?.get('onBlocCreated')).toBeDefined();
|
|
262
|
+
expect(metrics?.get('onBlocCreated')?.executionCount).toBe(1);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('Bloc Plugins (BlocPlugin)', () => {
|
|
267
|
+
it('should attach plugins to specific blocs', () => {
|
|
268
|
+
const onAttach = vi.fn();
|
|
269
|
+
const onStateChange = vi.fn();
|
|
270
|
+
|
|
271
|
+
const plugin: BlocPlugin<number> = {
|
|
272
|
+
name: 'test-bloc-plugin',
|
|
273
|
+
version: '1.0.0',
|
|
274
|
+
onAttach,
|
|
275
|
+
onStateChange
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const cubit = new CounterCubit();
|
|
279
|
+
cubit.addPlugin(plugin);
|
|
280
|
+
|
|
281
|
+
// Activate the bloc
|
|
282
|
+
Blac.activateBloc(cubit as any);
|
|
283
|
+
|
|
284
|
+
expect(onAttach).toHaveBeenCalledWith(cubit);
|
|
285
|
+
|
|
286
|
+
cubit.increment();
|
|
287
|
+
expect(onStateChange).toHaveBeenCalledWith(0, 1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should transform state through plugins', () => {
|
|
291
|
+
const transformPlugin: BlocPlugin<number> = {
|
|
292
|
+
name: 'double-plugin',
|
|
293
|
+
version: '1.0.0',
|
|
294
|
+
transformState: (prev, next) => next * 2
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const cubit = new CounterCubit();
|
|
298
|
+
cubit.addPlugin(transformPlugin);
|
|
299
|
+
Blac.activateBloc(cubit as any);
|
|
300
|
+
|
|
301
|
+
cubit.setValue(5);
|
|
302
|
+
expect(cubit.state).toBe(10); // 5 * 2
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should transform events through plugins', () => {
|
|
306
|
+
const transformPlugin: BlocPlugin<number, CounterEvent> = {
|
|
307
|
+
name: 'event-doubler',
|
|
308
|
+
version: '1.0.0',
|
|
309
|
+
transformEvent: (event) => {
|
|
310
|
+
if (event instanceof SetValue) {
|
|
311
|
+
return new SetValue(event.value * 2);
|
|
312
|
+
}
|
|
313
|
+
return event;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const bloc = new CounterBloc();
|
|
318
|
+
bloc.addPlugin(transformPlugin);
|
|
319
|
+
Blac.activateBloc(bloc as any);
|
|
320
|
+
|
|
321
|
+
bloc.add(new SetValue(5));
|
|
322
|
+
|
|
323
|
+
// Wait for event processing
|
|
324
|
+
setTimeout(() => {
|
|
325
|
+
expect(bloc.state).toBe(10); // 5 * 2
|
|
326
|
+
}, 10);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should respect plugin capabilities', () => {
|
|
330
|
+
const readOnlyPlugin: BlocPlugin<number> = {
|
|
331
|
+
name: 'read-only',
|
|
332
|
+
version: '1.0.0',
|
|
333
|
+
capabilities: {
|
|
334
|
+
readState: true,
|
|
335
|
+
transformState: false,
|
|
336
|
+
interceptEvents: false,
|
|
337
|
+
persistData: false,
|
|
338
|
+
accessMetadata: false
|
|
339
|
+
},
|
|
340
|
+
transformState: () => {
|
|
341
|
+
throw new Error('Should not be called');
|
|
342
|
+
},
|
|
343
|
+
onStateChange: vi.fn()
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const cubit = new CounterCubit();
|
|
347
|
+
cubit.addPlugin(readOnlyPlugin);
|
|
348
|
+
Blac.activateBloc(cubit as any);
|
|
349
|
+
|
|
350
|
+
cubit.increment();
|
|
351
|
+
expect(cubit.state).toBe(1); // Transform not applied
|
|
352
|
+
expect(readOnlyPlugin.onStateChange).toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('Example Plugins', () => {
|
|
357
|
+
it('should log with TestLoggingPlugin', () => {
|
|
358
|
+
const consoleSpy = vi.spyOn(console, 'debug');
|
|
359
|
+
|
|
360
|
+
const plugin = new TestLoggingPlugin({ logLevel: 'debug' });
|
|
361
|
+
Blac.instance.plugins.add(plugin);
|
|
362
|
+
|
|
363
|
+
const cubit = Blac.getBloc(CounterCubit);
|
|
364
|
+
cubit.increment();
|
|
365
|
+
|
|
366
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
367
|
+
expect.stringContaining('Bloc created'),
|
|
368
|
+
undefined
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
consoleSpy.mockRestore();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should persist state with TestPersistencePlugin', () => {
|
|
375
|
+
const storage = new MockStorage();
|
|
376
|
+
storage.setItem('counter', '42');
|
|
377
|
+
|
|
378
|
+
const plugin = new TestPersistencePlugin<number>({
|
|
379
|
+
key: 'counter',
|
|
380
|
+
storage,
|
|
381
|
+
saveDebounceMs: 0
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const cubit = new CounterCubit();
|
|
385
|
+
cubit.addPlugin(plugin);
|
|
386
|
+
Blac.activateBloc(cubit as any);
|
|
387
|
+
|
|
388
|
+
// Should restore from storage
|
|
389
|
+
expect(cubit.state).toBe(42);
|
|
390
|
+
|
|
391
|
+
// Should save new state
|
|
392
|
+
cubit.setValue(100);
|
|
393
|
+
|
|
394
|
+
// Wait for debounced save
|
|
395
|
+
setTimeout(() => {
|
|
396
|
+
expect(storage.getItem('counter')).toBe('100');
|
|
397
|
+
}, 10);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should validate state with TestValidationPlugin', () => {
|
|
401
|
+
const validator = (state: number) => {
|
|
402
|
+
if (state < 0) return 'Value must be non-negative';
|
|
403
|
+
if (state > 100) return 'Value must not exceed 100';
|
|
404
|
+
return true;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const plugin = new TestValidationPlugin(validator);
|
|
408
|
+
|
|
409
|
+
const cubit = new CounterCubit();
|
|
410
|
+
cubit.addPlugin(plugin);
|
|
411
|
+
Blac.activateBloc(cubit as any);
|
|
412
|
+
|
|
413
|
+
cubit.setValue(-5);
|
|
414
|
+
expect(cubit.state).toBe(0); // Rejected
|
|
415
|
+
|
|
416
|
+
cubit.setValue(50);
|
|
417
|
+
expect(cubit.state).toBe(50); // Accepted
|
|
418
|
+
|
|
419
|
+
cubit.setValue(150);
|
|
420
|
+
expect(cubit.state).toBe(50); // Rejected
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('Plugin Composition', () => {
|
|
425
|
+
it('should compose multiple bloc plugins', () => {
|
|
426
|
+
const storage = new MockStorage();
|
|
427
|
+
|
|
428
|
+
const persistencePlugin = new TestPersistencePlugin<number>({
|
|
429
|
+
key: 'validated-counter',
|
|
430
|
+
storage,
|
|
431
|
+
saveDebounceMs: 0
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const validationPlugin = new TestValidationPlugin<number>(
|
|
435
|
+
(state) => state >= 0 && state <= 10
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const cubit = new CounterCubit();
|
|
439
|
+
cubit.addPlugin(validationPlugin);
|
|
440
|
+
cubit.addPlugin(persistencePlugin);
|
|
441
|
+
Blac.activateBloc(cubit as any);
|
|
442
|
+
|
|
443
|
+
cubit.setValue(5);
|
|
444
|
+
expect(cubit.state).toBe(5);
|
|
445
|
+
|
|
446
|
+
cubit.setValue(15);
|
|
447
|
+
expect(cubit.state).toBe(5); // Validation rejected
|
|
448
|
+
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
expect(storage.getItem('validated-counter')).toBe('5');
|
|
451
|
+
}, 10);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Bloc } from '../Bloc';
|
|
3
|
+
import { Cubit } from '../Cubit';
|
|
4
|
+
import { PropsUpdated } from '../events';
|
|
5
|
+
import { BlacAdapter } from '../adapter/BlacAdapter';
|
|
6
|
+
import { Blac } from '../Blac';
|
|
7
|
+
|
|
8
|
+
// Test types
|
|
9
|
+
interface TestProps {
|
|
10
|
+
query: string;
|
|
11
|
+
filters?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TestState {
|
|
15
|
+
data: string;
|
|
16
|
+
loading: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Test Cubit with props
|
|
20
|
+
class TestCubit extends Cubit<TestState, TestProps> {
|
|
21
|
+
onPropsChangedMock = vi.fn();
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super({ data: '', loading: false });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected onPropsChanged(oldProps: TestProps | undefined, newProps: TestProps): void {
|
|
28
|
+
this.onPropsChangedMock(oldProps, newProps);
|
|
29
|
+
if (oldProps?.query !== newProps.query) {
|
|
30
|
+
this.emit({ ...this.state, data: `Query: ${newProps.query}` });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
loadData = () => {
|
|
35
|
+
const query = this.props?.query ?? 'default';
|
|
36
|
+
this.emit({ data: `Loaded: ${query}`, loading: false });
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Test Bloc with props
|
|
41
|
+
class TestBloc extends Bloc<TestState, PropsUpdated<TestProps>> {
|
|
42
|
+
constructor() {
|
|
43
|
+
super({ data: '', loading: false });
|
|
44
|
+
|
|
45
|
+
this.on(PropsUpdated<TestProps>, (event, emit) => {
|
|
46
|
+
emit({ ...this.state, data: `Props: ${event.props.query}` });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Props functionality', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
// Clear Blac instance between tests
|
|
54
|
+
Blac.resetInstance();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('PropsUpdated event', () => {
|
|
58
|
+
it('should create PropsUpdated event with correct props', () => {
|
|
59
|
+
const props = { query: 'test', filters: ['a', 'b'] };
|
|
60
|
+
const event = new PropsUpdated(props);
|
|
61
|
+
|
|
62
|
+
expect(event.props).toEqual(props);
|
|
63
|
+
expect(event.props).toBe(props); // Should be the same reference
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('Cubit props support', () => {
|
|
68
|
+
it('should support props getter', () => {
|
|
69
|
+
const cubit = new TestCubit();
|
|
70
|
+
expect(cubit.props).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should update props via _updateProps', () => {
|
|
74
|
+
const cubit = new TestCubit();
|
|
75
|
+
const props = { query: 'test' };
|
|
76
|
+
|
|
77
|
+
(cubit as any)._updateProps(props);
|
|
78
|
+
|
|
79
|
+
expect(cubit.props).toEqual(props);
|
|
80
|
+
expect(cubit.onPropsChangedMock).toHaveBeenCalledWith(null, props);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should emit state when props change', () => {
|
|
84
|
+
const cubit = new TestCubit();
|
|
85
|
+
const props1 = { query: 'test1' };
|
|
86
|
+
const props2 = { query: 'test2' };
|
|
87
|
+
|
|
88
|
+
(cubit as any)._updateProps(props1);
|
|
89
|
+
expect(cubit.state.data).toBe('Query: test1');
|
|
90
|
+
|
|
91
|
+
(cubit as any)._updateProps(props2);
|
|
92
|
+
expect(cubit.state.data).toBe('Query: test2');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should access props in methods', () => {
|
|
96
|
+
const cubit = new TestCubit();
|
|
97
|
+
|
|
98
|
+
cubit.loadData();
|
|
99
|
+
expect(cubit.state.data).toBe('Loaded: default');
|
|
100
|
+
|
|
101
|
+
(cubit as any)._updateProps({ query: 'custom' });
|
|
102
|
+
cubit.loadData();
|
|
103
|
+
expect(cubit.state.data).toBe('Loaded: custom');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Bloc props support', () => {
|
|
108
|
+
it('should handle PropsUpdated events', async () => {
|
|
109
|
+
const bloc = new TestBloc();
|
|
110
|
+
const props = { query: 'search' };
|
|
111
|
+
|
|
112
|
+
await bloc.add(new PropsUpdated(props));
|
|
113
|
+
|
|
114
|
+
expect(bloc.state.data).toBe('Props: search');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should queue PropsUpdated events like any other event', async () => {
|
|
118
|
+
const bloc = new TestBloc();
|
|
119
|
+
|
|
120
|
+
await bloc.add(new PropsUpdated({ query: 'first' }));
|
|
121
|
+
await bloc.add(new PropsUpdated({ query: 'second' }));
|
|
122
|
+
|
|
123
|
+
expect(bloc.state.data).toBe('Props: second');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('BlacAdapter props ownership', () => {
|
|
128
|
+
it('should allow first adapter to own props', () => {
|
|
129
|
+
const Constructor = TestCubit as any;
|
|
130
|
+
const adapter1 = new BlacAdapter(
|
|
131
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
132
|
+
{ props: { query: 'test1' } }
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(() => {
|
|
136
|
+
adapter1.updateProps({ query: 'test2' });
|
|
137
|
+
}).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should prevent non-owner adapters from updating props', () => {
|
|
141
|
+
const warnSpy = vi.spyOn(Blac, 'warn').mockImplementation(() => {});
|
|
142
|
+
const Constructor = TestCubit as any;
|
|
143
|
+
|
|
144
|
+
// First adapter becomes owner
|
|
145
|
+
const adapter1 = new BlacAdapter(
|
|
146
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
147
|
+
{ props: { query: 'test1' } }
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Second adapter tries to update props
|
|
151
|
+
const adapter2 = new BlacAdapter(
|
|
152
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
153
|
+
{}
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
adapter2.updateProps({ query: 'hijack' });
|
|
157
|
+
|
|
158
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
159
|
+
expect.stringContaining('non-owner adapter')
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
warnSpy.mockRestore();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should clear ownership on adapter unmount', () => {
|
|
166
|
+
const Constructor = TestCubit as any;
|
|
167
|
+
const adapter1 = new BlacAdapter(
|
|
168
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
169
|
+
{ props: { query: 'test1' } }
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
adapter1.mount();
|
|
173
|
+
adapter1.unmount();
|
|
174
|
+
|
|
175
|
+
// New adapter should be able to take ownership
|
|
176
|
+
const adapter2 = new BlacAdapter(
|
|
177
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
178
|
+
{ props: { query: 'test2' } }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(() => {
|
|
182
|
+
adapter2.updateProps({ query: 'test3' });
|
|
183
|
+
}).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should not update props if they are shallowly equal', () => {
|
|
187
|
+
const cubit = new TestCubit();
|
|
188
|
+
const Constructor = TestCubit as any;
|
|
189
|
+
const adapter = new BlacAdapter(
|
|
190
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
191
|
+
{}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const props = { query: 'test' };
|
|
195
|
+
adapter.updateProps(props);
|
|
196
|
+
|
|
197
|
+
// Reset mock
|
|
198
|
+
cubit.onPropsChangedMock.mockClear();
|
|
199
|
+
|
|
200
|
+
// Update with same props
|
|
201
|
+
adapter.updateProps({ query: 'test' });
|
|
202
|
+
|
|
203
|
+
// onPropsChanged should not have been called
|
|
204
|
+
expect(cubit.onPropsChangedMock).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should ignore props updates during disposal', () => {
|
|
208
|
+
const Constructor = TestCubit as any;
|
|
209
|
+
const adapter = new BlacAdapter(
|
|
210
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
211
|
+
{ props: { query: 'test1' } }
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Force disposal state
|
|
215
|
+
(adapter.blocInstance as any)._disposalState = 'disposing';
|
|
216
|
+
|
|
217
|
+
expect(() => {
|
|
218
|
+
adapter.updateProps({ query: 'test2' });
|
|
219
|
+
}).not.toThrow();
|
|
220
|
+
|
|
221
|
+
// Props should not have been updated
|
|
222
|
+
expect((adapter.blocInstance as TestCubit).props?.query).toBe('test1');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('Props integration', () => {
|
|
227
|
+
it('should dispatch PropsUpdated for Bloc instances', async () => {
|
|
228
|
+
const Constructor = TestBloc as any;
|
|
229
|
+
const adapter = new BlacAdapter(
|
|
230
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
231
|
+
{}
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const bloc = adapter.blocInstance as TestBloc;
|
|
235
|
+
adapter.updateProps({ query: 'adapter-test' });
|
|
236
|
+
|
|
237
|
+
// Wait for event processing
|
|
238
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
239
|
+
|
|
240
|
+
expect(bloc.state.data).toBe('Props: adapter-test');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should call _updateProps for Cubit instances', () => {
|
|
244
|
+
const Constructor = TestCubit as any;
|
|
245
|
+
const adapter = new BlacAdapter(
|
|
246
|
+
{ componentRef: { current: {} }, blocConstructor: Constructor },
|
|
247
|
+
{}
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
adapter.updateProps({ query: 'adapter-test' });
|
|
251
|
+
|
|
252
|
+
expect((adapter.blocInstance as TestCubit).props?.query).toBe('adapter-test');
|
|
253
|
+
expect((adapter.blocInstance as TestCubit).state.data).toBe('Query: adapter-test');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|