import { TextDecoder, TextEncoder } from "util"; import React from "react"; import { CarouselSwipeConfig, useCarouselSwipe } from "./use-carousel-swipe"; import { act, renderHook } from "@testing-library/react"; // Helper to create a mock TouchEvent function makeTouchEvent(clientX: number): React.TouchEvent { return { touches: [{ clientX }], } as unknown as React.TouchEvent; } describe("useCarouselSwipe", () => { const defaultConfig: CarouselSwipeConfig = { itemCount: 5, enableAutoScroll: false, }; beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); describe("Initial state", () => { it("returns currentIndex 0", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.currentIndex).toBe(0); }); it("returns swipeOffset 0", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.swipeOffset).toBe(0); }); it("returns isSwiping false", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.isSwiping).toBe(false); }); it("returns containerRef", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.containerRef).toBeDefined(); expect(result.current.containerRef.current).toBeNull(); }); it("returns default constants", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.constants).toEqual({ CARD_OFFSET_PERCENTAGE: 105, SWIPE_THRESHOLD: 0.15, MOBILE_BREAKPOINT: 768, AUTO_SCROLL_INTERVAL: 8000, }); }); it("uses custom constants from config", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 3, cardOffsetPercentage: 110, swipeThreshold: 0.2, mobileBreakpoint: 1024, autoScrollInterval: 5000, }) ); expect(result.current.constants).toEqual({ CARD_OFFSET_PERCENTAGE: 110, SWIPE_THRESHOLD: 0.2, MOBILE_BREAKPOINT: 1024, AUTO_SCROLL_INTERVAL: 5000, }); }); }); describe("nextSlide", () => { it("advances to next slide", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.nextSlide()); expect(result.current.currentIndex).toBe(1); }); it("wraps around to 0 from last slide", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); // Go to last slide (index 4) act(() => result.current.goToSlide(4)); act(() => result.current.nextSlide()); expect(result.current.currentIndex).toBe(0); }); it("does nothing when itemCount is 0", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 0, enableAutoScroll: false }) ); act(() => result.current.nextSlide()); expect(result.current.currentIndex).toBe(0); }); }); describe("prevSlide", () => { it("goes to previous slide", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.goToSlide(2)); act(() => result.current.prevSlide()); expect(result.current.currentIndex).toBe(1); }); it("wraps to last slide from first slide", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.prevSlide()); expect(result.current.currentIndex).toBe(4); }); it("does nothing when itemCount is 0", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 0, enableAutoScroll: false }) ); act(() => result.current.prevSlide()); expect(result.current.currentIndex).toBe(0); }); }); describe("goToSlide", () => { it("navigates to specific slide index", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.goToSlide(3)); expect(result.current.currentIndex).toBe(3); }); it("ignores negative index", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.goToSlide(-1)); expect(result.current.currentIndex).toBe(0); }); it("ignores index >= itemCount", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.goToSlide(5)); expect(result.current.currentIndex).toBe(0); }); it("navigates to first slide (index 0)", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.goToSlide(3)); act(() => result.current.goToSlide(0)); expect(result.current.currentIndex).toBe(0); }); it("navigates to last valid slide", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.goToSlide(4)); expect(result.current.currentIndex).toBe(4); }); }); describe("Touch gesture handling", () => { it("sets isSwiping to true on touch start", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.handleTouchStart(makeTouchEvent(200))); expect(result.current.isSwiping).toBe(true); }); it("tracks swipe offset during touch move", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.handleTouchStart(makeTouchEvent(200))); act(() => result.current.handleTouchMove(makeTouchEvent(150))); expect(result.current.swipeOffset).toBe(-50); }); it("does not track move when not swiping", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); // Don't call handleTouchStart, so isSwiping is false act(() => result.current.handleTouchMove(makeTouchEvent(150))); expect(result.current.swipeOffset).toBe(0); }); it("resets swipeOffset and isSwiping on touch end", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); act(() => result.current.handleTouchStart(makeTouchEvent(200))); act(() => result.current.handleTouchMove(makeTouchEvent(210))); act(() => result.current.handleTouchEnd()); expect(result.current.swipeOffset).toBe(0); expect(result.current.isSwiping).toBe(false); }); it("triggers prevSlide on right swipe exceeding threshold", () => { const { result } = renderHook(() => useCarouselSwipe({ ...defaultConfig, swipeThreshold: 0.01 }) ); // Start at slide 2 so prevSlide can go back act(() => result.current.goToSlide(2)); act(() => result.current.handleTouchStart(makeTouchEvent(100))); act(() => result.current.handleTouchMove(makeTouchEvent(300))); act(() => result.current.handleTouchEnd()); expect(result.current.currentIndex).toBe(1); }); it("triggers nextSlide on left swipe exceeding threshold", () => { const { result } = renderHook(() => useCarouselSwipe({ ...defaultConfig, swipeThreshold: 0.01 }) ); act(() => result.current.handleTouchStart(makeTouchEvent(300))); act(() => result.current.handleTouchMove(makeTouchEvent(100))); act(() => result.current.handleTouchEnd()); expect(result.current.currentIndex).toBe(1); }); it("does not change slide when swipe is below threshold", () => { const { result } = renderHook(() => useCarouselSwipe({ ...defaultConfig, swipeThreshold: 0.99 }) ); act(() => result.current.handleTouchStart(makeTouchEvent(200))); act(() => result.current.handleTouchMove(makeTouchEvent(195))); act(() => result.current.handleTouchEnd()); expect(result.current.currentIndex).toBe(0); }); }); describe("Auto-scroll", () => { it("auto-scrolls to next slide at configured interval", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 5, enableAutoScroll: true, autoScrollInterval: 3000, }) ); expect(result.current.currentIndex).toBe(0); act(() => jest.advanceTimersByTime(3000)); expect(result.current.currentIndex).toBe(1); act(() => jest.advanceTimersByTime(3000)); expect(result.current.currentIndex).toBe(2); }); it("does not auto-scroll when enableAutoScroll is false", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 5, enableAutoScroll: false, autoScrollInterval: 3000, }) ); act(() => jest.advanceTimersByTime(10000)); expect(result.current.currentIndex).toBe(0); }); it("does not auto-scroll when autoScrollInterval is 0", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 5, enableAutoScroll: true, autoScrollInterval: 0, }) ); act(() => jest.advanceTimersByTime(10000)); expect(result.current.currentIndex).toBe(0); }); it("does not auto-scroll when itemCount is 0", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 0, enableAutoScroll: true, autoScrollInterval: 3000, }) ); act(() => jest.advanceTimersByTime(10000)); expect(result.current.currentIndex).toBe(0); }); it("pauses auto-scroll on touch start", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 5, enableAutoScroll: true, autoScrollInterval: 3000, }) ); act(() => result.current.handleTouchStart(makeTouchEvent(200))); act(() => jest.advanceTimersByTime(10000)); // Should still be at 0 because auto-scroll was paused // (though the initial interval may have already ticked) expect(result.current.isSwiping).toBe(true); }); it("restarts auto-scroll after touch end when enabled", () => { const { result } = renderHook(() => useCarouselSwipe({ itemCount: 5, enableAutoScroll: true, autoScrollInterval: 3000, }) ); // Touch interaction act(() => result.current.handleTouchStart(makeTouchEvent(200))); act(() => result.current.handleTouchEnd()); // After touch end, auto-scroll should restart act(() => jest.advanceTimersByTime(3000)); expect(result.current.currentIndex).toBeGreaterThan(0); }); it("cleans up interval on unmount", () => { const clearIntervalSpy = jest.spyOn(global, "clearInterval"); const { unmount } = renderHook(() => useCarouselSwipe({ itemCount: 5, enableAutoScroll: true, autoScrollInterval: 3000, }) ); unmount(); expect(clearIntervalSpy).toHaveBeenCalled(); clearIntervalSpy.mockRestore(); }); }); describe("Responsive behavior", () => { it("detects mobile viewport", () => { Object.defineProperty(window, "innerWidth", { writable: true, value: 500, }); const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.isMobile).toBe(true); }); it("detects desktop viewport", () => { Object.defineProperty(window, "innerWidth", { writable: true, value: 1024, }); const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.isMobile).toBe(false); }); it("updates isMobile on resize", () => { Object.defineProperty(window, "innerWidth", { writable: true, value: 1024, }); const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); expect(result.current.isMobile).toBe(false); act(() => { Object.defineProperty(window, "innerWidth", { writable: true, value: 500, }); window.dispatchEvent(new Event("resize")); }); expect(result.current.isMobile).toBe(true); }); it("updates containerWidth on resize", () => { const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); // eslint-disable-next-line @typescript-eslint/no-unused-vars const initialWidth = result.current.containerWidth; act(() => { Object.defineProperty(window, "innerWidth", { writable: true, value: 800, }); window.dispatchEvent(new Event("resize")); }); // containerRef is null so falls back to window.innerWidth expect(result.current.containerWidth).toBe(800); }); it("uses the attached containerRef width when present", () => { Object.defineProperty(window, "innerWidth", { writable: true, value: 1024, }); const { result } = renderHook(() => useCarouselSwipe(defaultConfig)); const container = document.createElement("div"); Object.defineProperty(container, "offsetWidth", { configurable: true, value: 640, }); act(() => { result.current.containerRef.current = container as HTMLDivElement; window.dispatchEvent(new Event("resize")); }); expect(result.current.containerWidth).toBe(640); }); it("uses custom mobileBreakpoint", () => { Object.defineProperty(window, "innerWidth", { writable: true, value: 900, }); const { result } = renderHook(() => useCarouselSwipe({ ...defaultConfig, mobileBreakpoint: 1024 }) ); expect(result.current.isMobile).toBe(true); }); it("removes resize listeners on unmount", () => { const removeEventListenerSpy = jest.spyOn(window, "removeEventListener"); const { unmount } = renderHook(() => useCarouselSwipe(defaultConfig)); unmount(); const resizeCalls = removeEventListenerSpy.mock.calls.filter( call => call[0] === "resize" ); expect(resizeCalls.length).toBeGreaterThanOrEqual(2); // containerWidth + isMobile removeEventListenerSpy.mockRestore(); }); }); describe("SSR safety (window undefined)", () => { it("can be created without throwing when window is undefined and uses fallback containerWidth", async () => { const origWindow = globalThis.window; // @ts-expect-error - intentionally deleting window to simulate SSR delete globalThis.window; const origTE = globalThis.TextEncoder; const origTD = globalThis.TextDecoder; globalThis.TextEncoder = TextEncoder; globalThis.TextDecoder = TextDecoder; let hookResult: ReturnType | undefined; function TestComponent() { hookResult = useCarouselSwipe(defaultConfig); return null; } try { const { renderToString } = await import("react-dom/server"); // renderToString simulates real SSR: no window, no effects run renderToString(React.createElement(TestComponent)); // containerWidth should use the fallback value of 1 since window is absent expect(hookResult!.containerWidth).toBe(1); expect(hookResult!.isSwiping).toBe(false); expect(hookResult!.currentIndex).toBe(0); } finally { // Always restore globals so subsequent tests aren't affected globalThis.window = origWindow; globalThis.TextEncoder = origTE; globalThis.TextDecoder = origTD; } }); }); });