@blac/core 2.0.0-rc-11 → 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.
@@ -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
+ });
package/src/index.ts CHANGED
@@ -15,3 +15,6 @@ export * from './testing';
15
15
 
16
16
  // Adapter
17
17
  export * from './adapter';
18
+
19
+ // Plugins
20
+ export * from './plugins';
@@ -0,0 +1,220 @@
1
+ import { BlocPlugin, PluginRegistry, ErrorContext } from '../core/types';
2
+
3
+ /**
4
+ * Registry for bloc-specific plugins
5
+ */
6
+ export class BlocPluginRegistry<TState, TEvent = never> implements PluginRegistry<BlocPlugin<TState, TEvent>> {
7
+ private plugins = new Map<string, BlocPlugin<TState, TEvent>>();
8
+ private executionOrder: string[] = [];
9
+ private attached = false;
10
+
11
+ /**
12
+ * Add a bloc plugin
13
+ */
14
+ add(plugin: BlocPlugin<TState, TEvent>): void {
15
+ if (this.plugins.has(plugin.name)) {
16
+ throw new Error(`Plugin '${plugin.name}' is already registered`);
17
+ }
18
+
19
+ // Validate capabilities
20
+ if (plugin.capabilities) {
21
+ this.validateCapabilities(plugin);
22
+ }
23
+
24
+ this.plugins.set(plugin.name, plugin);
25
+ this.executionOrder.push(plugin.name);
26
+ }
27
+
28
+ /**
29
+ * Remove a bloc plugin
30
+ */
31
+ remove(pluginName: string): boolean {
32
+ const plugin = this.plugins.get(pluginName);
33
+ if (!plugin) return false;
34
+
35
+ // Call onDetach if attached
36
+ if (this.attached && plugin.onDetach) {
37
+ try {
38
+ plugin.onDetach();
39
+ } catch (error) {
40
+ console.error(`Plugin '${pluginName}' error in onDetach:`, error);
41
+ }
42
+ }
43
+
44
+ this.plugins.delete(pluginName);
45
+ this.executionOrder = this.executionOrder.filter(name => name !== pluginName);
46
+
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Get a plugin by name
52
+ */
53
+ get(pluginName: string): BlocPlugin<TState, TEvent> | undefined {
54
+ return this.plugins.get(pluginName);
55
+ }
56
+
57
+ /**
58
+ * Get all plugins in execution order
59
+ */
60
+ getAll(): ReadonlyArray<BlocPlugin<TState, TEvent>> {
61
+ return this.executionOrder.map(name => this.plugins.get(name)!);
62
+ }
63
+
64
+ /**
65
+ * Clear all plugins
66
+ */
67
+ clear(): void {
68
+ // Detach all plugins first
69
+ if (this.attached) {
70
+ for (const plugin of this.getAll()) {
71
+ if (plugin.onDetach) {
72
+ try {
73
+ plugin.onDetach();
74
+ } catch (error) {
75
+ console.error(`Plugin '${plugin.name}' error in onDetach:`, error);
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ this.plugins.clear();
82
+ this.executionOrder = [];
83
+ this.attached = false;
84
+ }
85
+
86
+ /**
87
+ * Attach all plugins to a bloc
88
+ */
89
+ attach(bloc: any): void {
90
+ if (this.attached) {
91
+ throw new Error('Plugins are already attached');
92
+ }
93
+
94
+ for (const plugin of this.getAll()) {
95
+ if (plugin.onAttach) {
96
+ try {
97
+ plugin.onAttach(bloc);
98
+ } catch (error) {
99
+ console.error(`Plugin '${plugin.name}' error in onAttach:`, error);
100
+ // Remove failing plugin
101
+ this.remove(plugin.name);
102
+ }
103
+ }
104
+ }
105
+
106
+ this.attached = true;
107
+ }
108
+
109
+ /**
110
+ * Transform state through all plugins
111
+ */
112
+ transformState(previousState: TState, nextState: TState): TState {
113
+ let transformedState = nextState;
114
+
115
+ for (const plugin of this.getAll()) {
116
+ if (plugin.transformState && this.canTransformState(plugin)) {
117
+ try {
118
+ transformedState = plugin.transformState(previousState, transformedState);
119
+ } catch (error) {
120
+ console.error(`Plugin '${plugin.name}' error in transformState:`, error);
121
+ // Continue with untransformed state
122
+ }
123
+ }
124
+ }
125
+
126
+ return transformedState;
127
+ }
128
+
129
+ /**
130
+ * Transform event through all plugins
131
+ */
132
+ transformEvent(event: TEvent): TEvent | null {
133
+ let transformedEvent: TEvent | null = event;
134
+
135
+ for (const plugin of this.getAll()) {
136
+ if (transformedEvent === null) break;
137
+
138
+ if (plugin.transformEvent && this.canInterceptEvents(plugin)) {
139
+ try {
140
+ transformedEvent = plugin.transformEvent(transformedEvent);
141
+ } catch (error) {
142
+ console.error(`Plugin '${plugin.name}' error in transformEvent:`, error);
143
+ // Continue with untransformed event
144
+ }
145
+ }
146
+ }
147
+
148
+ return transformedEvent;
149
+ }
150
+
151
+ /**
152
+ * Notify plugins of state change
153
+ */
154
+ notifyStateChange(previousState: TState, currentState: TState): void {
155
+ for (const plugin of this.getAll()) {
156
+ if (plugin.onStateChange && this.canReadState(plugin)) {
157
+ try {
158
+ plugin.onStateChange(previousState, currentState);
159
+ } catch (error) {
160
+ console.error(`Plugin '${plugin.name}' error in onStateChange:`, error);
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Notify plugins of event
168
+ */
169
+ notifyEvent(event: TEvent): void {
170
+ for (const plugin of this.getAll()) {
171
+ if (plugin.onEvent && this.canReadState(plugin)) {
172
+ try {
173
+ plugin.onEvent(event);
174
+ } catch (error) {
175
+ console.error(`Plugin '${plugin.name}' error in onEvent:`, error);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Notify plugins of error
183
+ */
184
+ notifyError(error: Error, context: ErrorContext): void {
185
+ for (const plugin of this.getAll()) {
186
+ if (plugin.onError) {
187
+ try {
188
+ plugin.onError(error, context);
189
+ } catch (hookError) {
190
+ console.error(`Plugin '${plugin.name}' error in onError:`, hookError);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ private validateCapabilities(plugin: BlocPlugin<TState, TEvent>): void {
197
+ const caps = plugin.capabilities!;
198
+
199
+ // Validate logical constraints
200
+ if (caps.transformState && !caps.readState) {
201
+ throw new Error(`Plugin '${plugin.name}': transformState requires readState capability`);
202
+ }
203
+
204
+ if (caps.interceptEvents && !caps.readState) {
205
+ throw new Error(`Plugin '${plugin.name}': interceptEvents requires readState capability`);
206
+ }
207
+ }
208
+
209
+ private canReadState(plugin: BlocPlugin<TState, TEvent>): boolean {
210
+ return !plugin.capabilities || plugin.capabilities.readState !== false;
211
+ }
212
+
213
+ private canTransformState(plugin: BlocPlugin<TState, TEvent>): boolean {
214
+ return !plugin.capabilities || plugin.capabilities.transformState === true;
215
+ }
216
+
217
+ private canInterceptEvents(plugin: BlocPlugin<TState, TEvent>): boolean {
218
+ return !plugin.capabilities || plugin.capabilities.interceptEvents === true;
219
+ }
220
+ }