import { afterEach, describe, it, expect, vi } from 'vitest'; // We need to import the singleton, but also be able to test a fresh instance. // The module exports a singleton `eventDispatcher`. Since the class is not // exported, we test through the singleton and reset between tests. import { eventDispatcher, markSettled, onSettled, __resetSettledEventsForTests, } from '@/utils/event'; describe('EventDispatcher', () => { // ----------------------------------------------------------------------- // Async listeners (on / off / dispatch) // ----------------------------------------------------------------------- describe('async listeners', () => { it('registers and dispatches an async listener', async () => { const fn = vi.fn(); eventDispatcher.on('test-event', fn); await eventDispatcher.dispatch('test-event', { foo: 'bar' }); expect(fn).toHaveBeenCalledOnce(); expect(fn.mock.calls[0]![0]).toBeInstanceOf(CustomEvent); expect(fn.mock.calls[0]![0].detail).toEqual({ foo: 'bar' }); eventDispatcher.off('test-event', fn); }); it('dispatches to multiple listeners in order', async () => { const order: number[] = []; const fn1 = vi.fn(() => { order.push(1); }); const fn2 = vi.fn(() => { order.push(2); }); eventDispatcher.on('multi', fn1); eventDispatcher.on('multi', fn2); await eventDispatcher.dispatch('multi'); expect(order).toEqual([1, 2]); eventDispatcher.off('multi', fn1); eventDispatcher.off('multi', fn2); }); it('removes a listener with off()', async () => { const fn = vi.fn(); eventDispatcher.on('rm-test', fn); eventDispatcher.off('rm-test', fn); await eventDispatcher.dispatch('rm-test'); expect(fn).not.toHaveBeenCalled(); }); it('off() on non-existent event does not throw', () => { const fn = vi.fn(); expect(() => eventDispatcher.off('nonexistent', fn)).not.toThrow(); }); it('dispatch on event with no listeners does not throw', async () => { await expect(eventDispatcher.dispatch('no-listeners', { x: 1 })).resolves.toBeUndefined(); }); it('dispatch with no detail sends undefined as detail argument', async () => { const fn = vi.fn(); eventDispatcher.on('no-detail', fn); await eventDispatcher.dispatch('no-detail'); // CustomEvent({ detail: undefined }) results in detail being null per spec expect(fn.mock.calls[0]![0].detail).toBeNull(); eventDispatcher.off('no-detail', fn); }); it('does not call listeners added during an in-progress dispatch (snapshot semantics)', async () => { // Regression: a listener that (re)subscribes another listener mid-dispatch // must not have that new listener fire for the *current* event. The live // Set used to be iterated directly, so a handler added during an awaited // listener got invoked in the same dispatch — causing paragraph mode to // toggle twice per keypress (#4717). const added = vi.fn(); const adder = vi.fn(async () => { await Promise.resolve(); eventDispatcher.on('reentrant', added); }); eventDispatcher.on('reentrant', adder); await eventDispatcher.dispatch('reentrant'); expect(adder).toHaveBeenCalledOnce(); expect(added).not.toHaveBeenCalled(); // The newly-added listener takes effect on the next dispatch. await eventDispatcher.dispatch('reentrant'); expect(added).toHaveBeenCalledOnce(); eventDispatcher.off('reentrant', adder); eventDispatcher.off('reentrant', added); }); it('awaits async listeners sequentially', async () => { const order: string[] = []; const slowFn = vi.fn(async () => { await new Promise((r) => setTimeout(r, 10)); order.push('slow'); }); const fastFn = vi.fn(() => { order.push('fast'); }); eventDispatcher.on('seq', slowFn); eventDispatcher.on('seq', fastFn); await eventDispatcher.dispatch('seq'); // slow should complete before fast starts because dispatch awaits each expect(order).toEqual(['slow', 'fast']); eventDispatcher.off('seq', slowFn); eventDispatcher.off('seq', fastFn); }); }); // ----------------------------------------------------------------------- // Sync listeners (onSync / offSync / dispatchSync) // ----------------------------------------------------------------------- describe('sync listeners', () => { it('registers and dispatches a sync listener', () => { const fn = vi.fn((_event: CustomEvent) => false); eventDispatcher.onSync('sync-test', fn); eventDispatcher.dispatchSync('sync-test', 42); expect(fn).toHaveBeenCalledOnce(); expect(fn.mock.calls[0]![0].detail).toBe(42); eventDispatcher.offSync('sync-test', fn); }); it('returns true when a listener consumes the event', () => { const consumer = vi.fn(() => true); eventDispatcher.onSync('consume', consumer); const result = eventDispatcher.dispatchSync('consume'); expect(result).toBe(true); eventDispatcher.offSync('consume', consumer); }); it('returns false when no listener consumes the event', () => { const ignorer = vi.fn(() => false); eventDispatcher.onSync('ignore', ignorer); const result = eventDispatcher.dispatchSync('ignore'); expect(result).toBe(false); eventDispatcher.offSync('ignore', ignorer); }); it('stops at the first consumer (reverse order)', () => { const fn1 = vi.fn(() => true); const fn2 = vi.fn(() => false); // fn1 is registered first, fn2 second. Dispatch iterates in reverse. eventDispatcher.onSync('stop-early', fn1); eventDispatcher.onSync('stop-early', fn2); const result = eventDispatcher.dispatchSync('stop-early'); // fn2 (last registered) is called first in reverse, returns false // then fn1 is called, returns true (consumed) expect(fn2).toHaveBeenCalledOnce(); expect(fn1).toHaveBeenCalledOnce(); expect(result).toBe(true); eventDispatcher.offSync('stop-early', fn1); eventDispatcher.offSync('stop-early', fn2); }); it('stops at consumer and does not call earlier listeners', () => { const earlyFn = vi.fn(() => true); const lateFn = vi.fn(() => true); eventDispatcher.onSync('stop', earlyFn); eventDispatcher.onSync('stop', lateFn); eventDispatcher.dispatchSync('stop'); // lateFn (last registered) is called first in reverse, returns true -> stop expect(lateFn).toHaveBeenCalledOnce(); expect(earlyFn).not.toHaveBeenCalled(); eventDispatcher.offSync('stop', earlyFn); eventDispatcher.offSync('stop', lateFn); }); it('removes a sync listener with offSync()', () => { const fn = vi.fn(() => false); eventDispatcher.onSync('off-sync', fn); eventDispatcher.offSync('off-sync', fn); eventDispatcher.dispatchSync('off-sync'); expect(fn).not.toHaveBeenCalled(); }); it('offSync on non-existent event does not throw', () => { const fn = vi.fn(() => false); expect(() => eventDispatcher.offSync('nope', fn)).not.toThrow(); }); it('dispatchSync on event with no listeners returns false', () => { expect(eventDispatcher.dispatchSync('nobody-listens')).toBe(false); }); }); // ----------------------------------------------------------------------- // Isolation between event names // ----------------------------------------------------------------------- describe('event isolation', () => { it('does not cross-fire between different event names', async () => { const fn1 = vi.fn(); const fn2 = vi.fn(); eventDispatcher.on('event-a', fn1); eventDispatcher.on('event-b', fn2); await eventDispatcher.dispatch('event-a'); expect(fn1).toHaveBeenCalledOnce(); expect(fn2).not.toHaveBeenCalled(); eventDispatcher.off('event-a', fn1); eventDispatcher.off('event-b', fn2); }); it('sync and async listeners with same event name are independent', async () => { const asyncFn = vi.fn(); const syncFn = vi.fn(() => false); eventDispatcher.on('shared-name', asyncFn); eventDispatcher.onSync('shared-name', syncFn); await eventDispatcher.dispatch('shared-name'); expect(asyncFn).toHaveBeenCalledOnce(); expect(syncFn).not.toHaveBeenCalled(); eventDispatcher.dispatchSync('shared-name'); expect(syncFn).toHaveBeenCalledOnce(); expect(asyncFn).toHaveBeenCalledOnce(); // still only once eventDispatcher.off('shared-name', asyncFn); eventDispatcher.offSync('shared-name', syncFn); }); }); }); describe('settled events (markSettled / onSettled)', () => { afterEach(() => { __resetSettledEventsForTests(); }); it('fires listeners that subscribed BEFORE settling', async () => { const fn = vi.fn(); onSettled('boot', fn); expect(fn).not.toHaveBeenCalled(); await markSettled('boot', { ready: true }); expect(fn).toHaveBeenCalledOnce(); expect(fn).toHaveBeenCalledWith({ ready: true }); }); it('replays synchronously for listeners that subscribe AFTER settling', async () => { await markSettled('boot', { ready: true }); const fn = vi.fn(); onSettled('boot', fn); expect(fn).toHaveBeenCalledOnce(); expect(fn).toHaveBeenCalledWith({ ready: true }); }); it('fires multiple listeners; each fires exactly once', async () => { const a = vi.fn(); const b = vi.fn(); onSettled('boot', a); onSettled('boot', b); await markSettled('boot'); expect(a).toHaveBeenCalledOnce(); expect(b).toHaveBeenCalledOnce(); // Idempotent: a second markSettled is a no-op. await markSettled('boot'); expect(a).toHaveBeenCalledOnce(); expect(b).toHaveBeenCalledOnce(); }); it('auto-unsubscribes after fire — repeated dispatches do NOT re-fire', async () => { const fn = vi.fn(); onSettled('boot', fn); await markSettled('boot'); expect(fn).toHaveBeenCalledOnce(); // The bare eventDispatcher would normally fire again on re-dispatch; // settled-event listeners must not. await eventDispatcher.dispatch('boot', { stale: true }); expect(fn).toHaveBeenCalledOnce(); }); it('returned unsubscribe cancels a pre-settle subscription', async () => { const fn = vi.fn(); const unsub = onSettled('boot', fn); unsub(); await markSettled('boot'); expect(fn).not.toHaveBeenCalled(); }); it('returned unsubscribe is a no-op for late (already-replayed) subscribers', async () => { await markSettled('boot'); const fn = vi.fn(); const unsub = onSettled('boot', fn); expect(fn).toHaveBeenCalledOnce(); expect(() => unsub()).not.toThrow(); }); it('preserves typed payload through onSettled', async () => { interface BootPayload { version: number; } let captured: BootPayload | null = null; onSettled('typed-boot', (p) => { captured = p; }); await markSettled('typed-boot', { version: 7 }); expect(captured).toEqual({ version: 7 }); }); it('different event names are independent', async () => { const a = vi.fn(); const b = vi.fn(); onSettled('a', a); onSettled('b', b); await markSettled('a'); expect(a).toHaveBeenCalledOnce(); expect(b).not.toHaveBeenCalled(); }); });