// src/plugin/__tests__/debounced-event-ref.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { DebouncedEventRef } from '../utils/debounced-event-ref' import { Events } from '../events' import { Component } from '../component' describe('DebouncedEventRef', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) describe('debounced subscription', () => { it('debounces event delivery to subscriber', () => { const events = new Events() const callback = vi.fn() // Create ref to establish subscription (ref must stay in scope) new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'arg1') expect(callback).not.toHaveBeenCalled() vi.advanceTimersByTime(100) expect(callback).toHaveBeenCalledWith('arg1') }) it('coalesces rapid events', () => { const events = new Events() const callback = vi.fn() // Create ref to establish subscription (ref must stay in scope) new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'first') vi.advanceTimersByTime(50) events.trigger('test', 'second') vi.advanceTimersByTime(50) events.trigger('test', 'third') vi.advanceTimersByTime(100) expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith('third') }) }) describe('dispose', () => { it('cancels pending and unsubscribes from events', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'pending') ref.dispose() vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() // New triggers should also not reach callback events.trigger('test', 'after-dispose') vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() }) }) describe('flush and cancel', () => { it('flush immediately invokes callback with pending args', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'pending') ref.flush() expect(callback).toHaveBeenCalledWith('pending') }) it('cancel stops pending without invoking', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'pending') ref.cancel() vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() }) it('flush after dispose is a no-op', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'pending') ref.dispose() ref.flush() // Should not throw or invoke callback expect(callback).not.toHaveBeenCalled() }) it('cancel after dispose is a no-op', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'pending') ref.dispose() ref.cancel() // Should not throw expect(callback).not.toHaveBeenCalled() }) }) describe('Component integration', () => { it('cancels pending debounce when component unloads', () => { const events = new Events() const callback = vi.fn() const component = new Component() component.load() const ref = new DebouncedEventRef(events, 'test', callback, 100) component.registerEvent(ref) // Trigger event that starts debounce timer events.trigger('test', 'pending') expect(ref.isPending()).toBe(true) // Unload component - this calls offref which our proxy intercepts // Our proxy calls dispose() which cancels the debouncer component.unload() // Debounce should be cancelled expect(ref.isPending()).toBe(false) // Advancing time should not trigger callback vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() // New triggers also won't reach the callback events.trigger('test', 'after-unload') vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() }) it('delivers event if debounce completes before unload', () => { const events = new Events() const callback = vi.fn() const component = new Component() component.load() const ref = new DebouncedEventRef(events, 'test', callback, 100) component.registerEvent(ref) // Trigger and wait for debounce to complete events.trigger('test', 'data') vi.advanceTimersByTime(100) // Callback should have been called expect(callback).toHaveBeenCalledWith('data') expect(callback).toHaveBeenCalledTimes(1) // Now unload component.unload() // New triggers won't reach callback events.trigger('test', 'after') vi.advanceTimersByTime(200) expect(callback).toHaveBeenCalledTimes(1) }) }) describe('proxy', () => { it('calling offref on proxy disposes ref', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'pending') expect(ref.isPending()).toBe(true) // Call offref through the proxy (simulates Component.unload behavior) ref.e.offref(ref) expect(ref.isPending()).toBe(false) vi.advanceTimersByTime(200) expect(callback).not.toHaveBeenCalled() }) it('forwards other Events methods to underlying instance', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) // The proxy should forward trigger() to the real events instance const otherCallback = vi.fn() ref.e.on('other-event', otherCallback) ref.e.trigger('other-event', 'data') expect(otherCallback).toHaveBeenCalledWith('data') }) it('forwards offref for unrelated refs to underlying instance', () => { const events = new Events() const callback1 = vi.fn() const callback2 = vi.fn() const ref1 = new DebouncedEventRef(events, 'test', callback1, 100) const ref2 = events.on('other', callback2) // Calling offref on ref1's proxy for ref2 should forward to events ref1.e.offref(ref2) // ref2 should be unsubscribed events.trigger('other', 'data') expect(callback2).not.toHaveBeenCalled() // ref1 should still work events.trigger('test', 'data') vi.advanceTimersByTime(100) expect(callback1).toHaveBeenCalledWith('data') }) }) describe('isPending', () => { it('returns false when no event has been triggered', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) expect(ref.isPending()).toBe(false) }) it('returns true after event is triggered', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'data') expect(ref.isPending()).toBe(true) }) it('returns false after debounce delay expires', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'data') expect(ref.isPending()).toBe(true) vi.advanceTimersByTime(100) expect(ref.isPending()).toBe(false) expect(callback).toHaveBeenCalledWith('data') }) it('returns false after cancel', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'data') expect(ref.isPending()).toBe(true) ref.cancel() expect(ref.isPending()).toBe(false) }) it('returns false after flush', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'data') expect(ref.isPending()).toBe(true) ref.flush() expect(ref.isPending()).toBe(false) expect(callback).toHaveBeenCalledWith('data') }) it('returns false after dispose', () => { const events = new Events() const callback = vi.fn() const ref = new DebouncedEventRef(events, 'test', callback, 100) events.trigger('test', 'data') expect(ref.isPending()).toBe(true) ref.dispose() expect(ref.isPending()).toBe(false) }) }) })