300 lines
8.5 KiB
TypeScript
300 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { eventBus, publish, subscribe } from './event-bus';
|
|
import { useAppEvent, usePublishEvent } from './hooks';
|
|
|
|
// ─── Test-Local Event Augmentation ──────────────────────────────
|
|
//
|
|
// In a real app, these would live in a `.d.ts` file inside the app's
|
|
// `src/types/` folder using `declare module '@repo/core-events'`.
|
|
//
|
|
// For the core package's own test suite, we augment the registry
|
|
// directly here so the tests have concrete event types to work with
|
|
// without polluting the core's shipped types.
|
|
//
|
|
declare module './events.registry' {
|
|
interface AppEventRegistry {
|
|
'TEST:STOCK_UPDATE': StockUpdatePayload;
|
|
'TEST:PROFILE_UPDATED': ProfileUpdatedPayload;
|
|
'TEST:INITIALIZED': undefined;
|
|
}
|
|
}
|
|
|
|
// ─── Test Payload Types ─────────────────────────────────────────
|
|
|
|
interface StockUpdatePayload {
|
|
id: string;
|
|
price: number;
|
|
change: number;
|
|
volume: number;
|
|
}
|
|
|
|
interface ProfileUpdatedPayload {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
avatar: string;
|
|
updatedAt: number;
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────
|
|
|
|
/** Clear all mitt handlers between tests to avoid cross-contamination. */
|
|
function clearAllHandlers() {
|
|
eventBus.all.clear();
|
|
}
|
|
|
|
// ─── Test Data ──────────────────────────────────────────────────
|
|
|
|
const mockStockUpdate: StockUpdatePayload = {
|
|
id: 'AAPL',
|
|
price: 185.42,
|
|
change: 1.23,
|
|
volume: 50000,
|
|
};
|
|
|
|
const mockProfile: ProfileUpdatedPayload = {
|
|
id: 'user-1',
|
|
name: 'Firman',
|
|
email: 'firman@eigen.co.id',
|
|
avatar: 'https://example.com/avatar.png',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
// ─── Core Event Bus Tests ───────────────────────────────────────
|
|
|
|
describe('Event Bus (Core)', () => {
|
|
beforeEach(() => {
|
|
clearAllHandlers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllHandlers();
|
|
});
|
|
|
|
it('publishes and subscribes to a typed event', () => {
|
|
const handler = vi.fn();
|
|
|
|
subscribe('TEST:STOCK_UPDATE', handler);
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
|
|
});
|
|
|
|
it('delivers events to multiple subscribers', () => {
|
|
const handler1 = vi.fn();
|
|
const handler2 = vi.fn();
|
|
|
|
subscribe('TEST:PROFILE_UPDATED', handler1);
|
|
subscribe('TEST:PROFILE_UPDATED', handler2);
|
|
publish('TEST:PROFILE_UPDATED', mockProfile);
|
|
|
|
expect(handler1).toHaveBeenCalledTimes(1);
|
|
expect(handler2).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not deliver events to unrelated subscribers', () => {
|
|
const stockHandler = vi.fn();
|
|
const profileHandler = vi.fn();
|
|
|
|
subscribe('TEST:STOCK_UPDATE', stockHandler);
|
|
subscribe('TEST:PROFILE_UPDATED', profileHandler);
|
|
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
|
|
expect(stockHandler).toHaveBeenCalledTimes(1);
|
|
expect(profileHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unsubscribes correctly via returned function', () => {
|
|
const handler = vi.fn();
|
|
|
|
const unsub = subscribe('TEST:STOCK_UPDATE', handler);
|
|
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
// Unsubscribe
|
|
unsub();
|
|
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
// Should still be 1, not 2
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('handles events with undefined payloads', () => {
|
|
const handler = vi.fn();
|
|
|
|
subscribe('TEST:INITIALIZED', handler);
|
|
publish('TEST:INITIALIZED', undefined);
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith(undefined);
|
|
});
|
|
|
|
it('handles rapid-fire events without loss', () => {
|
|
const handler = vi.fn();
|
|
subscribe('TEST:STOCK_UPDATE', handler);
|
|
|
|
for (let i = 0; i < 1000; i++) {
|
|
publish('TEST:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` });
|
|
}
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1000);
|
|
});
|
|
});
|
|
|
|
// ─── React Hook Tests ───────────────────────────────────────────
|
|
|
|
describe('useAppEvent (React Hook)', () => {
|
|
beforeEach(() => {
|
|
clearAllHandlers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllHandlers();
|
|
});
|
|
|
|
it('subscribes on mount and receives events', () => {
|
|
const handler = vi.fn();
|
|
|
|
renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
|
|
|
|
act(() => {
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith(mockStockUpdate);
|
|
});
|
|
|
|
it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => {
|
|
const handler = vi.fn();
|
|
|
|
const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
|
|
|
|
// Event should be received while mounted
|
|
act(() => {
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
});
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
// Unmount the component
|
|
unmount();
|
|
|
|
// Event should NOT be received after unmount
|
|
act(() => {
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
});
|
|
|
|
// Still 1, proving the handler was properly cleaned up
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not leak handlers across mount/unmount cycles', () => {
|
|
const handler = vi.fn();
|
|
|
|
// Mount and unmount 100 times
|
|
for (let i = 0; i < 100; i++) {
|
|
const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler));
|
|
unmount();
|
|
}
|
|
|
|
// After 100 cycles, emit one event
|
|
act(() => {
|
|
publish('TEST:STOCK_UPDATE', mockStockUpdate);
|
|
});
|
|
|
|
// Should be 0 — all handlers should have been cleaned up
|
|
expect(handler).toHaveBeenCalledTimes(0);
|
|
|
|
// Verify the handler map is empty for this event type
|
|
const handlers = eventBus.all.get('TEST:STOCK_UPDATE');
|
|
expect(!handlers || handlers.length === 0).toBe(true);
|
|
});
|
|
|
|
it('always calls the latest handler (no stale closures)', () => {
|
|
let capturedValue = '';
|
|
|
|
const { rerender } = renderHook(
|
|
({ value }: { value: string }) =>
|
|
useAppEvent('TEST:PROFILE_UPDATED', () => {
|
|
capturedValue = value;
|
|
}),
|
|
{ initialProps: { value: 'initial' } },
|
|
);
|
|
|
|
// Update the closure value
|
|
rerender({ value: 'updated' });
|
|
|
|
act(() => {
|
|
publish('TEST:PROFILE_UPDATED', mockProfile);
|
|
});
|
|
|
|
// Should capture the LATEST value, not the stale 'initial'
|
|
expect(capturedValue).toBe('updated');
|
|
});
|
|
|
|
it('does not re-subscribe when handler reference changes', () => {
|
|
// We spy on eventBus.on to count subscription calls
|
|
const onSpy = vi.spyOn(eventBus, 'on');
|
|
const offSpy = vi.spyOn(eventBus, 'off');
|
|
|
|
const { rerender } = renderHook(
|
|
({ handler }: { handler: () => void }) =>
|
|
useAppEvent('TEST:INITIALIZED', handler),
|
|
{ initialProps: { handler: vi.fn() } },
|
|
);
|
|
|
|
const initialOnCount = onSpy.mock.calls.length;
|
|
const initialOffCount = offSpy.mock.calls.length;
|
|
|
|
// Re-render with a NEW handler function reference
|
|
rerender({ handler: vi.fn() });
|
|
|
|
// on/off should NOT have been called again (ref-based pattern)
|
|
expect(onSpy.mock.calls.length).toBe(initialOnCount);
|
|
expect(offSpy.mock.calls.length).toBe(initialOffCount);
|
|
|
|
onSpy.mockRestore();
|
|
offSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('usePublishEvent (React Hook)', () => {
|
|
beforeEach(() => {
|
|
clearAllHandlers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllHandlers();
|
|
});
|
|
|
|
it('returns a working publish function', () => {
|
|
const handler = vi.fn();
|
|
subscribe('TEST:PROFILE_UPDATED', handler);
|
|
|
|
const { result } = renderHook(() => usePublishEvent());
|
|
|
|
act(() => {
|
|
result.current('TEST:PROFILE_UPDATED', mockProfile);
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith(mockProfile);
|
|
});
|
|
|
|
it('returns a referentially stable function across re-renders', () => {
|
|
const { result, rerender } = renderHook(() => usePublishEvent());
|
|
|
|
const firstRef = result.current;
|
|
|
|
rerender();
|
|
rerender();
|
|
rerender();
|
|
|
|
expect(result.current).toBe(firstRef);
|
|
});
|
|
});
|