import { renderHook } from '@testing-library/react'; import useWheelNavigation from '../useWheelNavigation'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build a minimal containerRef backed by a div with mocked event methods. */ const makeContainerRef = () => { const el = document.createElement('div'); el.addEventListener = jest.fn(); el.removeEventListener = jest.fn(); return { current: el }; }; /** Extract the wheel handler that was registered via the mocked addEventListener. */ const getWheelHandler = ( containerRef: ReturnType, ): ((e: Partial) => void) => { const calls = (containerRef.current.addEventListener as jest.Mock).mock.calls; const call = calls.find((c) => c[0] === 'wheel'); if (!call) throw new Error('No wheel listener was registered'); return call[1] as (e: Partial) => void; }; /** Create a fake WheelEvent-like object. */ const makeWheelEvent = (deltaY: number): Partial => ({ deltaY, preventDefault: jest.fn(), }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('useWheelNavigation', () => { let onScrollDown: jest.Mock; let onScrollUp: jest.Mock; beforeEach(() => { onScrollDown = jest.fn(); onScrollUp = jest.fn(); jest.restoreAllMocks(); }); // ── Registration ────────────────────────────────────────────────────────── it('registers a wheel listener on the container when enabled', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp }), ); expect(containerRef.current.addEventListener).toHaveBeenCalledWith( 'wheel', expect.any(Function), { passive: false }, ); }); it('does NOT register a wheel listener when disabled', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, disabled: true }), ); const calls = (containerRef.current.addEventListener as jest.Mock).mock.calls; expect(calls.find((c) => c[0] === 'wheel')).toBeUndefined(); }); it('removes the wheel listener on cleanup', () => { const containerRef = makeContainerRef(); const { unmount } = renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp }), ); unmount(); expect(containerRef.current.removeEventListener).toHaveBeenCalledWith( 'wheel', expect.any(Function), ); }); // ── Scroll-down navigation ───────────────────────────────────────────────── it('calls onScrollDown when accumulated deltaY reaches the threshold', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30 }), ); const handler = getWheelHandler(containerRef); // Single event just above threshold handler(makeWheelEvent(35)); expect(onScrollDown).toHaveBeenCalledTimes(1); expect(onScrollUp).not.toHaveBeenCalled(); }); it('calls onScrollDown only after accumulation exceeds the threshold across multiple events', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs: 0 }), ); const handler = getWheelHandler(containerRef); handler(makeWheelEvent(10)); // 10 – below threshold handler(makeWheelEvent(10)); // 20 – still below expect(onScrollDown).not.toHaveBeenCalled(); handler(makeWheelEvent(15)); // 35 – over threshold, fires expect(onScrollDown).toHaveBeenCalledTimes(1); }); it('calls preventDefault on the wheel event', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp }), ); const handler = getWheelHandler(containerRef); const event = makeWheelEvent(50); handler(event); expect(event.preventDefault).toHaveBeenCalled(); }); // ── Scroll-up navigation ─────────────────────────────────────────────────── it('calls onScrollUp when accumulated deltaY is negative and meets the threshold', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30 }), ); const handler = getWheelHandler(containerRef); handler(makeWheelEvent(-40)); expect(onScrollUp).toHaveBeenCalledTimes(1); expect(onScrollDown).not.toHaveBeenCalled(); }); // ── Threshold boundary ───────────────────────────────────────────────────── it('does NOT fire if the accumulated deltaY stays below the threshold', () => { const containerRef = makeContainerRef(); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30 }), ); const handler = getWheelHandler(containerRef); handler(makeWheelEvent(20)); handler(makeWheelEvent(5)); expect(onScrollDown).not.toHaveBeenCalled(); expect(onScrollUp).not.toHaveBeenCalled(); }); // ── Accumulator reset after firing ──────────────────────────────────────── it('resets the accumulator after firing so subsequent scrolls still need to reach the threshold', () => { const containerRef = makeContainerRef(); const nowSpy = jest.spyOn(Date, 'now'); renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs: 0 }), ); const handler = getWheelHandler(containerRef); // First fire nowSpy.mockReturnValue(0); handler(makeWheelEvent(40)); expect(onScrollDown).toHaveBeenCalledTimes(1); // Immediately after (cooldown = 0): small scroll should not fire again nowSpy.mockReturnValue(1); handler(makeWheelEvent(10)); expect(onScrollDown).toHaveBeenCalledTimes(1); // still 1 // One more event that pushes over threshold nowSpy.mockReturnValue(2); handler(makeWheelEvent(25)); expect(onScrollDown).toHaveBeenCalledTimes(2); }); // ── Cooldown ─────────────────────────────────────────────────────────────── it('ignores events during the cooldown period and resets the accumulator', () => { const containerRef = makeContainerRef(); const nowSpy = jest.spyOn(Date, 'now'); const cooldownMs = 600; renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs }), ); const handler = getWheelHandler(containerRef); // Fire once at t=1000 (> cooldownMs so lastFired=0 doesn't block the first event) nowSpy.mockReturnValue(1000); handler(makeWheelEvent(40)); expect(onScrollDown).toHaveBeenCalledTimes(1); // t=1100ms — still in cooldown (only 100ms have passed), must not fire nowSpy.mockReturnValue(1100); handler(makeWheelEvent(500)); expect(onScrollDown).toHaveBeenCalledTimes(1); // t=1700ms — cooldown expired; accumulator was reset, need fresh threshold nowSpy.mockReturnValue(1700); handler(makeWheelEvent(40)); // fresh accumulation, above threshold → fires again expect(onScrollDown).toHaveBeenCalledTimes(2); }); it('does not fire during cooldown even when accumulated delta would exceed threshold', () => { const containerRef = makeContainerRef(); const nowSpy = jest.spyOn(Date, 'now'); const cooldownMs = 600; renderHook(() => useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs }), ); const handler = getWheelHandler(containerRef); // Start above cooldownMs so lastFired=0 doesn't block the first event nowSpy.mockReturnValue(1000); handler(makeWheelEvent(40)); expect(onScrollDown).toHaveBeenCalledTimes(1); // Multiple large events within cooldown window nowSpy.mockReturnValue(1200); handler(makeWheelEvent(200)); nowSpy.mockReturnValue(1400); handler(makeWheelEvent(200)); // Still only the original single call expect(onScrollDown).toHaveBeenCalledTimes(1); }); // ── Stable callback refs ─────────────────────────────────────────────────── it('uses the latest onScrollDown/onScrollUp callbacks without re-registering the listener', () => { const containerRef = makeContainerRef(); const onScrollDown2 = jest.fn(); const onScrollUp2 = jest.fn(); let flip = false; const { rerender } = renderHook(() => useWheelNavigation({ containerRef, onScrollDown: flip ? onScrollDown2 : onScrollDown, onScrollUp: flip ? onScrollUp2 : onScrollUp, threshold: 30, cooldownMs: 0, }), ); const registrationCountBefore = (containerRef.current.addEventListener as jest.Mock).mock.calls.length; // Swap callbacks flip = true; rerender(); const registrationCountAfter = (containerRef.current.addEventListener as jest.Mock).mock.calls.length; // The effect that attaches the listener should NOT have re-run (stable deps) expect(registrationCountAfter).toBe(registrationCountBefore); // Firing the handler should now invoke the latest callbacks const handler = getWheelHandler(containerRef); jest.spyOn(Date, 'now').mockReturnValue(9999); // ensure no cooldown handler(makeWheelEvent(40)); expect(onScrollDown2).toHaveBeenCalledTimes(1); expect(onScrollDown).not.toHaveBeenCalled(); }); });